From 052fb473d05cb5c060e390fe9b2180ccbddd528a Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 11:00:53 +0100 Subject: [PATCH] gitea: remove the code bound to no event since ADR 0571 clause 13: the verdict setter, the controller's checked and plan types, the build seat's started (issue 1126) Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- modules/gitea/gate.ts | 51 +--- modules/gitea/index.ts | 125 +--------- modules/gitea/pulls.ts | 284 +---------------------- modules/gitea/test/gate.test.ts | 133 +---------- modules/gitea/test/pulls.test.ts | 155 +------------ modules/gitea/test/siblings.test.ts | 38 --- modules/gitea/test/tools-account.test.ts | 11 - modules/gitea/test/trunk.test.ts | 26 --- 8 files changed, 20 insertions(+), 803 deletions(-) diff --git a/modules/gitea/gate.ts b/modules/gitea/gate.ts index ca10290c..067dea26 100644 --- a/modules/gitea/gate.ts +++ b/modules/gitea/gate.ts @@ -1,8 +1,7 @@ // **One writer of a head's mesh/merge-gate, and the verdict lands last** (novox/hq issue 446). // -// A pending check says where it is: "waiting in the build queue since …" when the head is announced, "building on -// since …" when a build machine takes the check — the build seat's `started` (novox/hq ADR 0157) — and the -// verdict when the controller says `checked`. The forge keeps only the newest status of a context, so a pending word +// A pending check says where it is: "waiting in the build queue since …" when the head is announced, and the verdict when +// it is said. The forge keeps only the newest status of a context, so a pending word // written after the verdict hides it: a pass reads pending and the merge waits for ever; a failure hides under // "building". Two writers cannot order their writes, and a writer that reads the forge before writing races the other // between its read and its write. So this module is the one writer of mesh/merge-gate, and every write of one head's @@ -20,16 +19,6 @@ import { CHECK_CONTEXT, pendingWords } from "./pulls.js"; import type { CommitStatus, HeadCheck } from "./client.js"; -/** The build seat's `started`, as mesh-controller's link.BuildStart publishes it. */ -export interface BuildStarted { - id?: string; - repository?: string; - path?: string; - ref?: string; - on?: string; - at?: string; -} - /** What the gate writer needs of the forge: a status set, and a head's gate as the forge holds it. */ export interface GateForge { setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise; @@ -77,15 +66,9 @@ export function openedAt(history: GateStatus[]): number | undefined { /** How far a build machine's clock may stand behind this one's when a start is weighed against the pending words. */ const CLOCK_SLACK_MS = 60_000; -/** How the words of a check a machine is running begin. */ +/** How the words of a check a machine was running began: a status the forge still holds from before is no opening of a check. */ const BUILDING = "building on "; -/** The words of a check a machine is running, in the time the operator reads. */ -export function buildingWords(on: string, at: Date): string { - const two = (n: number) => String(n).padStart(2, "0"); - return `${BUILDING}${on} since ${two(at.getHours())}:${two(at.getMinutes())}`; -} - /** Whether a repository as the mesh clones it (`http://forge/novox/hq.git`) is owner/repo. */ export function clonedAs(url: string | undefined, owner: string, repo: string): boolean { const u = (url ?? "").trim().toLowerCase().replace(/\.git$/, ""); @@ -196,34 +179,6 @@ export class GateWriter { }).then(() => written); } - /** - * A build machine took an ask of this head: said while no verdict was heard, and only for the check now pending — - * a start from before the pending words were set (a run before a recheck, the stream's history) is not this check's. - * A head this process did not announce (it restarted since) is weighed against the forge's own gate: said only while - * that is pending, and not for a start older than it. - */ - started(owner: string, repo: string, sha: string, b: BuildStarted): Promise { - const at = b.at ? new Date(b.at) : new Date(NaN); - if (!b.on || Number.isNaN(at.getTime())) return Promise.resolve(false); - const h = this.head(owner, repo, sha); - let said = false; - return this.said(h, `${owner}/${repo}@${sha.slice(0, 8)}: could not say where its check is`, async () => { - if (h.verdict) return; - let since = h.pendingAt; - if (since === undefined) { - const gate = await this.forge.gateOf(owner, repo, sha); - // No gate on the forge: none was written (its base does not require it, issue 1301), and nothing is said. - if (!gate || gate.state !== "pending") return; - since = gate?.updated_at ? Date.parse(gate.updated_at) : undefined; - } - if (since !== undefined && at.getTime() < since - CLOCK_SLACK_MS) return; - if (h.verdict) return; // heard while the forge was read - await this.forge.setCommitStatus(owner, repo, sha, { state: "pending", context: CHECK_CONTEXT, - description: buildingWords(b.on as string, at) }); - said = true; - }).then(() => said); - } - /** * The verdict: marked at once, so no pending word is written after it, then written in its turn — last. A write * that fails fails the caller, so the event is offered again. diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index 9271f010..fd4380c6 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -9,13 +9,9 @@ // // module.gitea.pull.closed — a pull request closed unmerged: the delivery it was is stopped (novox/hq ADR 0239) // -// Once consumed mesh-controller.checked — a pull request's merge check, judged — and set it as the head -// commit's statuses (setVerdict, kept below for a revert, and bound to no event since ADR 0571 clause 13): `mesh/merge-gate`, the modules of the mesh's graph the change touches, and -// `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment -// saying why when the gate is not a pass or the repository's own check failed. Of the two, only those the base -// still requires are written (issue 1301): main requires `mesh/check` — the checker's — everywhere, hq included since -// ADR 0527 §2, and since R4g no controller judges a merge (its merge gate went with build-agent, ADR 0524 §5), so nothing -// publishes a merge check's verdict, this module consumes no event of the controller, and these two are written for no base. +// It consumes no event of the controller: no controller judges a merge (ADR 0524 §5, ADR 0571 clause 13), and the two older +// statuses, `mesh/merge-gate` and `mesh/repo-check`, are written for no base that main requires (issue 1301); main +// requires `mesh/check`, the checker's, everywhere, hq included since ADR 0527 §2. // // Reads mesh-delivery-checker.owed — what the checker owes the forge, mesh/check on each head it judged — by a watch, and // writes it (check.ts; ADR 0521, R4e finding R2-1). No tool writes mesh/check (finding K1), and no event: the watch hands @@ -53,14 +49,13 @@ import { announce, keepWatermark, readWatermark } from "./announce.js"; import { mergeTool } from "./merge.js"; import { GiteaAdmin, GiteaClient, movedSince } from "./client.js"; import { keepMainRules, mainRulesStep } from "./mainrules.js"; -import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headKey, maintained, - requiredOf, statusesFor, writesGate, type Announced, type Checked, repoCheckFacts } from "./pulls.js"; +import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, headKey, requiredOf, writesGate, type Announced } from "./pulls.js"; import { actOn, announceNewHeads, startPass } from "./watch.js"; import { GateWriter } from "./gate.js"; import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js"; -import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js"; +import { keepSiblings, readSiblings, siblingsFile, weighSiblings } from "./siblings.js"; import { bindInBackground, bindUntilHeld } from "./bind.js"; -import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js"; +import { announcementOf, weighTrunks } from "./trunk.js"; import { ReadAll, readAllTools } from "./readall.js"; import { DeliveredWrites, INDEXER_MODULES, writeTools, type ModulesChange } from "./writes.js"; import { TOOLS_ACCOUNT, TOOLS_ORG, meshAccounts } from "./tools-account.js"; @@ -356,114 +351,6 @@ async function siblingMoves(client: GiteaClient, repository: string, number?: nu } } -// **The verdict, set where the pull request shows it.** Every verdict is the head commit's status; one -// that is not a pass also leaves the check's own account as a comment, so the reason is read where the -// change is reviewed. An error — the check could not run — is the forge's `error`, never a success. -async function setVerdict(client: GiteaClient, event: { body: unknown }): Promise { - const c = (event.body ?? {}) as Checked; - if (!c.group && c.owner && c.repo && c.took) { - // How long it took, for the merge tool to say how long the next one may (novox/hq ADR 0323 §5). - keepTook(tookFile(process.env.MESH_GITEA_STATE_DIR), `${c.owner}/${c.repo}`, - { took: c.took, at: new Date().toISOString(), head: c.commit }); - } - if (c.group) { - // A delivery group's composed check (novox/hq ADR 0239, ADR 0403 §1): its verdict is each member's merge gate, at the - // head it read; the group's own status, mesh/delivery-group, is its owner's to say. - await setGroupVerdict(client, c); - return; - } - if (!c.owner || !c.repo || !c.commit || !c.verdict) { - console.error("[gitea] a merge check's verdict named no repository, commit or verdict; ignored"); - return; - } - // The siblings this verdict read are kept for the member (ADR 0382 §5); a verdict that read none removes the record. - const siblings = siblingsOf(c.siblings); - if (c.number && noteVerdict(siblingNamed, `${c.owner}/${c.repo}#${c.number}`.toLowerCase(), c.commit, siblings)) { - keepSiblings(siblingsRecord, siblingNamed); - } - // A sibling that moved while the check ran is weighed once this verdict has landed, in the head's own turn after it. - if (siblings.length) { - for (const repository of new Set(siblings.map((s) => s.repository))) { - void Promise.resolve().then(() => siblingMoves(client, repository)); - } - } - // Each status links to the pull request, where the delivery's view is kept (novox/hq ADR 0239). - let target: string | undefined; - let base: string | undefined; - if (c.number) { - const pull = await client.getPullRequest(c.owner, c.repo, c.number).catch(() => undefined); - target = pull?.html_url || undefined; - base = pull?.base || undefined; - } - // **A verdict judged against a trunk commit that is no longer the trunk's head is out of date when it lands** - // (novox/hq ADR 0323 §4): a check asked before the trunk moved. It is said so, pending with the reason, in place of - // its pass or failure; a trunk that cannot be read lets the verdict stand, and the merge tool compares the commits. - const trunk = base ?? c.plan?.base; - // Only the statuses the base still requires are written (issue 1301): on hq, mesh/repo-check alone. - const required = trunk ? requiredOf(await client.appliedProtection(c.owner, c.repo, trunk).catch(() => undefined)) : undefined; - const kept = maintained(required); - if (c.trunk && trunk) { - const head = await client.branchHead(c.owner, c.repo, trunk).catch(() => undefined); - if (head && !sameCommit(c.trunk, head)) { - const description = outOfDateWords(c.trunk, head); - for (const context of [...(composedGate(c) ? [] : [CHECK_CONTEXT]), ...(c["repo-check"] ? [REPO_CHECK_CONTEXT] : [])] - .filter((x) => kept.includes(x))) { - await client.setCommitStatus(c.owner, c.repo, c.commit, { state: "pending", context, description, - ...(target ? { target_url: target } : {}) }); - } - // Its account is kept and shown all the same (ADR 0323 §7): what it judged, and that it is out of date. - const said = commentFor(c, await repoCheckFacts(client, c, trunk), required); - if (c.number && !composedGate(c)) await client.addComment(c.owner, c.repo, c.number, lateVerdictComment(c, said, description)); - console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}, ` + - `${description}`); - return; - } - } - const facts = await repoCheckFacts(client, c, base ?? c.plan?.base); - const gateNow = composedGate(c) ? (await client.headChecks(c.owner, c.repo, c.commit).catch(() => ({} as Record)))[CHECK_CONTEXT]?.description : undefined; - for (const status of statusesFor(c, facts, gateNow, required)) { - await client.setCommitStatus(c.owner, c.repo, c.commit, target ? { ...status, target_url: target } : status); - } - const comment = composedGate(c) ? null : commentFor(c, facts, required); - if (comment && c.number) await client.addComment(c.owner, c.repo, c.number, comment); - console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}`); -} - -/** **The composed verdict sets each member's gate** (novox/hq ADR 0403 §1, issue 978), in each head's own turn of the gate - * writer (gate.ts), judged against the trunk as a member's own verdict is: one that names a trunk commit that is no longer - * the trunk's head is out of date when it lands, and said so, pending. */ -async function setGroupVerdict(client: GiteaClient, c: Checked): Promise { - // The base branch each member merges into, read with its pull request below: the composed verdict names the siblings of - // each member from it (issue 1049). - const bases = new Map(); - for (const m of c.members ?? []) { - if (!m.owner || !m.repo || !m.commit || !c.verdict) { - console.error(`[gitea] a composed verdict of ${c.group} names a member with no repository, commit or verdict; ignored`); - continue; - } - await gates.verdict(m.owner, m.repo, m.commit, async () => { - const pull = m.number ? await client.getPullRequest(m.owner, m.repo, m.number).catch(() => undefined) : undefined; - const target = pull?.html_url || undefined; - if (m.number && pull?.base) bases.set(`${m.owner}/${m.repo}#${m.number}`, pull.base); - let status = groupGateStatus(c, m); - if (m.trunk && pull?.base) { - const head = await client.branchHead(m.owner, m.repo, pull.base).catch(() => undefined); - if (head && !sameCommit(m.trunk, head)) status = { state: "pending", context: CHECK_CONTEXT, description: outOfDateWords(m.trunk, head) }; - } - await client.setCommitStatus(m.owner, m.repo, m.commit, target ? { ...status, target_url: target } : status); - console.log(`[gitea] ${m.owner}/${m.repo}#${m.number ?? "?"} at ${m.commit.slice(0, 8)}: the group ${c.group}'s composed check ${c.verdict}, ` + - `its merge gate ${status.state}`); - }, c.id); - } - // The siblings of each member are the other members at the heads and base commits this verdict composed (issue 1049): a - // sibling's own merge of such a head is no move of the member's base. Kept for what the merge tool and the siblings' - // weighing read; nothing is run. - if (c.verdict && c.members?.length) { - const members = c.members.map((m) => ({ ...m, base: m.number ? bases.get(`${m.owner}/${m.repo}#${m.number}`) : undefined })); - if (noteComposed(siblingNamed, members)) keepSiblings(siblingsRecord, siblingNamed); - } -} - // **This module is the one writer of a head's mesh/merge-gate** (novox/hq issue 446): the announcement's pending words, // where the check is when a build machine takes it, and the verdict — each head's writes in one chain, the verdict last // (gate.ts). A second writer elsewhere raced the verdict and could leave a pass reading pending. diff --git a/modules/gitea/pulls.ts b/modules/gitea/pulls.ts index 957e43da..16a01183 100644 --- a/modules/gitea/pulls.ts +++ b/modules/gitea/pulls.ts @@ -1,17 +1,15 @@ // A pull request's merge check (novox/hq to-be 45 §9): what the forge's announcer says when a pull -// request's head moves, and what it sets as the pull request's status when the mesh says the verdict. +// request's head moves, and the status contexts the check is kept under. // -// **Before merge, never after.** Every check the mesh had ran after a merge, on a machine: a manifest the -// node-engine refuses (issue 236), an identity a real machine's name made too long (263). So each new head -// of an open pull request is announced as `pull.updated`; the controller asks the build seat to check it -// against every machine of the mesh's facts; and the verdict comes back as the controller's `checked`, -// which this sets as the head commit's status — and, when it is not a pass, as a comment saying why. +// **Before merge, never after.** Each new head of an open pull request is announced as `pull.updated`, and the mesh +// checks it against every machine of its facts before it merges. The verdict is the checker's `mesh/check`, which this +// module writes from the checker's owed state (check.ts); the controller's `checked`, which once set the two older +// statuses, is no event any more (ADR 0571 clause 13). // -// Pure functions here, so they are tested without a forge; index.ts does the asking and the setting. +// Pure functions here, so they are tested without a forge. -import type { CommitStatus, GiteaClient, GiteaPull, GiteaRepo } from "./client.js"; +import type { GiteaPull, GiteaRepo } from "./client.js"; import { movedSince } from "./client.js"; -import { UNKNOWN_MARK, besideWords, siblingsOf } from "./siblings.js"; /** The status context a merge check's gate is kept under: one per commit, the newest replacing the last. */ export const CHECK_CONTEXT = "mesh/merge-gate"; @@ -59,88 +57,6 @@ export function pendingWords(announced: Date): string { return `waiting in the build queue since ${two(announced.getHours())}:${two(announced.getMinutes())}`; } -/** One layer of a check, judged. */ -export interface Layer { - verdict: string; - summary: string; - /** The modules a merge of the change would move, as the controller's planner reckons it… */ - modules?: string[]; - /** …and those it would build after them because they stand on them. */ - dependents?: string[]; -} - -/** What the controller says as `checked` (mesh-controller internal/link, Checked). */ -export interface Checked { - owner: string; - repo: string; - number?: number; - commit: string; - verdict: string; - summary: string; - report?: string; - id: string; - on?: string; - /** The gate — the modules of the mesh's graph the change touches — and the repository's own check. A - * controller from before the layers says neither, and its verdict is the gate's. */ - gate?: Layer; - "repo-check"?: Layer; - /** The change plan of the commit checked (novox/hq ADR 0238): what a merge of it would build and send. */ - plan?: ChangePlan; - /** Set on a delivery group's composed check (novox/hq ADR 0239): its verdict is each member's merge gate (ADR 0403 §1, - * issue 978), set in setGroupVerdict, never read as this head's own. */ - group?: string; - /** The members a composed check read, each at its head and the trunk commit it was merged onto (ADR 0323 §6). */ - members?: { owner: string; repo: string; number?: number; commit: string; trunk?: string }[]; - /** The trunk commit the head was merged into and judged as, twelve characters (novox/hq ADR 0323 §3); absent from a - * controller whose build seat judged the head alone. Beside are the other repositories' commits the check read, - * by directory; took how long it ran. */ - trunk?: string; - beside?: Record; - took?: string; - /** The other members of its delivery group the check read beside the head, each at its head merged onto its base - * commit (novox/hq ADR 0382 §4); absent when it read none, and from a controller from before the rule. */ - siblings?: { repository: string; number: number; head: string; base: string; base_commit: string }[]; - /** Why the siblings could not be learned (mesh-delivery did not answer), when that is so. */ - siblings_unknown?: string; - /** Where the judge came from, when it came from the group (ADR 0382 §3): said in the account. */ - judge_from?: string; -} - -/** A change plan, as the controller says it (mesh-controller internal/link, ChangePlan). */ -export interface ChangePlan { - repository: string; - base: string; - head: string; - moved?: string[]; - dependents?: string[]; - new?: string[]; - unread?: string[]; - tiers?: string[][]; - machines?: { machine: string; receives?: string[]; waits?: string[] }[]; - steps?: string[]; - summary: string; -} - -/** Whether a plan builds anything: a change that touches the mesh's graph. */ -function builds(plan?: ChangePlan): boolean { - return !!plan && ((plan.moved?.length ?? 0) > 0 || (plan.new?.length ?? 0) > 0); -} - -/** A delivery plan (the controller's ChangePlan) as a person reads it on the pull request. */ -export function planText(plan: ChangePlan): string { - const lines = [`**Delivery plan** — ${plan.summary}`]; - (plan.tiers ?? []).forEach((tier, i) => lines.push(`- tier ${i}: ${tier.join(", ")}`)); - for (const m of plan.machines ?? []) { - const parts: string[] = []; - if (m.receives?.length) parts.push(`receives ${m.receives.join(", ")}`); - if (m.waits?.length) parts.push(`waits for a person: ${m.waits.join(", ")}`); - lines.push(`- ${m.machine}: ${parts.join("; ")}`); - } - for (const step of plan.steps ?? []) lines.push(`- ${step}`); - if (plan.unread?.length) lines.push(`- read by no module's build: ${plan.unread.join(", ")}`); - return lines.join("\n"); -} - /** The heads already announced, keyed by `headKey`, so a restart announces nothing twice. */ export type Announced = Record; @@ -176,197 +92,11 @@ export function reposToLook(repos: GiteaRepo[], floor: string, pulls: PullSeen[] return repos.filter((r) => moved.has(r.full_name)); } -// **A required status is success or it blocks** (novox/hq issue 293). The forge combines `warning` as a -// failure, and a branch whose protection requires mesh/merge-gate or mesh/repo-check — with no -// administrator override — will not merge past one. So the controller's `warning`, a note and never a -// question, is set as `success` with the note in its description; only what a person must decide is a -// `failure`, with why. The mapping, verdict by verdict: -// -// pass → success -// warning (a note: rebuild width, a problem -// already so on the base, a script's own) → success, "pass, with a note: …" -// repo-check, no merge-check.sh, not required → success, "no repository check defined" -// repo-check, no merge-check.sh, required (or -// the protection unreadable) → failure: a person adds one, or lifts the requirement -// fail → failure -// error, or no verdict → error — never a success - -/** The forge's state for a verdict: an error is the forge's `error`, never a success; a warning is a note. */ -function stateOf(verdict: string): CommitStatus["state"] { - return verdict === "pass" || verdict === "warning" ? "success" : verdict === "fail" ? "failure" : "error"; -} - -/** How a verdict is said in a status's description: a warning as the pass with a note it is. */ -function saidAs(verdict: string): string { - return verdict === "warning" ? "pass, with a note" : verdict || "error"; -} - -function described(verdict: string, summary: string, modules?: string[], dependents?: string[]): string { - // The forge keeps a short description; the rest is the comment's. - let d = `${saidAs(verdict)}: ${summary}`; - if (modules?.length) d += ` [${modules.join(", ")}${dependents?.length ? ` +${dependents.length} dependent(s)` : ""}]`; - return clipped(d); -} - export function clipped(d: string): string { d = d.replace(/\s+/g, " ").trim(); return d.length > 140 ? d.slice(0, 139) + "…" : d; } -/** What the forge says of a repository's own check beyond the controller's verdict, asked by index.ts only - * when the verdict is a warning: whether the head holds a merge-check.sh, and whether the base branch's - * protection requires mesh/repo-check. Unknown is undefined. */ -export interface RepoCheckFacts { - defined?: boolean; - required?: boolean; -} - -// **What only the forge knows of a repository check said as a warning** (novox/hq issue 293): whether the -// head holds a merge-check.sh at all, and — when it does not — whether the base branch's protection -// requires mesh/repo-check. Asked only for a warning; unknown is left undefined, which statusesFor reads -// as possibly required: a failure on a status nothing requires blocks nothing, a success on one that is -// required would let an untested repository merge. The base's protection is the one the forge applies, read -// from the branch: the rules' own list is a repository administrator's, which the tools' account is not -// (novox/hq ADR 0365). -export async function repoCheckFacts( - forge: Pick, c: Checked, base: string | undefined): Promise { - if (c["repo-check"]?.verdict !== "warning") return {}; - const defined = await forge.holdsFile(c.owner, c.repo, c.commit, "merge-check.sh").catch(() => undefined); - if (defined !== false) return { defined }; - const rule = await forge.appliedProtection(c.owner, c.repo, base || "main").catch(() => undefined); - if (rule === undefined) return { defined }; - const required = !!rule?.enable_status_check && (rule.status_check_contexts ?? []).includes(REPO_CHECK_CONTEXT); - return { defined, required }; -} - -/** The description of a required repository check that is not defined. */ -export const UNDEFINED_REQUIRED = "fail: mesh/repo-check is required here and this head defines no merge-check.sh — " + - "a person adds one, or lifts the requirement from the branch's protection"; - -/** The repository check's status: its verdict, unless the repository defines no check at all — then - * success where the check is not required, and a failure for a person where it is (or may be). */ -function repoStatus(repo: Layer, facts: RepoCheckFacts): CommitStatus { - if (repo.verdict === "warning" && facts.defined === false) { - return facts.required === false - ? { state: "success", context: REPO_CHECK_CONTEXT, description: "no repository check defined" } - : { state: "failure", context: REPO_CHECK_CONTEXT, description: clipped(UNDEFINED_REQUIRED) }; - } - return { state: stateOf(repo.verdict), context: REPO_CHECK_CONTEXT, description: described(repo.verdict, repo.summary) }; -} - -/** Whether the repository check is a failure a person must read: failed, could not run, or required and - * not defined. */ -function repoWrong(repo: Layer | undefined, facts: RepoCheckFacts): boolean { - return !!repo && repoStatus(repo, facts).state !== "success"; -} - /** What a group member's gate says while the group's composed check is the one that judges it: the member's own check can say * nothing more of it, so asking that check again brings no verdict that names a trunk (novox/hq issue 1106). */ export const WAITS_FOR_COMPOSED = "waits for its group's composed check"; - -/** The forge's status for the gate. */ -export function statusFor(c: Checked): CommitStatus { - const gate = c.gate ?? { verdict: c.verdict, summary: c.summary }; - // With a plan, the status says what the change does and how it was judged: "pass: builds gitea → anchor; - // no bus step; every machine composes…". - const description = builds(c.plan) - ? described(gate.verdict, `${c.plan!.summary}; ${gate.summary}`) - : described(gate.verdict, gate.summary, gate.modules, gate.dependents); - return judgedFirst(c, { state: stateOf(gate.verdict), context: CHECK_CONTEXT, description }); -} - -/** - * **A member's gate from its group's composed verdict** (novox/hq ADR 0403 §1, issue 978): the composed check, which - * composed every machine with all the members' heads, sets each member's `mesh/merge-gate` at the head it read. The - * description names what it judged first, as a member's own gate does (ADR 0323 §3), and the group. - */ -export function groupGateStatus(c: Checked, m: NonNullable[number]): CommitStatus { - const said = described(c.verdict, `group ${c.group} composed: ${c.summary}`); - const first = m.trunk ? `head ${m.commit.slice(0, 12)} merged into trunk ${m.trunk.slice(0, 12)} — ` : ""; - return { state: stateOf(c.verdict), context: CHECK_CONTEXT, description: clipped(first + said) }; -} - -/** Whether a verdict's gate layer is the group's composed check's (ADR 0403 §1): no gate was run for the head alone. */ -export function composedGate(c: Checked): boolean { - return c.gate?.verdict === "composed"; -} - -/** Every status a verdict sets: the gate's, and the repository's own check's when it was said; of them, only those the - * base requires (`required`, the base's rule: maintained). */ -export function statusesFor(c: Checked, facts: RepoCheckFacts = {}, gateNow?: string, required?: string[]): CommitStatus[] { - const kept = maintained(required); - return allStatusesFor(c, facts, gateNow).filter((s) => kept.includes(s.context)); -} - -function allStatusesFor(c: Checked, facts: RepoCheckFacts = {}, gateNow?: string): CommitStatus[] { - if (composedGate(c)) { - // A member whose gate the group's composed check judges (ADR 0403 §1): only its repository's check is its own to - // set. The gate stays pending, saying it waits for the group, unless a composed verdict already stands on it. - const out: CommitStatus[] = []; - if (!(gateNow ?? "").includes(" composed: ")) { - out.push({ state: "pending", context: CHECK_CONTEXT, description: WAITS_FOR_COMPOSED }); - } - const repo = c["repo-check"]; - if (repo) out.push(judgedFirst(c, repoStatus(repo, facts))); - return out; - } - const out = [statusFor(c)]; - const repo = c["repo-check"]; - if (repo) out.push(judgedFirst(c, repoStatus(repo, facts))); - return out; -} - -/** **A status names what its verdict judged, first** (novox/hq ADR 0323 §3): "head 1a2b3c4d5e6f merged into trunk - * 7a8b9c0d1e2f — pass: …", so the forge's 140 characters never clip it away; the trunk's move is weighed by it. A - * verdict that names no trunk commit is said as before. */ -function judgedFirst(c: Checked, status: CommitStatus): CommitStatus { - // The siblings it read, named after the commits (ADR 0382 §4), so the clip never takes them either. - const siblings = siblingsOf(c.siblings); - // A verdict that could not learn them says so on the status too (clause 1: it is still checked, and merges). - const mark = siblings.length ? besideWords(siblings) : c.siblings_unknown ? UNKNOWN_MARK : ""; - if (!c.trunk) return mark ? { ...status, description: clipped(`${mark} — ${status.description}`) } : status; - return { ...status, description: clipped(`head ${c.commit.slice(0, 12)} merged into trunk ${c.trunk.slice(0, 12)}${mark ? " " + mark : ""} — ${status.description}`) }; -} - -/** The comment a verdict leaves on its pull request, with the check's own account: the change plan of a - * change that builds something (novox/hq ADR 0238), and why, when the gate is not a pass or the - * repository's own check failed or could not run. A repository with no merge-check.sh, touching nothing, - * is said by its statuses alone, not by a comment on every push. */ -export function commentFor(c: Checked, facts: RepoCheckFacts = {}, required?: string[]): string | null { - const gate = c.gate ?? { verdict: c.verdict, summary: c.summary }; - // A base that no longer requires the gate (issue 1301) is told nothing of it: its verdict is the repository's own check. - const withGate = writesGate(required); - const repo = c["repo-check"]; - const wrong = repoWrong(repo, facts); - if ((!withGate || gate.verdict === "pass") && !wrong && !builds(c.plan)) return null; - const lines = [`**Merge check** at \`${c.commit.slice(0, 8)}\``, ""]; - if (c.trunk) { - // What was judged (novox/hq ADR 0323 §3): the head merged into the trunk commit, and what was read beside it. - const beside = Object.entries(c.beside ?? {}).map(([d, sha]) => `${d} \`${sha}\``).join(", "); - lines.splice(1, 0, `Judged: head \`${c.commit.slice(0, 12)}\` merged into trunk \`${c.trunk.slice(0, 12)}\`` + - (beside ? `; read beside it: ${beside}` : "")); - } - const siblings = siblingsOf(c.siblings); - if (siblings.length) { - lines.splice(c.trunk ? 2 : 1, 0, `Siblings read beside it: ` + siblings.map((s) => - `${s.repository}#${s.number} \`${s.head.slice(0, 12)}\` merged onto \`${s.base_commit.slice(0, 12)}\` (${s.base})`).join(", ") + - (c.judge_from ? `; judged by ${c.judge_from}` : "")); - } else if (c.siblings_unknown) { - lines.splice(c.trunk ? 2 : 1, 0, `Siblings could not be learned: ${c.siblings_unknown}`); - } - if (withGate) { - lines.push(`- \`${CHECK_CONTEXT}\`: **${saidAs(gate.verdict).toUpperCase()}** — ${gate.summary}` + - (gate.modules?.length ? ` (modules: ${gate.modules.join(", ")}` + - (gate.dependents?.length ? `; built after them: ${gate.dependents.join(", ")}` : "") + ")" : "")); - } - if (repo) { - const st = repoStatus(repo, facts); - lines.push(`- \`${REPO_CHECK_CONTEXT}\`: ` + (repo.verdict === "warning" && facts.defined === false - ? `**${st.state === "success" ? "PASS" : "FAIL"}** — ${st.description.replace(/^fail: /, "")}` - : `**${saidAs(repo.verdict).toUpperCase()}** — ${repo.summary}`)); - } - if (builds(c.plan)) lines.push("", planText(c.plan!)); - const ran = c.on ? `\n\nRun by the build seat on ${c.on} as \`${c.id}\` (\`builds --log ${c.id}\`).` : ""; - const report = c.report ? `\n\n\`\`\`\n${c.report.replace(/```/g, "'''")}\n\`\`\`` : ""; - return lines.join("\n") + ran + report; -} diff --git a/modules/gitea/test/gate.test.ts b/modules/gitea/test/gate.test.ts index 82cea9ff..590f1c3e 100644 --- a/modules/gitea/test/gate.test.ts +++ b/modules/gitea/test/gate.test.ts @@ -2,18 +2,11 @@ import assert from "node:assert/strict"; import { test } from "node:test"; // **A pending check says where it is, and the verdict lands last** (novox/hq issue 446). This module is the one writer -// of a head's mesh/merge-gate: the announcement's pending words, "building on since …" when the build seat -// says a machine took the check, and the verdict. The forge keeps the newest status of a context, so a pending word +// of a head's mesh/merge-gate: the announcement's pending words and the verdict. The forge keeps the newest status of a context, so a pending word // written after the verdict hides it — a pass reads pending, a failure hides under "building". const SHA = "a6b08de8507c1703528277e5cefc80548dbc9bb3"; -// The build seat's `started` as a machine said it for novox/hq#309's check on 2026-10-11 (its log, read with the -// controller's `builds --log build-1791680941048622697`, opens at 03:09:01 on shanks); only the forge's address -// replaced. The shape is mesh-controller's link.BuildStart. -const startedOnShanks = { id: "build-1791680941048622697", repository: "http://127.0.0.1:20000/novox/hq.git", - ref: SHA, on: "shanks", at: "2026-10-11T01:09:01.204319Z" }; - /** A forge that keeps the newest status per context, as gitea does, and whose writes take as long as a test says. */ function aForge() { const kept: Record = {}; @@ -71,99 +64,8 @@ function aForge() { /** The clock of the test: the head announced at 01:08:50Z. */ const announcedAt = () => new Date("2026-10-11T01:08:50Z"); -test("a build machine taking the check is said on the head: building on which machine, since the local time", async () => { - const { GateWriter, buildingWords } = await import("../gate.ts"); - const forge = aForge(); - const gates = new GateWriter(forge, announcedAt); - await gates.pending("novox", "hq", SHA); - assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), true); - assert.equal(forge.kept["mesh/merge-gate"].state, "pending"); - assert.equal(forge.kept["mesh/merge-gate"].description, buildingWords("shanks", new Date(startedOnShanks.at))); - assert.equal(buildingWords("shanks", new Date(2026, 9, 11, 3, 9, 1)), "building on shanks since 03:09"); -}); - -test("the verdict heard while a 'building' write is in flight lands last and stays", async () => { - const { GateWriter } = await import("../gate.ts"); - const forge = aForge(); - const gates = new GateWriter(forge, announcedAt); - await gates.pending("novox", "hq", SHA); - forge.holdWrites(); // the 'building' request is in flight when the verdict comes - const building = gates.started("novox", "hq", SHA, startedOnShanks); - await new Promise((r) => setImmediate(r)); - const verdict = gates.verdict("novox", "hq", SHA, () => - forge.setCommitStatus("novox", "hq", SHA, { state: "success", context: "mesh/merge-gate", description: "pass" })); - forge.release(); - await Promise.all([building, verdict]); - assert.equal(`${forge.kept["mesh/merge-gate"].state} ${forge.kept["mesh/merge-gate"].description}`, "success pass", - `the verdict is not the newest status: ${forge.written.join(" | ")}`); -}); - -// The race the review of mesh-catalog#202 reproduced: a writer reads the forge's gate (pending), the verdict is written, -// then the writer writes "building" — and the pass reads pending until the next push. A head this process did not -// announce is the one case where the forge is read before writing. -test("the verdict written while a start's read of the forge is in flight stays the newest status", async () => { - const { GateWriter } = await import("../gate.ts"); - const forge = aForge(); - forge.kept["mesh/merge-gate"] = { state: "pending", description: "waiting", updated_at: "2026-10-11T01:08:50Z" }; - const gates = new GateWriter(forge, announcedAt); - forge.holdReads(); - const building = gates.started("novox", "hq", SHA, startedOnShanks); - await new Promise((r) => setImmediate(r)); - const verdict = gates.verdict("novox", "hq", SHA, () => - forge.setCommitStatus("novox", "hq", SHA, { state: "success", context: "mesh/merge-gate", description: "pass" })); - await new Promise((r) => setImmediate(r)); - forge.releaseReads(); - await Promise.all([building, verdict]); - assert.equal(`${forge.kept["mesh/merge-gate"].state} ${forge.kept["mesh/merge-gate"].description}`, "success pass", - `the verdict is not the newest status: ${forge.written.join(" | ")}`); -}); - -test("the verdict heard while a 'building' write still waits its turn: the 'building' is never written", async () => { - const { GateWriter } = await import("../gate.ts"); - const forge = aForge(); - const gates = new GateWriter(forge, announcedAt); - forge.holdWrites(); // the pending words are still being written - const pending = gates.pending("novox", "hq", SHA); - const building = gates.started("novox", "hq", SHA, startedOnShanks); - const verdict = gates.verdict("novox", "hq", SHA, () => - forge.setCommitStatus("novox", "hq", SHA, { state: "failure", context: "mesh/merge-gate", description: "fail: 0 of 4 compose" })); - forge.release(); - await Promise.all([pending, verdict]); - assert.equal(await building, false); - assert.equal(forge.kept["mesh/merge-gate"].state, "failure"); - assert.ok(!forge.written.some((w) => w.includes("building")), forge.written.join(" | ")); -}); - -test("a start heard after the verdict writes nothing", async () => { - const { GateWriter } = await import("../gate.ts"); - const forge = aForge(); - const gates = new GateWriter(forge, announcedAt); - await gates.pending("novox", "hq", SHA); - await gates.verdict("novox", "hq", SHA, () => - forge.setCommitStatus("novox", "hq", SHA, { state: "success", context: "mesh/merge-gate", description: "pass" })); - assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false); - assert.equal(forge.kept["mesh/merge-gate"].state, "success"); -}); - -test("a start from before the check now pending (a run before a recheck, the stream's history) writes nothing", async () => { - const { GateWriter } = await import("../gate.ts"); - const forge = aForge(); - let now = announcedAt(); - const gates = new GateWriter(forge, () => now); - await gates.pending("novox", "hq", SHA); - await gates.verdict("novox", "hq", SHA, () => - forge.setCommitStatus("novox", "hq", SHA, { state: "failure", context: "mesh/merge-gate", description: "error" })); - now = new Date("2026-10-11T01:30:00Z"); // checked again: the recheck's pending, as the tool writes it (issue 456) - await gates.rechecked("novox", "hq", SHA, { state: "pending", context: "mesh/merge-gate", description: "checking again: flaky" }); - assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false, "the first run's start"); - assert.equal(forge.kept["mesh/merge-gate"].description, "checking again: flaky"); - assert.equal(await gates.started("novox", "hq", SHA, { ...startedOnShanks, at: "2026-10-11T01:31:00Z" }), true, - "the second run's start"); -}); - // **A recheck's pending goes through the one writer** (novox/hq issue 456). mesh-delivery's recheck puts the gate back -// to pending through the gitea_commit_status tool; before, that write bypassed the head's chain: the first run's -// verdict mark stayed set, so the new run never said "building", and an old run's verdict could land over the pending. +// to pending through the gitea_commit_status tool. /** The ask of a check, as the controller's link.NewBuildID names it: when it was asked, in unix nanoseconds. */ const askOf = (at: string) => `build-${BigInt(Date.parse(at)) * 1_000_000n}`; @@ -209,20 +111,6 @@ test("an old run's verdict in flight when the recheck comes lands before the rec assert.equal(gateOf(forge), "pending checking again: main moved on", forge.written.join(" | ")); }); -test("a rechecked head says where its new run is: building on which machine", async () => { - const { GateWriter, buildingWords } = await import("../gate.ts"); - const forge = aForge(); - let now = announcedAt(); - const gates = new GateWriter(forge, () => now); - await gates.pending("novox", "hq", SHA); - await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun); - now = recheckAt; - await gates.rechecked("novox", "hq", SHA, checkingAgain); - const at = "2026-10-11T01:30:03Z"; - assert.equal(await gates.started("novox", "hq", SHA, { ...startedOnShanks, id: secondRun, at }), true); - assert.equal(forge.kept["mesh/merge-gate"].description, buildingWords("shanks", new Date(at))); -}); - test("the recheck's own verdict is written, even asked a little before the pending by another machine's clock", async () => { const { GateWriter } = await import("../gate.ts"); const forge = aForge(); @@ -306,16 +194,6 @@ test("when a check was asked is read from its id; the opening of the check now p assert.equal(openedAt([]), undefined); }); -test("a head this process did not announce (it restarted) is said only while the forge holds its gate pending", async () => { - const { GateWriter } = await import("../gate.ts"); - const forge = aForge(); - forge.kept["mesh/merge-gate"] = { state: "success", description: "pass", updated_at: "2026-10-11T01:20:00Z" }; - const gates = new GateWriter(forge, announcedAt); - assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false); - forge.kept["mesh/merge-gate"] = { state: "pending", description: "waiting", updated_at: "2026-10-11T01:08:50Z" }; - assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), true); -}); - test("a repository as the mesh clones it is told from another", async () => { const { clonedAs } = await import("../gate.ts"); assert.equal(clonedAs("http://127.0.0.1:20000/novox/hq.git", "novox", "hq"), true); @@ -325,10 +203,3 @@ test("a repository as the mesh clones it is told from another", async () => { // Issue 1301: a build machine's start on a head that holds no mesh/merge-gate (its base requires none, so none was written) // says nothing: no "building on" status appears on a main that does not require the gate. -test("a start on a head with no gate on the forge writes nothing", async () => { - const { GateWriter } = await import("../gate.ts"); - const forge = aForge(); - const gates = new GateWriter(forge, announcedAt); - assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false); - assert.equal(forge.kept["mesh/merge-gate"], undefined); -}); diff --git a/modules/gitea/test/pulls.test.ts b/modules/gitea/test/pulls.test.ts index c548ae78..bbc0daf1 100644 --- a/modules/gitea/test/pulls.test.ts +++ b/modules/gitea/test/pulls.test.ts @@ -29,92 +29,6 @@ test("a pull request retargeted keeps its head and is announced again, into its assert.notEqual(headKey("novox/de-spiegel", 6, "main"), headKey("novox/de-spiegel", 6, "old-trunk")); }); -test("a verdict is the head commit's status; an error is the forge's error, never a success", async () => { - const { statusFor, commentFor, CHECK_CONTEXT } = await import("../pulls.ts"); - const base = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "build-1", on: "laptop" }; - assert.equal(statusFor({ ...base, verdict: "pass", summary: "every machine composes" }).state, "success"); - // A warning is a note, never a blocking state: the forge combines `warning` as a failure (issue 293). - const wide = statusFor({ ...base, verdict: "warning", summary: "a merge rebuilds 14 module(s)" }); - assert.equal(wide.state, "success"); - assert.match(wide.description, /^pass, with a note: a merge rebuilds 14 module/); - assert.equal(statusFor({ ...base, verdict: "fail", summary: "x" }).state, "failure"); - assert.equal(statusFor({ ...base, verdict: "error", summary: "the check could not run" }).state, "error"); - assert.equal(statusFor({ ...base, verdict: "", summary: "" }).state, "error", "no verdict is no pass"); - const long = statusFor({ ...base, verdict: "fail", summary: "y".repeat(500) }); - assert.ok(long.description.length <= 140 && long.context === CHECK_CONTEXT); - assert.equal(commentFor({ ...base, verdict: "pass", summary: "fine" }), null, "a pass leaves no comment"); - const said = commentFor({ ...base, verdict: "fail", summary: "lemurs refused", report: "fails:\n - ```x```" }) ?? ""; - assert.match(said, /mesh\/merge-gate`: \*\*FAIL\*\*/); - assert.match(said, /01234567/); - assert.match(said, /builds --log build-1/); - assert.ok(!said.slice(said.indexOf("```") + 3, said.lastIndexOf("```")).includes("```"), "the report cannot close its own block"); -}); - -test("each layer is its own status: the gate with the modules it judged, the repository's own check beside it", async () => { - const { statusesFor, commentFor, CHECK_CONTEXT, REPO_CHECK_CONTEXT } = await import("../pulls.ts"); - const base = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "build-1" }; - const both = statusesFor({ ...base, verdict: "pass", summary: "every machine composes", - gate: { verdict: "pass", summary: "every machine composes", modules: ["gitea", "keycloak"], dependents: ["node-tools"] }, - "repo-check": { verdict: "fail", summary: "its merge-check.sh failed: FAIL x" } }); - assert.deepEqual(both.map((s) => [s.context, s.state]), [[CHECK_CONTEXT, "success"], [REPO_CHECK_CONTEXT, "failure"]]); - assert.match(both[0].description, /gitea, keycloak \+1 dependent/); - assert.match(commentFor({ ...base, verdict: "pass", summary: "", gate: { verdict: "pass", summary: "" }, - "repo-check": { verdict: "fail", summary: "its merge-check.sh failed" } }) ?? "", /mesh\/repo-check`: \*\*FAIL/); - - // Nothing of the graph touched, no script: a pass and a warning, and no comment on every push. - const quiet = { ...base, verdict: "pass", summary: "the change touches no module of the mesh's graph", - gate: { verdict: "pass", summary: "the change touches no module of the mesh's graph" }, - "repo-check": { verdict: "warning", summary: "the repository declares no merge-check.sh" } }; - assert.deepEqual(statusesFor(quiet, { defined: false, required: false }).map((s) => [s.state, s.description]), - [["success", "pass: the change touches no module of the mesh's graph"], ["success", "no repository check defined"]]); - assert.equal(commentFor(quiet, { defined: false, required: false }), null); - - // A repository outside the mesh, touching nothing: the gate alone, a pass. - assert.equal(statusesFor({ ...base, verdict: "pass", summary: "x", gate: { verdict: "pass", summary: "x" } }).length, 1); - - // A controller from before the layers: its verdict is the gate's. - assert.deepEqual(statusesFor({ ...base, verdict: "warning", summary: "wide" }).map((s) => [s.context, s.state]), - [[CHECK_CONTEXT, "success"]]); -}); - -// **A required status is success or it blocks** (novox/hq issue 293): branch protection requires -// mesh/merge-gate (and mesh/repo-check on the core repositories) with no administrator override, and the -// forge combines `warning` as a failure. No verdict ever sets `warning` on either; a note is a success that -// says it; only what a person must decide is a failure, with why. -test("no verdict sets warning on a required check; only a person's decision fails", async () => { - const { statusesFor, commentFor, UNDEFINED_REQUIRED, REPO_CHECK_CONTEXT } = await import("../pulls.ts"); - const base = { owner: "novox", repo: "photos", number: 3, commit: "0123456789abcdef", id: "build-2" }; - const noScript = { verdict: "warning", summary: "the repository declares no merge-check.sh" }; - const quiet = { ...base, verdict: "pass", summary: "x", gate: { verdict: "pass", summary: "x" }, "repo-check": noScript }; - for (const verdict of ["pass", "warning", "fail", "error", ""]) { - for (const facts of [{}, { defined: true }, { defined: false }, { defined: false, required: true }, { defined: false, required: false }]) { - const c = { ...base, verdict, summary: "s", gate: { verdict, summary: "s" }, "repo-check": { verdict, summary: "s" } }; - for (const s of statusesFor(c, facts)) assert.notEqual(s.state, "warning", `${verdict} ${JSON.stringify(facts)} → ${s.context}`); - } - } - // A repository without a merge-check.sh where repo-check is not required: success, said. - assert.deepEqual(statusesFor(quiet, { defined: false, required: false })[1], - { state: "success", context: REPO_CHECK_CONTEXT, description: "no repository check defined" }); - // Where it is required — or the protection could not be read — a person decides: a failure, with why. - for (const facts of [{ defined: false, required: true }, { defined: false }]) { - const s = statusesFor(quiet, facts)[1]; - assert.equal(s.state, "failure"); - assert.ok(s.description.length <= 140 && UNDEFINED_REQUIRED.startsWith(s.description.replace(/…$/, ""))); - assert.match(s.description, /^fail: mesh\/repo-check is required/); - assert.match(commentFor(quiet, facts) ?? "", /mesh\/repo-check`: \*\*FAIL\*\* — mesh\/repo-check is required/); - } - // A script that defines its check and said a warning itself: a note, a success. - const noted = statusesFor({ ...quiet, "repo-check": { verdict: "warning", summary: "2 tests skipped" } }, { defined: true })[1]; - assert.deepEqual([noted.state, noted.description], ["success", "pass, with a note: 2 tests skipped"]); - // The gate's notes — a wide rebuild, a problem already so on the base — are successes that say so. - const already = statusesFor({ ...base, verdict: "warning", summary: "s", - gate: { verdict: "warning", summary: "the module check's problems were all so on main already" } })[0]; - assert.deepEqual([already.state, already.description], - ["success", "pass, with a note: the module check's problems were all so on main already"]); - assert.match(commentFor({ ...base, verdict: "warning", summary: "wide", gate: { verdict: "warning", summary: "wide" } }) ?? "", - /PASS, WITH A NOTE/); -}); - test("a commit status is set on the commit, under the merge check's context", async () => { const { GiteaClient } = await import("../client.ts"); let seen: { path: string; body: any } | null = null; @@ -140,24 +54,6 @@ test("a commit status is set on the commit, under the merge check's context", as assert.equal(seen!.body.context, "mesh/merge-gate"); }); -test("a change plan is the gate's result: said on the status and, when it builds something, as a comment", async () => { - const { statusFor, commentFor } = await import("../pulls.ts"); - const plan = { - repository: "novox/mesh-catalog", base: "main", head: "0123456789abcdef", moved: ["gitea"], - tiers: [["gitea"]], machines: [{ machine: "anchor", receives: ["gitea"] }], steps: [], - summary: "builds gitea → anchor; no bus step", - }; - const c = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "b", verdict: "pass", - summary: "every machine composes", gate: { verdict: "pass", summary: "every machine composes", modules: ["gitea"] }, plan }; - assert.equal(statusFor(c).description, "pass: builds gitea → anchor; no bus step; every machine composes"); - const said = commentFor(c) ?? ""; - assert.match(said, /Delivery plan\*\* — builds gitea → anchor/); - assert.match(said, /- anchor: receives gitea/); - // A plan that builds nothing, passing: the statuses say it, no comment. - const nothing = { ...c, plan: { ...plan, moved: [], tiers: [], machines: [], summary: "builds nothing" } }; - assert.equal(commentFor(nothing), null); -}); - // **A pending check says where it is** (novox/hq issue 446): announced, it waits in the build queue, since the time the // operator reads — not "mapping this head onto its module graph" for the ten minutes of a controller check. test("a head announced is pending in the build queue, since the local time it was announced", async () => { @@ -166,48 +62,10 @@ test("a head announced is pending in the build queue, since the local time it wa assert.equal(pendingWords(new Date(2026, 9, 11, 14, 0, 0)), "waiting in the build queue since 14:00"); }); -// **A group's composed verdict is each member's merge gate** (novox/hq ADR 0403 §1, issue 978): set at the head the -// composed check read, naming the trunk commit it was merged onto, the group, and what the check said. -test("a composed verdict sets every member's gate at its own head, naming its trunk and the group", async () => { - const { groupGateStatus, CHECK_CONTEXT } = await import("../pulls.ts"); - const c = { owner: "", repo: "", commit: "", id: "build-1", group: "feat/x", verdict: "pass", summary: "both compose", - members: [{ owner: "novox", repo: "mesh-controller", number: 1, commit: "c".repeat(40), trunk: "a".repeat(40) }, - { owner: "novox", repo: "mesh-catalog", number: 2, commit: "d".repeat(40) }] }; - const [first, second] = c.members.map((m) => groupGateStatus(c, m)); - assert.equal(first.context, CHECK_CONTEXT); - assert.equal(first.state, "success"); - assert.match(first.description, /^head cccccccccccc merged into trunk aaaaaaaaaaaa — pass: group feat\/x composed: both compose/); - assert.equal(second.state, "success"); - assert.match(second.description, /^pass: group feat\/x composed: both compose/, "no trunk named, none said"); - // A composed check that failed is every member's failure, never a success. - assert.equal(groupGateStatus({ ...c, verdict: "fail", summary: "a and b do not compose" }, c.members[0]).state, "failure"); - assert.equal(groupGateStatus({ ...c, verdict: "error", summary: "could not run" }, c.members[0]).state, "error"); -}); - -// A member's own verdict whose gate layer is the group's composed check's sets its repository check only, and never the -// gate as an error: the gate stays pending until the composed verdict lands, in either order (ADR 0403 §1, issue 978). -test("a member's own verdict with a composed gate layer leaves the gate to the composed verdict, in either order", async () => { - const { statusesFor, composedGate, groupGateStatus, CHECK_CONTEXT, REPO_CHECK_CONTEXT } = await import("../pulls.ts"); - const c = { owner: "novox", repo: "mesh-catalog", number: 2, commit: "d".repeat(40), id: "build-1", verdict: "pass", summary: "x", - gate: { verdict: "composed", summary: "judged by its group" }, "repo-check": { verdict: "pass", summary: "suite passed" } }; - assert.ok(composedGate(c)); - // Before the composed verdict: the repository check, and the gate pending, saying it waits. - const before = statusesFor(c, {}, undefined); - assert.deepEqual(before.map((s) => [s.context, s.state]), [[CHECK_CONTEXT, "pending"], [REPO_CHECK_CONTEXT, "success"]]); - assert.match(before[0].description, /waits for its group's composed check/); - assert.ok(!before.some((s) => s.state === "error")); - // After it: the standing composed gate is left alone. - const standing = groupGateStatus({ ...c, group: "feat/x", members: [] }, { owner: "novox", repo: "mesh-catalog", commit: c.commit }).description; - const after = statusesFor(c, {}, standing); - assert.deepEqual(after.map((s) => [s.context, s.state]), [[REPO_CHECK_CONTEXT, "success"]]); - // A verdict with an ordinary gate is as before. - assert.equal(composedGate({ ...c, gate: { verdict: "pass", summary: "x" } }), false); -}); - // Issue 1301 (ADR 0503 §3, §5): gitea writes the controller's statuses only where the base still requires them. hq's main -// requires mesh/repo-check alone: its verdict sets mesh/repo-check and no mesh/merge-gate, and its comment names no gate. +// requires mesh/repo-check alone, so no mesh/merge-gate is written on its heads. test("a base's required statuses decide which of the controller's statuses are written", async () => { - const { maintained, writesGate, statusesFor, commentFor, CHECK_STATUS } = await import("../pulls.ts"); + const { maintained, writesGate, CHECK_STATUS } = await import("../pulls.ts"); assert.equal(CHECK_STATUS, "mesh/check"); assert.deepEqual(maintained(undefined), ["mesh/merge-gate", "mesh/repo-check"], "a rule not read: as before"); assert.deepEqual(maintained(["mesh/repo-check"]), ["mesh/repo-check"]); @@ -217,15 +75,6 @@ test("a base's required statuses decide which of the controller's statuses are w assert.equal(writesGate(["mesh/repo-check"]), false); assert.equal(writesGate(["mesh/check"]), false); assert.equal(writesGate(["mesh/merge-gate", "mesh/repo-check"]), true); - const c = { owner: "novox", repo: "hq", number: 5, commit: "a".repeat(40), id: "x", verdict: "warning", summary: "no script", - trunk: "b".repeat(12), "repo-check": { verdict: "pass", summary: "ok" } } as any; - const hq = statusesFor(c, {}, undefined, ["mesh/repo-check"]); - assert.deepEqual(hq.map((s: any) => s.context), ["mesh/repo-check"]); - assert.match(hq[0].description, /merged into trunk bbbbbbbbbbbb/); - assert.deepEqual(statusesFor(c).map((s: any) => s.context), ["mesh/merge-gate", "mesh/repo-check"], "unread: as before"); - assert.deepEqual(statusesFor(c, {}, undefined, ["mesh/check"]), []); - const comment = commentFor(c, {}, ["mesh/repo-check"]); - assert.ok(comment === null || !comment.includes("mesh/merge-gate"), comment ?? ""); }); test("a rule the forge gives with no contexts (null) requires nothing, and an unread rule is unknown", async () => { diff --git a/modules/gitea/test/siblings.test.ts b/modules/gitea/test/siblings.test.ts index 92da1119..edadee27 100644 --- a/modules/gitea/test/siblings.test.ts +++ b/modules/gitea/test/siblings.test.ts @@ -13,7 +13,6 @@ import { join } from "node:path"; import { approvedHeadsOf, besideWords, groupRefusal, keepSiblings, namesSiblings, noteVerdict, readSiblings, siblingOutOfDate, siblingsFile, siblingsOf, siblingStatuses, weighSiblings, type SiblingRecord } from "../dist/siblings.js"; import { weighTrunks } from "../dist/trunk.js"; -import { commentFor, statusesFor } from "../dist/pulls.js"; const h = (c: string) => c.repeat(40); const MEMBER_HEAD = h("a"), HOST_HEAD = h("b"), HOST_BASE = h("c"), HOST_NEW = h("d"), HOST_MOVED = h("e"), MERGE = h("f"); @@ -22,43 +21,6 @@ const verdict = { owner: "novox", repo: "mesh-controller", number: 346, commit: trunk: h("1").slice(0, 12), siblings: [sibling], judge_from: "sibling novox/mesh-controller#346 merge 123456789abc", gate: { verdict: "pass", summary: "every machine composes ".repeat(10) }, "repo-check": { verdict: "pass", summary: "ok" } }; -test("both statuses name the siblings the verdict read, keeping the verdict word and the start of its reason within 140 characters", () => { - // A realistic controller summary for a failure, long enough that the forge clips it. - const reason = "2 of 4 machines do not compose: ace: mesh-host.service needs seat node-reads, which no module on ace claims after this change"; - const c = { ...verdict, gate: { verdict: "fail", summary: reason }, "repo-check": { verdict: "fail", summary: "its merge-check.sh failed: internal/link/check.go:145:2: undefined" } }; - const [gate, repo] = statusesFor(c); - for (const s of [gate, repo]) { - assert.ok(s.description.startsWith(`head ${MEMBER_HEAD.slice(0, 12)} merged into trunk 111111111111 beside mesh-host#114@bbbbbbbb — fail: `), s.description); - assert.ok(s.description.length <= 140, `${s.description.length} characters`); - assert.ok(namesSiblings(s.description)); - } - assert.match(gate.description, /fail: 2 of 4 machines do not compose: ace: mesh-host/, gate.description); - assert.match(repo.description, /fail: its merge-check\.sh failed: internal/, repo.description); - assert.equal(besideWords([...siblingsOf([sibling]), ...siblingsOf([{ ...sibling, number: 115 }])]), "beside mesh-host#114@bbbbbbbb +1 more"); - const comment = commentFor({ ...verdict, gate: { verdict: "fail", summary: "no" } })!; - assert.match(comment, /Siblings read beside it: novox\/mesh-host#114 `bbbbbbbbbbbb` merged onto `cccccccccccc` \(main\); judged by sibling novox\/mesh-controller#346 merge/); -}); - -test("a verdict that could not learn its siblings says so on the status and in the comment, and names none", () => { - const { siblings, ...unknown } = { ...verdict, siblings_unknown: "mesh-delivery did not answer" }; - const reason = "2 of 4 machines do not compose: ace: mesh-host.service needs seat node-reads, which no module claims"; - for (const s of statusesFor({ ...unknown, gate: { verdict: "fail", summary: reason } })) { - assert.match(s.description, /^head aaaaaaaaaaaa merged into trunk 111111111111 siblings unknown — (fail|pass): /, s.description); - assert.ok(s.description.length <= 140); - assert.equal(namesSiblings(s.description), false, "it read no siblings, so the merge tool asks for none"); - } - assert.match(commentFor({ ...unknown, gate: { verdict: "fail", summary: "no" } })!, /Siblings could not be learned: mesh-delivery did not answer/); -}); - -test("a verdict without siblings is said exactly as before (an older controller)", () => { - const { siblings, judge_from, ...old } = verdict; - const [gate] = statusesFor(old); - assert.equal(namesSiblings(gate.description), false); - assert.match(gate.description, /^head aaaaaaaaaaaa merged into trunk 111111111111 — pass: /); - assert.deepEqual(siblingsOf(undefined), []); - assert.deepEqual(siblingsOf([{ repository: "novox/x" }]), [], "a sibling named without its head is none"); -}); - test("the siblings a verdict names are kept per member, replaced by its next verdict, and removed by one that read none", () => { const file = siblingsFile(mkdtempSync(join(tmpdir(), "gitea-siblings-"))); const record: SiblingRecord = {}; diff --git a/modules/gitea/test/tools-account.test.ts b/modules/gitea/test/tools-account.test.ts index c69edb77..6bf55535 100644 --- a/modules/gitea/test/tools-account.test.ts +++ b/modules/gitea/test/tools-account.test.ts @@ -17,7 +17,6 @@ import { join } from "node:path"; import { MintedToken } from "../dist/token.js"; import { GiteaClient } from "../dist/client.js"; import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS, BUILDER_ACCOUNT, AGENT_ACCOUNT, meshAccounts } from "../dist/tools-account.js"; -import { repoCheckFacts } from "../dist/pulls.js"; const ADMIN = "mesh-admin"; const PASSWORD = "the-vault-minted-this"; @@ -364,16 +363,6 @@ test("the manifest hands the tools their account, and the Go forge bundle holds // Review of issue 469: what reads the rules' list is moved to the branch, and nothing leaves the tools without a token. -test("a repository check said as a warning on a head without merge-check.sh reads the base's protection from the branch", async () => { - const client = fresh(await environment()); - const forgeOf = { holdsFile: async () => false, appliedProtection: client.appliedProtection.bind(client) }; - const c = { owner: "novox", repo: "hq", commit: "abc123def4567890abc123def4567890abc12345", "repo-check": { verdict: "warning" } } as any; - // main requires mesh/repo-check: required, so a head that defines no check fails it for a person to see. - assert.deepEqual(await repoCheckFacts(forgeOf, c, "main"), { defined: false, required: true }); - // A base nothing guards: not required, so the head's repository check passes rather than fails. - assert.deepEqual(await repoCheckFacts(forgeOf, c, "feat/unguarded"), { defined: false, required: false }); -}); - test("a mint that fails leaves the administrator's old token in place: the tools are never left without one", async () => { const env = await environment(); const old = { id: 901, name: "mesh-tools", sha1: "sha-admin-kept", user: ADMIN, scopes: ["read:user"] }; diff --git a/modules/gitea/test/trunk.test.ts b/modules/gitea/test/trunk.test.ts index 5df4c788..2e4b242f 100644 --- a/modules/gitea/test/trunk.test.ts +++ b/modules/gitea/test/trunk.test.ts @@ -8,37 +8,11 @@ import assert from "node:assert/strict"; import { checkOnItsWay, lateVerdictComment, outOfDateStatuses, outOfDateWords, trunkNamed, verdictAtTrunk, weighTrunks } from "../dist/trunk.js"; import { GateWriter } from "../dist/gate.js"; -import { statusesFor, commentFor } from "../dist/pulls.js"; const HEAD = "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const TRUNK = "a2c2c35bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const MOVED = "d28ca95ccccccccccccccccccccccccccccccccc"; -test("both statuses say the head and the trunk commit the verdict judged, first, whatever the summary's length", () => { - const c = { owner: "novox", repo: "mesh-host", number: 66, commit: HEAD, verdict: "pass", summary: "x", id: "build-1", - trunk: TRUNK.slice(0, 12), beside: { "mesh-catalog": "0123456789ab", "mesh-lab": "ba9876543210" }, - gate: { verdict: "pass", summary: "4 of 4 compose ".repeat(20) }, - "repo-check": { verdict: "fail", summary: "its merge-check.sh failed: internal/store/store.go:145:2: Declared redeclared" } }; - const [gate, repo] = statusesFor(c); - for (const s of [gate, repo]) { - assert.ok(s.description.startsWith("head dfb1415aaaaa merged into trunk a2c2c35bbbbb — "), s.description); - assert.ok(s.description.length <= 140, `${s.description.length} characters`); - assert.equal(trunkNamed(s.description), "a2c2c35bbbbb"); - } - assert.equal(gate.state, "success"); - assert.equal(repo.state, "failure"); - const comment = commentFor(c)!; - assert.match(comment, /Judged: head `dfb1415aaaaa` merged into trunk `a2c2c35bbbbb`; read beside it: mesh-catalog `0123456789ab`, mesh-lab `ba9876543210`/); - - // A verdict of a build seat from before the rule names no trunk, and is said as before. - const [old] = statusesFor({ ...c, trunk: undefined, gate: { verdict: "pass", summary: "every machine composes" } }); - assert.equal(old.description, "pass: every machine composes"); - assert.equal(trunkNamed(old.description), undefined); - // ADR 0548 §8: a carried verdict's line names the head on the new trunk first, so the merge tool merges it on that trunk. - assert.equal(trunkNamed(`pass ${HEAD.slice(0, 12)} on ${MOVED.slice(0, 12)} | carried from ${TRUNK.slice(0, 12)}, mesh-catalog 0123456789ab | unit 0 run, 4 kept`), - MOVED.slice(0, 12)); -}); - test("a trunk's move puts an open head's merge check out of date, naming both commits, unless a check is on its way", () => { const verdict = (trunk: string, state = "success") => ({ "mesh/merge-gate": { state, description: `head ${HEAD.slice(0, 12)} merged into trunk ${trunk.slice(0, 12)} — pass: x` },