diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 9ccf741c..5f80c6de 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -672,19 +672,6 @@ export class GiteaClient { return out; } - /** Every status of one context on a commit, newest first, as the forge keeps each one it was given: what tells - * when the check now pending on a head was opened, after a restart (novox/hq issue 456). */ - async contextHistory(owner: string, repo: string, sha: string, context: string): Promise { - const raw = await this.request(`${repoPath(owner, repo)}/statuses/${commitSegment(sha)}?sort=highestindex&limit=50`); - const out: HeadCheck[] = []; - for (const s of Array.isArray(raw) ? raw : []) { - if (s?.context !== context) continue; - out.push({ state: String(s.status ?? s.state ?? ""), description: String(s.description ?? ""), - updated_at: String(s.created_at ?? s.updated_at ?? "") }); - } - return out; - } - /** A commit's statuses whole, as the forge combines them for a ref (a sha, or the start of one): the commit * it resolved to and every status with its description, link, times and setter (novox/hq ADR 0239). */ async combinedStatus(owner: string, repo: string, ref: string): Promise<{ sha: string; state: string; statuses: unknown[] }> { diff --git a/modules/gitea/gate.ts b/modules/gitea/gate.ts index 067dea26..e1c36bb5 100644 --- a/modules/gitea/gate.ts +++ b/modules/gitea/gate.ts @@ -1,74 +1,21 @@ -// **One writer of a head's mesh/merge-gate, and the verdict lands last** (novox/hq issue 446). +// **One writer of a head's mesh/merge-gate** (novox/hq issue 446). // -// 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 -// gate goes through one chain: a verdict is marked the moment it is heard, before it waits its turn, and a "building" -// still waiting behind or taking its turn after that mark is dropped, never written. -// -// **A recheck's pending is written here too** (novox/hq issue 456). mesh-delivery's recheck puts the gate back to -// pending through the gitea_commit_status tool, which this module serves from the watcher's own bundle so that it -// reaches this writer: the pending clears the verdict mark, so the new run's "building" is said, and queues behind an -// old verdict in flight, so that verdict lands first. A verdict is weighed by the ask it answers — the controller's -// checked.id, `build-` of when the check was asked — against when the check now pending was opened: -// an older run's verdict, redelivered after its write failed, is dropped, never written over the pending. After a -// restart, when the check was opened is read from the forge's own history of the gate. +// A pending check says where it is: "waiting in the build queue since …" when the head is announced. The forge keeps only +// the newest status of a context, so two writers cannot order their writes; this module is the one writer of +// mesh/merge-gate, and every write of one head's gate goes through one chain, in the order it was asked. A recheck's +// pending (novox/hq issue 456) and a trunk's move (ADR 0323 §4) take their turns on the same chain. No verdict is written +// here any more: the controller's `checked` is no event (ADR 0571 clause 13), and main requires the checker's mesh/check. import { CHECK_CONTEXT, pendingWords } from "./pulls.js"; import type { CommitStatus, HeadCheck } from "./client.js"; -/** What the gate writer needs of the forge: a status set, and a head's gate as the forge holds it. */ +/** What the gate writer needs of the forge: a status set, and a head's statuses as the forge holds them. */ export interface GateForge { setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise; - /** The head's mesh/merge-gate, newest: its state and when it was set; undefined when it has none. */ - gateOf(owner: string, repo: string, sha: string): Promise<{ state: string; updated_at?: string } | undefined>; - /** The head's mesh/merge-gate statuses, every one the forge keeps, newest first (novox/hq issue 456). */ - gateHistory(owner: string, repo: string, sha: string): Promise; /** The head's statuses by context, the newest of each, with their words (novox/hq ADR 0323). */ headChecks(owner: string, repo: string, sha: string): Promise>; } -/** One status of a head's gate as the forge keeps it: its state, its words, and when it was set. */ -export interface GateStatus { - state: string; - description?: string; - updated_at?: string; -} - -/** - * When a check was asked, from its id as mesh-controller's link.NewBuildID writes it: `build-`. - * Undefined for an id of any other shape, whose ask time is not known (novox/hq issue 456). - */ -export function askedAt(id: string | undefined): number | undefined { - const m = /^build-(\d{1,20})$/.exec(String(id ?? "")); - if (!m) return undefined; - const ms = Number(BigInt(m[1]) / 1_000_000n); - return ms > 0 ? ms : undefined; -} - -/** - * When the check now pending on a head was opened, from the gate's history, newest first: the newest status, past the - * "building" words this writer says only inside an open check, when it is pending; undefined when the gate holds a - * verdict, holds nothing, or its time cannot be read (novox/hq issue 456). - */ -export function openedAt(history: GateStatus[]): number | undefined { - for (const s of history) { - if (s.state !== "pending") return undefined; - if ((s.description ?? "").startsWith(BUILDING)) continue; - const t = Date.parse(s.updated_at ?? ""); - return Number.isNaN(t) ? undefined : t; - } - return 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 was running began: a status the forge still holds from before is no opening of a check. */ -const BUILDING = "building on "; - /** 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$/, ""); @@ -77,12 +24,6 @@ export function clonedAs(url: string | undefined, owner: string, repo: string): } interface Head { - /** When the pending words were last set by this writer; undefined for a head this process did not announce. */ - pendingAt?: number; - /** A verdict was heard for the head: nothing pending is written after it. */ - verdict: boolean; - /** Which verdict set the mark: one dropped in its turn takes the mark back only while it is still its own. */ - mark?: object; /** The chain the head's writes take their turns on. */ chain: Promise; } @@ -101,7 +42,7 @@ export class GateWriter { const key = `${owner}/${repo}@${sha}`.toLowerCase(); let h = this.heads.get(key); if (!h) { - h = { verdict: false, chain: Promise.resolve() }; + h = { chain: Promise.resolve() }; this.heads.set(key, h); // Kept for the heads still in play; the oldest go first, past what any forge holds open at once. if (this.heads.size > 2000) this.heads.delete(this.heads.keys().next().value as string); @@ -127,36 +68,17 @@ export class GateWriter { pending(owner: string, repo: string, sha: string): Promise { const h = this.head(owner, repo, sha); const at = this.now(); - this.opened(h, at); return this.said(h, `${owner}/${repo}@${sha.slice(0, 8)}: could not say a check is pending`, () => this.forge.setCommitStatus(owner, repo, sha, { state: "pending", context: CHECK_CONTEXT, description: pendingWords(at) })); } - /** A new check of the head opened now: no verdict heard for it yet, and nothing older than it is its own. */ - private opened(h: Head, at: Date): void { - h.pendingAt = at.getTime(); - h.verdict = false; - h.mark = undefined; - } - /** - * A recheck's pending, asked through the gitea_commit_status tool (novox/hq issue 456): on mesh/merge-gate it opens - * a new check of the head — the verdict mark cleared, so the new run's "building" is said — and takes its turn - * after every write already asked for, an old verdict in flight included, so it lands over that verdict. Any - * other status of the merge check (mesh/repo-check, which the verdict also writes) takes its turn on the same - * chain. A write that fails fails the caller, and the head is as it was before it. + * A recheck's pending, asked through the gitea_commit_status tool (novox/hq issue 456): it takes its turn after every + * write already asked for, so it lands over them. Any other status of the merge check takes its turn on the same + * chain. A write that fails fails the caller. */ rechecked(owner: string, repo: string, sha: string, status: CommitStatus): Promise { - const h = this.head(owner, repo, sha); - const write = () => this.forge.setCommitStatus(owner, repo, sha, status); - if (status.context !== CHECK_CONTEXT || status.state !== "pending") return this.turn(h, write); - const before = { pendingAt: h.pendingAt, verdict: h.verdict, mark: h.mark }; - const at = this.now(); - this.opened(h, at); - return this.turn(h, write).catch((err) => { - if (h.pendingAt === at.getTime() && !h.verdict) Object.assign(h, before); - throw err; - }); + return this.turn(this.head(owner, repo, sha), () => this.forge.setCommitStatus(owner, repo, sha, status)); } /** @@ -178,50 +100,4 @@ export class GateWriter { } }).then(() => written); } - - /** - * 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. - * - * `id` is the ask it answers (checked.id): a verdict of a check asked before the check now pending was opened is - * an older run's — redelivered after its write failed, or heard late — and is dropped, never written over the - * pending (novox/hq issue 456). When this process opened no check of the head (it restarted since), the opening - * is read from the forge's history of the gate in the verdict's turn; a history that cannot be read writes the - * verdict, as one with no readable id is: a verdict is never lost to what cannot be weighed. - */ - verdict(owner: string, repo: string, sha: string, write: () => Promise, id?: string): Promise { - const h = this.head(owner, repo, sha); - const asked = askedAt(id); - const head = `${owner}/${repo}@${sha.slice(0, 8)}`; - if (asked !== undefined && h.pendingAt !== undefined && older(asked, h.pendingAt)) { - console.log(`[gitea] ${head}: the verdict of ${id} is an older run's than the check now pending; not written`); - return Promise.resolve(); - } - const before = h.verdict; - const mark = {}; - h.verdict = true; - h.mark = mark; - return this.turn(h, async () => { - if (asked !== undefined && h.pendingAt === undefined) { - let opened: number | undefined; - try { - opened = openedAt(await this.forge.gateHistory(owner, repo, sha)); - } catch (err) { - console.error(`[gitea] ${head}: the gate's history could not be read, so the verdict of ${id} is written ` + - `unweighed — ${err instanceof Error ? err.message : err}`); - } - if (opened !== undefined && older(asked, opened)) { - if (h.mark === mark) h.verdict = before; - console.log(`[gitea] ${head}: the verdict of ${id} is an older run's than the check the forge holds pending; not written`); - return; - } - } - await write(); - }); - } -} - -/** Whether a check asked at `asked` was asked before a check opened at `opened`, past what the clocks may differ by. */ -function older(asked: number, opened: number): boolean { - return asked < opened - CLOCK_SLACK_MS; } diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index fd4380c6..cbf8bedd 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -356,8 +356,6 @@ async function siblingMoves(client: GiteaClient, repository: string, number?: nu // (gate.ts). A second writer elsewhere raced the verdict and could leave a pass reading pending. const gates = new GateWriter({ setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status), - gateOf: async (owner, repo, sha) => (await (gitea as GiteaClient).headChecks(owner, repo, sha))[CHECK_CONTEXT], - gateHistory: (owner, repo, sha) => (gitea as GiteaClient).contextHistory(owner, repo, sha, CHECK_CONTEXT), headChecks: (owner, repo, sha) => (gitea as GiteaClient).headChecks(owner, repo, sha), }); diff --git a/modules/gitea/siblings.ts b/modules/gitea/siblings.ts index ae9b6c58..5dcff478 100644 --- a/modules/gitea/siblings.ts +++ b/modules/gitea/siblings.ts @@ -119,48 +119,6 @@ export function keepSiblings(file: string | null, record: SiblingRecord): void { } } -/** A member's latest verdict, kept: its siblings when it read some, else the entry removed. Answers whether it changed. */ -export function noteVerdict(record: SiblingRecord, key: string, head: string, siblings: SiblingNamed[]): boolean { - if (siblings.length === 0) { - if (!(key in record)) return false; - delete record[key]; - return true; - } - record[key] = { head, siblings }; - return true; -} - -/** One member of a composed verdict, as the group's composed check read it: its pull request, the head it judged, the - * trunk commit that head was merged onto, and the branch the pull request merges into. */ -export interface ComposedMember { - owner: string; - repo: string; - number?: number; - commit: string; - trunk?: string; - base?: string; -} - -/** - * **A group's composed verdict names, for each member, the other members at the heads and base commits it composed** - * (novox/hq ADR 0403 §5, issue 1049): the composed check read every member's head together, so it is the verdict to trust - * for the siblings. Without this, the record held what a member's OWN verdict named, which went stale when a sibling got - * a new head; the sibling's own merge of the head the composed check judged then read as another head's merge, put the - * member's gate back to pending, and the group merged in two passes. A member the verdict cannot name (no pull request - * number, trunk commit or base branch), or a verdict of fewer than two such members, changes nothing. Answers whether - * the record changed. - */ -export function noteComposed(record: SiblingRecord, members: ComposedMember[]): boolean { - const named = members.filter((m) => m.owner && m.repo && m.number && m.commit && m.trunk && m.base); - if (named.length < 2 || named.length !== members.length) return false; - for (const m of named) { - const siblings = named.filter((o) => o !== m).map((o): SiblingNamed => ({ - repository: `${o.owner}/${o.repo}`, number: o.number!, head: o.commit, base: o.base!, base_commit: o.trunk! })); - record[memberKey(m.owner, m.repo, m.number!)] = { head: m.commit, siblings }; - } - return true; -} - // --- what a sibling's move is --------------------------------------------------------------------------------- /** What weighing a sibling reads of the forge. */ diff --git a/modules/gitea/test/gate.test.ts b/modules/gitea/test/gate.test.ts index 590f1c3e..eee5342b 100644 --- a/modules/gitea/test/gate.test.ts +++ b/modules/gitea/test/gate.test.ts @@ -1,9 +1,8 @@ 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 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". +// **One writer of a head's mesh/merge-gate** (novox/hq issue 446): the announcement's pending words, a recheck's pending and +// a trunk's move, each write of a head in the order it was asked. const SHA = "a6b08de8507c1703528277e5cefc80548dbc9bb3"; @@ -11,15 +10,11 @@ const SHA = "a6b08de8507c1703528277e5cefc80548dbc9bb3"; function aForge() { const kept: Record = {}; const written: string[] = []; - /** Every mesh/merge-gate status the forge was given, newest first, as gitea keeps each one. */ - const history: { state: string; description: string; updated_at: string }[] = []; let failNext = 0; let hold: Promise | null = null; let release = () => {}; - let readHold: Promise | null = null; - let readRelease = () => {}; return { - kept, written, history, + kept, written, /** The next writes fail: a request the forge did not take. */ failWrites(n = 1) { failNext = n; @@ -32,6 +27,9 @@ function aForge() { hold = null; release(); }, + async headChecks() { + return {}; + }, async setCommitStatus(_o: string, _r: string, _sha: string, s: { state: string; context: string; description: string }) { if (hold) await hold; if (failNext > 0) { @@ -39,121 +37,35 @@ function aForge() { throw new Error("the forge answered 502"); } kept[s.context] = { state: s.state, description: s.description, updated_at: new Date().toISOString() }; - if (s.context === "mesh/merge-gate") history.unshift(kept[s.context]); written.push(`${s.state} ${s.description}`); }, - /** Reads answer what the forge held when asked, as late as the reads are held: the answer of a request in flight. */ - holdReads() { - readHold = new Promise((r) => (readRelease = r)); - }, - releaseReads() { - readHold = null; - readRelease(); - }, - async gateOf(_o: string, _r: string, _sha: string) { - const held = kept["mesh/merge-gate"] ? { ...kept["mesh/merge-gate"] } : undefined; - if (readHold) await readHold; - return held; - }, - async gateHistory(_o: string, _r: string, _sha: string) { - return history.map((h) => ({ ...h })); - }, }; } -/** The clock of the test: the head announced at 01:08:50Z. */ -const announcedAt = () => new Date("2026-10-11T01:08:50Z"); - // **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. -/** 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}`; -const firstRun = askOf("2026-10-11T01:09:01Z"); const recheckAt = new Date("2026-10-11T01:30:00Z"); -const secondRun = askOf("2026-10-11T01:30:02Z"); const checkingAgain = { state: "pending" as const, context: "mesh/merge-gate", description: "checking again: main moved on" }; -const writeOf = (forge: ReturnType, state: string, description: string) => () => - forge.setCommitStatus("novox", "hq", SHA, { state, context: "mesh/merge-gate", description }); const gateOf = (forge: ReturnType) => `${forge.kept["mesh/merge-gate"].state} ${forge.kept["mesh/merge-gate"].description}`; -test("an old run's verdict redelivered after a recheck's pending is not written over it", 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); - forge.failWrites(); // the first run's verdict is not taken: the event is offered again - await assert.rejects(gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun)); - now = recheckAt; - await gates.rechecked("novox", "hq", SHA, checkingAgain); - await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun); // redelivered - assert.equal(gateOf(forge), "pending checking again: main moved on", forge.written.join(" | ")); - // The recheck's own verdict is written. - await gates.verdict("novox", "hq", SHA, writeOf(forge, "failure", "fail: 0 of 4 compose"), secondRun); - assert.equal(gateOf(forge), "failure fail: 0 of 4 compose"); +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); + assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-hq.git", "novox", "hq"), false); + assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-controller.git", "novox", "hq"), false); }); -test("an old run's verdict in flight when the recheck comes lands before the recheck's pending", 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); - forge.holdWrites(); // the first run's verdict is being written when the recheck comes - const verdict = gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun); - await new Promise((r) => setImmediate(r)); - now = recheckAt; - const pending = gates.rechecked("novox", "hq", SHA, checkingAgain); - forge.release(); - await Promise.all([verdict, pending]); - assert.equal(gateOf(forge), "pending checking again: main moved on", forge.written.join(" | ")); -}); - -test("the recheck's own verdict is written, even asked a little before the pending by another machine's clock", async () => { +test("a recheck the forge does not take fails its caller, and the next write still takes its turn", async () => { const { GateWriter } = await import("../gate.ts"); const forge = aForge(); const gates = new GateWriter(forge, () => recheckAt); - await gates.rechecked("novox", "hq", SHA, checkingAgain); - await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), askOf("2026-10-11T01:29:30Z")); - assert.equal(gateOf(forge), "success pass"); - // A verdict whose ask cannot be read is written: a verdict is never lost to what cannot be weighed. - await gates.rechecked("novox", "hq", SHA, checkingAgain); - await gates.verdict("novox", "hq", SHA, writeOf(forge, "failure", "fail"), "an id of another shape"); - assert.equal(gateOf(forge), "failure fail"); -}); - -test("after a restart, an old run's verdict is weighed against the check the forge's history holds open", async () => { - const { GateWriter } = await import("../gate.ts"); - const forge = aForge(); - // What the forge kept before this process started: the announcement, the first run's verdict never written, - // the recheck's pending, and the second run's "building" over it. - forge.history.unshift({ state: "pending", description: "waiting in the build queue since 03:08", updated_at: "2026-10-11T01:08:50Z" }); - forge.history.unshift({ state: "pending", description: "checking again: main moved on", updated_at: "2026-10-11T01:30:00Z" }); - forge.history.unshift({ state: "pending", description: "building on shanks since 03:30", updated_at: "2026-10-11T01:30:05Z" }); - forge.kept["mesh/merge-gate"] = forge.history[0]; - const gates = new GateWriter(forge, announcedAt); - await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun); - assert.equal(forge.kept["mesh/merge-gate"].description, "building on shanks since 03:30", forge.written.join(" | ")); - await gates.verdict("novox", "hq", SHA, writeOf(forge, "failure", "fail"), secondRun); - assert.equal(gateOf(forge), "failure fail"); -}); - -test("a recheck the forge does not take fails its caller, and the head is as it was", 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); forge.failWrites(); - await assert.rejects(gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun)); - now = recheckAt; - forge.failWrites(); await assert.rejects(gates.rechecked("novox", "hq", SHA, checkingAgain)); - // The recheck was refused, so its check is not asked: the first run's verdict, offered again, is the head's. - await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun); - assert.equal(gateOf(forge), "success pass"); + await gates.rechecked("novox", "hq", SHA, checkingAgain); + assert.equal(gateOf(forge), "pending checking again: main moved on"); }); test("the status tool writes the merge check's pending through the writer, and the mesh's other statuses as they were", async () => { @@ -166,40 +78,15 @@ test("the status tool writes the merge check's pending through the writer, and t setCommitStatus: async (_o, _r, _sha, s) => void direct.push(`${s.context} ${s.state}`), gates, }); - await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun); await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/merge-gate", state: "pending", description: "checking again: x" }); await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/repo-check", state: "pending", description: "checking again: x" }); await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/delivery", state: "success", description: "delivered" }); assert.equal(gateOf(forge), "pending checking again: x"); assert.equal(forge.kept["mesh/repo-check"].state, "pending"); assert.deepEqual(direct, ["mesh/delivery success"]); - // The redelivered first run's verdict is the writer's to drop. - await gates.verdict("novox", "hq", SHA, writeOf(forge, "success", "pass"), firstRun); - assert.equal(gateOf(forge), "pending checking again: x"); // A head named short is another head to the writer: refused, nothing sent. await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: SHA.slice(0, 12), context: "mesh/merge-gate", state: "pending" }), /whole sha/); await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/merge-gate", state: "success" }), /set by its verdict only/); }); - -test("when a check was asked is read from its id; the opening of the check now pending from the gate's history", async () => { - const { askedAt, openedAt } = await import("../gate.ts"); - assert.equal(askedAt("build-1791680941048622697"), Date.parse("2026-10-11T01:09:01.048Z")); - assert.equal(askedAt("build-mesh-catalog"), undefined); - assert.equal(askedAt(undefined), undefined); - assert.equal(openedAt([{ state: "pending", description: "building on ace since 03:31", updated_at: "2026-10-11T01:31:00Z" }, - { state: "pending", description: "checking again: x", updated_at: "2026-10-11T01:30:00Z" }]), Date.parse("2026-10-11T01:30:00Z")); - assert.equal(openedAt([{ state: "success", description: "pass", updated_at: "2026-10-11T01:30:00Z" }]), undefined); - assert.equal(openedAt([]), undefined); -}); - -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); - assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-hq.git", "novox", "hq"), false); - assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-controller.git", "novox", "hq"), false); -}); - -// 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. diff --git a/modules/gitea/test/guard.test.ts b/modules/gitea/test/guard.test.ts index f6b1ec9e..8b080200 100644 --- a/modules/gitea/test/guard.test.ts +++ b/modules/gitea/test/guard.test.ts @@ -7,7 +7,7 @@ import { test, after, beforeEach } from "node:test"; import assert from "node:assert/strict"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { mkdtempSync, readFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -179,7 +179,7 @@ const client = new GiteaClient(`http://127.0.0.1:${port}`, new ConfiguredToken(" const stateDir = mkdtempSync(join(tmpdir(), "gitea-tools-")); // The merge check's one writer, as the watcher's bundle holds it: what the merge tool's pending goes through. const writer = new GateWriter({ setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s), - gateOf: async () => undefined, gateHistory: async () => [], headChecks: async () => ({}) }); + headChecks: async () => ({}) }); const throughTheWriter: string[] = []; const gates = { rechecked: (o: string, r: string, sha: string, s: any) => { throughTheWriter.push(`${sha.slice(0, 12)} ${s.context} ${s.state}`); @@ -302,8 +302,8 @@ test("gitea_merge_pull_request merges only a head whose required statuses all su // the trunk's head and passes, the merge goes through — asked again, never by itself. test("gitea_merge_pull_request refuses a verdict judged against an earlier trunk, and asks the check once", async () => { const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }); - const { keepTook, tookFile } = await import("../dist/trunk.js"); - keepTook(tookFile(stateDir), "novox/mesh-catalog", { took: "4m31s", at: "2026-10-12T14:49:00Z" }); + const { tookFile } = await import("../dist/trunk.js"); + writeFileSync(tookFile(stateDir)!, JSON.stringify({ "novox/mesh-catalog": { took: "4m31s", at: "2026-10-12T14:49:00Z" } })); state.trunk = MOVED; // another merge moved the trunk after the verdict await assert.rejects(merge(), (err: Error) => { @@ -734,7 +734,7 @@ test("gitea_merge_pull_request refuses a head the Approve did not name", async ( // the sibling merged that head, and the member then merges on its first ask: the sibling's own merge is no move of its // base. The records are the module's own (siblings.ts), the weighing is what the sibling's merge announcement does. test("a group of two merges in one pass: the sibling's merge of the composed head does not stop the member's merge (issue 1049)", async () => { - const { keepSiblings, siblingsFile, noteComposed, weighSiblings } = await import("../dist/siblings.js"); + const { keepSiblings, siblingsFile, weighSiblings } = await import("../dist/siblings.js"); const both = ["novox/mesh-catalog@" + REVIEWED, "novox/mesh-host@" + SIBLING_HEAD]; const OLD_SIBLING = "9".repeat(40), HOST_BASE = "c".repeat(40), HOST_MERGE = "f".repeat(40); const beside = `head ${REVIEWED.slice(0, 12)} merged into trunk ${TRUNK.slice(0, 12)} beside mesh-host#114@${OLD_SIBLING.slice(0, 8)}`; @@ -747,8 +747,9 @@ test("a group of two merges in one pass: the sibling's merge of the composed hea const record: Record = { "novox/mesh-catalog#7": { head: REVIEWED, siblings: [{ repository: "novox/mesh-host", number: 114, head: OLD_SIBLING, base: "main", base_commit: HOST_BASE }] } }; // The composed verdict then judged both new heads. - noteComposed(record, [{ owner: "novox", repo: "mesh-catalog", number: 7, commit: REVIEWED, trunk: TRUNK.slice(0, 12), base: "main" }, - { owner: "novox", repo: "mesh-host", number: 114, commit: SIBLING_HEAD, trunk: HOST_BASE.slice(0, 12), base: "main" }]); + Object.assign(record, { + "novox/mesh-catalog#7": { head: REVIEWED, siblings: [{ repository: "novox/mesh-host", number: 114, head: SIBLING_HEAD, base: "main", base_commit: HOST_BASE.slice(0, 12) }] }, + "novox/mesh-host#114": { head: SIBLING_HEAD, siblings: [{ repository: "novox/mesh-catalog", number: 7, head: REVIEWED, base: "main", base_commit: TRUNK.slice(0, 12) }] } }); // The sibling merges its head; its announcement weighs the member's record. The member's gate is written only by what // that weighing decides, through the writer. const pulled = { number: 114, title: "", html_url: "", merged: true, state: "closed", head_sha: SIBLING_HEAD, merge_commit_sha: HOST_MERGE }; diff --git a/modules/gitea/test/siblings.test.ts b/modules/gitea/test/siblings.test.ts index edadee27..0562b635 100644 --- a/modules/gitea/test/siblings.test.ts +++ b/modules/gitea/test/siblings.test.ts @@ -10,7 +10,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { approvedHeadsOf, besideWords, groupRefusal, keepSiblings, namesSiblings, noteVerdict, readSiblings, siblingOutOfDate, siblingsFile, +import { approvedHeadsOf, besideWords, groupRefusal, keepSiblings, namesSiblings, readSiblings, siblingOutOfDate, siblingsFile, siblingsOf, siblingStatuses, weighSiblings, type SiblingRecord } from "../dist/siblings.js"; import { weighTrunks } from "../dist/trunk.js"; @@ -21,15 +21,11 @@ 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("the siblings a verdict names are kept per member, replaced by its next verdict, and removed by one that read none", () => { +test("the siblings a verdict names are kept per member, and read back from where the holder keeps its state", () => { const file = siblingsFile(mkdtempSync(join(tmpdir(), "gitea-siblings-"))); - const record: SiblingRecord = {}; - assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, []), false); - assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, siblingsOf([sibling])), true); + const record: SiblingRecord = { "novox/mesh-controller#346": { head: MEMBER_HEAD, siblings: siblingsOf([sibling]) } }; keepSiblings(file, record); assert.deepEqual(readSiblings(file), record, "kept where the holder keeps its state"); - assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, []), true); - assert.deepEqual(record, {}); assert.deepEqual(readSiblings(null), {}); }); @@ -241,6 +237,10 @@ const composedMembers = () => [ { owner: "novox", repo: "mesh-controller", number: 346, commit: MEMBER_HEAD, trunk: "111111111111", base: "main" }, { owner: "novox", repo: "mesh-host", number: 114, commit: HOST_NEW, trunk: HOST_BASE.slice(0, 12), base: "main" }, ]; +/** The record a group's composed verdict once left: for each member, the other members at the heads and base commits it composed. */ +const composedRecord = (members: ReturnType): SiblingRecord => Object.fromEntries(members.map((m) => [ + `${m.owner}/${m.repo}#${m.number}`.toLowerCase(), { head: m.commit, siblings: members.filter((o) => o !== m).map((o) => ({ + repository: `${o.owner}/${o.repo}`, number: o.number, head: o.commit, base: o.base, base_commit: o.trunk })) }])); const staleRecord = (): SiblingRecord => ({ "novox/mesh-controller#346": { head: MEMBER_HEAD, siblings: siblingsOf([sibling]) } }); test("without the composed verdict's siblings, a sibling's own merge of its new head is a move (the pin of issue 1049)", async () => { @@ -252,9 +252,7 @@ test("without the composed verdict's siblings, a sibling's own merge of its new }); test("a composed verdict names the siblings of each member at the heads and base commits it composed, so a sibling's own merge of its new head is no move (issue 1049)", async () => { - const { noteComposed } = await import("../dist/siblings.js"); - const record = staleRecord(); - assert.equal(noteComposed(record, composedMembers()), true); + const record = composedRecord(composedMembers()); assert.deepEqual(record["novox/mesh-controller#346"], { head: MEMBER_HEAD, siblings: [{ repository: "novox/mesh-host", number: 114, head: HOST_NEW, base: "main", base_commit: HOST_BASE.slice(0, 12) }] }); assert.deepEqual(record["novox/mesh-host#114"], { head: HOST_NEW, @@ -267,21 +265,9 @@ test("a composed verdict names the siblings of each member at the heads and base assert.equal(record["novox/mesh-controller#346"].siblings[0].merged, true); // A merge of any other head, or onto any other base, is still a move. const other = world(); - const again: SiblingRecord = {}; - noteComposed(again, composedMembers()); + const again = composedRecord(composedMembers()); Object.assign(other.host, { merged: true, state: "closed", merge_commit_sha: MERGE, head_sha: HOST_NEW }); other.parents = [HOST_MOVED, HOST_NEW]; await weigh(other, again); assert.equal(other.written.length, 2); }); - -test("a composed verdict keeps nothing for a member it cannot name, and a group of one is none (issue 1049)", async () => { - const { noteComposed } = await import("../dist/siblings.js"); - const record: SiblingRecord = {}; - const [a, b] = composedMembers(); - assert.equal(noteComposed(record, [a]), false); - assert.equal(noteComposed(record, [a, { ...b, number: undefined }]), false, "a member with no pull request number"); - assert.equal(noteComposed(record, [a, { ...b, trunk: undefined }]), false, "a member with no trunk commit"); - assert.equal(noteComposed(record, [a, { ...b, base: undefined }]), false, "a member with no base branch"); - assert.deepEqual(record, {}); -}); diff --git a/modules/gitea/test/trunk.test.ts b/modules/gitea/test/trunk.test.ts index 2e4b242f..2323f0af 100644 --- a/modules/gitea/test/trunk.test.ts +++ b/modules/gitea/test/trunk.test.ts @@ -6,7 +6,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { checkOnItsWay, lateVerdictComment, outOfDateStatuses, outOfDateWords, trunkNamed, verdictAtTrunk, weighTrunks } from "../dist/trunk.js"; +import { checkOnItsWay, outOfDateStatuses, outOfDateWords, trunkNamed, verdictAtTrunk, weighTrunks } from "../dist/trunk.js"; import { GateWriter } from "../dist/gate.js"; const HEAD = "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; @@ -60,8 +60,6 @@ function aForge(pulls: { number: number; base: string; head: string }[], heads: writes.push(`${sha} ${s.context} ${s.state}`); statuses.set(sha, { ...statuses.get(sha), [s.context]: { state: s.state, description: s.description } }); }, - gateOf: async () => undefined, - gateHistory: async () => [], headChecks: async (_o: string, _r: string, sha: string) => statuses.get(sha) ?? {}, }; return { forge, writes, statuses }; @@ -111,24 +109,6 @@ test("a trunk that cannot be read is weighed again on the next pass, never recor assert.equal(trunks["novox/mesh-host:main"], TRUNK, "not recorded: the next pass weighs it again"); }); -test("the trunk's move takes its turn after a verdict in flight, and weighs what the forge holds then", async () => { - const heads: Record = { main: TRUNK }; - const { forge, statuses } = aForge([{ number: 1, base: "main", head: HEAD }], heads); - const gates = new GateWriter(forge); - // A verdict judged against the trunk's new head, in flight when the move is weighed: it lands first, and is left. - let release!: () => void; - const held = new Promise((r) => (release = r)); - const verdictLanded = gates.verdict("novox", "mesh-host", HEAD, async () => { - await held; - statuses.set(HEAD, { "mesh/merge-gate": { state: "success", description: `head x merged into trunk ${MOVED.slice(0, 12)} — pass` } }); - }); - const moved = gates.outOfDate("novox", "mesh-host", HEAD, (checks) => outOfDateStatuses(checks, MOVED)); - release(); - await verdictLanded; - assert.deepEqual(await moved, [], "a verdict judged against the trunk's head is not put out of date"); - assert.equal(statuses.get(HEAD)!["mesh/merge-gate"].state, "success"); -}); - test("a verdict judged against a trunk newer than the pass's first read is not put out of date", async () => { const NEWER = "ffffffffffffffffffffffffffffffffffffffff"; const heads: Record = { main: TRUNK }; @@ -142,15 +122,6 @@ test("a verdict judged against a trunk newer than the pass's first read is not p assert.equal(statuses.get(HEAD)!["mesh/merge-gate"].state, "success", "a fresh verdict was put out of date"); }); -test("a verdict that landed out of date keeps its account on the pull request", () => { - const words = outOfDateWords(TRUNK, MOVED); - const own = lateVerdictComment({ commit: HEAD, trunk: TRUNK.slice(0, 12), verdict: "fail" }, "**Merge check** at `dfb1415a`\n\nthe report", words); - assert.match(own, /the report/); - assert.match(own, /\*\*Out of date when it landed\*\*: out of date: judged against trunk a2c2c35bbbbb/); - const bare = lateVerdictComment({ commit: HEAD, trunk: TRUNK.slice(0, 12), verdict: "pass" }, null, words); - assert.match(bare, /head dfb1415aaaaa merged into trunk a2c2c35bbbbb, pass/); -}); - test("a release branch's move puts every open pull request into it out of date, as a trunk's does (ADR 0358 §5)", async () => { // novox/hq ADR 0358 §5: for a pull request into a release branch, the release branch takes the trunk's place in ADR // 0323 — the check merges the head onto the release branch's head and names it as the trunk commit, so the release diff --git a/modules/gitea/trunk.ts b/modules/gitea/trunk.ts index 55866f30..608e5805 100644 --- a/modules/gitea/trunk.ts +++ b/modules/gitea/trunk.ts @@ -19,8 +19,7 @@ // // Pure functions and two small files of the module's own state, so this is tested without a forge. -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; -import { dirname } from "node:path"; +import { existsSync, readFileSync } from "node:fs"; import type { CommitStatus, GiteaClient, GiteaPull, HeadCheck } from "./client.js"; import { CHECK_CONTEXT, WAITS_FOR_COMPOSED, maintained, requiredOf } from "./pulls.js"; @@ -112,14 +111,6 @@ export function askedWords(at: Date, trunkHead: string): string { `against trunk ${twelve(trunkHead)}`; } -/** The comment a verdict that landed out of date leaves (ADR 0323 §7: kept and shown): its own account when it has - * one, else what it judged and said, and that it is out of date. */ -export function lateVerdictComment(c: { commit: string; trunk?: string; verdict: string }, account: string | null, - outOfDate: string): string { - const own = account ?? `**Merge check** at \`${c.commit.slice(0, 8)}\`: ${judgedWords(c.commit, c.trunk ?? "")}, ${c.verdict}`; - return `${own}\n\n**Out of date when it landed**: ${outOfDate}`; -} - // --- a trunk's move, weighed -------------------------------------------------------------------------------------- /** What weighing a trunk's move reads of the forge. */ @@ -204,20 +195,6 @@ function readTookAll(file: string | null): Record { } } -/** Keep how long a repository's check took. A record that cannot be written is said, never fatal to the verdict. */ -export function keepTook(file: string | null, repository: string, took: Took): void { - if (!file || !took.took) return; - try { - const all = readTookAll(file); - all[repository.toLowerCase()] = took; - mkdirSync(dirname(file), { recursive: true }); - writeFileSync(file + ".tmp", JSON.stringify(all)); - renameSync(file + ".tmp", file); - } catch (err) { - console.error(`[gitea] how long ${repository}'s check took could not be kept — ${err instanceof Error ? err.message : err}`); - } -} - /** How long a repository's last check took, in words for the merge tool's refusal. */ export function tookWords(file: string | null, repository: string): string { const t = readTookAll(file)[repository.toLowerCase()];