From 5053e96d4e65ec9ad5812d138281067611da2cf1 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 12:20:23 +0200 Subject: [PATCH] Route a recheck's pending through the gitea module's one writer of mesh/merge-gate The recheck's pending was written from the tools' bundle, its own process, past the writer's per-head chain: the first run's verdict mark stayed set, so a rechecked head never said where its new run builds, and an old run's verdict in flight or redelivered could land over the pending. The status tool is now served from the watcher's bundle and writes the merge check's pending through the writer, and a verdict whose ask (checked.id) predates the check now pending is dropped, weighed after a restart against the forge's own history of the gate (novox/hq issue 456). --- modules/gitea/client.ts | 13 +++ modules/gitea/delivery.ts | 47 +++++++++ modules/gitea/gate.ts | 120 +++++++++++++++++++++- modules/gitea/index.ts | 19 +++- modules/gitea/test/gate.test.ts | 168 ++++++++++++++++++++++++++++++- modules/gitea/test/guard.test.ts | 10 +- modules/gitea/test/token.test.ts | 2 +- modules/gitea/tools/index.ts | 23 +---- 8 files changed, 370 insertions(+), 32 deletions(-) diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index eb3879b1..eb3b8c93 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -465,6 +465,19 @@ 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/delivery.ts b/modules/gitea/delivery.ts index ec667ade..43277a67 100644 --- a/modules/gitea/delivery.ts +++ b/modules/gitea/delivery.ts @@ -13,6 +13,9 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; +import type { ToolDefinition } from "@novox/mesh-sdk/tools"; +import type { CommitStatus } from "./client.js"; +import { checkName } from "./guard.js"; /** How a command is run: docker on the forge's machine, or a test's. */ export type Runner = (file: string, args: string[]) => Promise<{ stdout: string; code: number }>; @@ -106,3 +109,47 @@ export function deliveryStatus(context: string, state: string, description: stri return { state: state as "pending" | "success" | "error" | "failure" | "warning", context, description: d, ...(target && /^https?:\/\//.test(target) ? { target_url: target } : {}) }; } + +/** What the status tool writes with: the forge, and the gate writer the merge check's statuses go through. */ +export interface StatusWriters { + setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise; + gates: { rechecked(owner: string, repo: string, sha: string, status: CommitStatus): Promise }; +} + +/** + * The `gitea_commit_status` tool. **The merge check's pending goes through the module's one writer** (novox/hq issue + * 456): a recheck's pending on mesh/merge-gate or mesh/repo-check takes its turn on the head's chain (gate.ts), so an + * old run's verdict in flight lands before it and a redelivered one is dropped. Served from the watcher's bundle + * (index.ts), the process that holds that writer: each bundle the runtime serves is its own process. The mesh's other + * statuses (mesh/delivery, mesh/delivery-group) are written as they were. + */ +export function commitStatusTool(writers: StatusWriters): ToolDefinition { + return { + name: "gitea_commit_status", + description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. The merge check's statuses (mesh/merge-gate, mesh/repo-check) take pending only — a recheck putting them back until the fresh verdict sets them — on the head's whole sha, in their turn after the check's earlier writes.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + sha: { type: "string", description: "the commit" }, + context: { type: "string", description: "the status's name, mesh/…" }, + state: { type: "string", description: "pending | success | error | failure | warning" }, + description: { type: "string", description: "one line" }, + target_url: { type: "string", description: "the page it links to (the pull request)" }, + }, + run: async (args) => { + const owner = checkName("owner", args.owner), repo = checkName("name", args.repo), sha = String(args.sha ?? ""); + const status = deliveryStatus(String(args.context), String(args.state), String(args.description ?? ""), + args.target_url ? String(args.target_url) : undefined); + if (mergeCheckContexts.has(status.context)) { + // The writer keeps a head by its whole sha, as the forge announced it: a short one would be another head. + if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) { + throw new Error(`${JSON.stringify(args.sha)} is not a head's whole sha: the merge check's status names the head whole. Nothing was sent`); + } + await writers.gates.rechecked(owner, repo, sha, status); + } else { + await writers.setCommitStatus(owner, repo, sha, status); + } + return { set: status }; + }, + }; +} diff --git a/modules/gitea/gate.ts b/modules/gitea/gate.ts index b636a422..9a19ac72 100644 --- a/modules/gitea/gate.ts +++ b/modules/gitea/gate.ts @@ -8,6 +8,14 @@ // 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. import { CHECK_CONTEXT, pendingWords } from "./pulls.js"; import type { CommitStatus } from "./client.js"; @@ -27,15 +35,53 @@ 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; +} + +/** 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 is running begin. */ +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 ${on} since ${two(at.getHours())}:${two(at.getMinutes())}`; + 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. */ @@ -50,6 +96,8 @@ interface Head { 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; } @@ -94,12 +142,38 @@ export class GateWriter { pending(owner: string, repo: string, sha: string): Promise { const h = this.head(owner, repo, sha); const at = this.now(); - h.pendingAt = at.getTime(); - h.verdict = false; + 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. + */ + 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; + }); + } + /** * 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. @@ -130,10 +204,46 @@ export class GateWriter { /** * 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): Promise { + 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; - return this.turn(h, write); + 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 96d05528..4cbd90fa 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -17,6 +17,9 @@ // Consumes node-build-agent.started — a build machine took an ask — and says, on the head a pending check is of, // which machine builds it since when (novox/hq issue 446). This module is the one writer of mesh/merge-gate. // +// Serves gitea_commit_status from this bundle, not the tools', so a recheck's pending reaches that one writer +// (novox/hq issue 456). +// // Every pull request the forge holds is announced, whatever its repository: the controller holds the // module graph and decides what is checked — a repository is never asked to opt in. // @@ -29,6 +32,8 @@ // the forge for an immediacy nobody asked for is not. import { emit, on } from "@novox/mesh-sdk/events"; +import { registerModuleTools } from "@novox/mesh-sdk/tools"; +import { commitStatusTool } from "./delivery.js"; import { GiteaClient, movedSince } from "./client.js"; import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked, type RepoCheckFacts, reposToLook } from "./pulls.js"; @@ -344,8 +349,17 @@ async function repoCheckFacts(client: GiteaClient, c: Checked, base: string | un 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), }); +// **The status tool is served here, beside the writer** (novox/hq issue 456): the runtime starts each bundle as its own +// process, so a recheck's pending written from the tools' bundle bypassed this writer's chain. Without a forge to +// reach, nothing is served, as the tools' bundle serves nothing then. +registerModuleTools("gitea", () => (gitea ? [commitStatusTool({ + setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status), + gates, +})] : [])); + // **A build machine took an ask** (the build seat's `started`, novox/hq ADR 0157): said on the head it checks, when // the head is an open pull request's this module announced. Any other build — a branch, a merge's — says nothing. async function buildStarted(event: { body: unknown }): Promise { @@ -392,7 +406,10 @@ if (gitea) { await on("mesh-controller.checked", (event) => { const c = (event.body ?? {}) as Checked; // A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts). - if (!c.group && c.owner && c.repo && c.commit) return gates.verdict(c.owner, c.repo, c.commit, () => setVerdict(client, event)); + // A verdict of a check asked before the one now pending is an older run's, and dropped (novox/hq issue 456). + if (!c.group && c.owner && c.repo && c.commit) { + return gates.verdict(c.owner, c.repo, c.commit, () => setVerdict(client, event), c.id); + } return setVerdict(client, event); }); await on("node-build-agent.started", buildStarted); diff --git a/modules/gitea/test/gate.test.ts b/modules/gitea/test/gate.test.ts index 1178668c..4567a76f 100644 --- a/modules/gitea/test/gate.test.ts +++ b/modules/gitea/test/gate.test.ts @@ -18,12 +18,19 @@ const startedOnShanks = { id: "build-1791680941048622697", repository: "http://1 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, + kept, written, history, + /** The next writes fail: a request the forge did not take. */ + failWrites(n = 1) { + failNext = n; + }, /** The next writes wait until released: a request in flight. */ holdWrites() { hold = new Promise((r) => (release = r)); @@ -34,7 +41,12 @@ function aForge() { }, async setCommitStatus(_o: string, _r: string, _sha: string, s: { state: string; context: string; description: string }) { if (hold) await hold; + if (failNext > 0) { + failNext--; + 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. */ @@ -50,6 +62,9 @@ function aForge() { if (readHold) await readHold; return held; }, + async gateHistory(_o: string, _r: string, _sha: string) { + return history.map((h) => ({ ...h })); + }, }; } @@ -138,14 +153,159 @@ test("a start from before the check now pending (a run before a recheck, the str 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: pending once more - await gates.pending("novox", "hq", SHA); + 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.match(forge.kept["mesh/merge-gate"].description, /^waiting in the build queue since /); + 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. + +/** 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("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("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(); + 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"); +}); + +test("the status tool writes the merge check's pending through the writer, and the mesh's other statuses as they were", async () => { + const { GateWriter } = await import("../gate.ts"); + const { commitStatusTool } = await import("../delivery.ts"); + const forge = aForge(); + const direct: string[] = []; + const gates = new GateWriter(forge, () => recheckAt); + const tool = commitStatusTool({ + 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 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(); diff --git a/modules/gitea/test/guard.test.ts b/modules/gitea/test/guard.test.ts index 92aa711b..a9d84830 100644 --- a/modules/gitea/test/guard.test.ts +++ b/modules/gitea/test/guard.test.ts @@ -11,6 +11,8 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht import { GiteaClient } from "../dist/client.js"; import { ConfiguredToken } from "../dist/token.js"; import { getGiteaTools } from "../dist/tools/index.js"; +import { commitStatusTool } from "../dist/delivery.js"; +import { GateWriter } from "../dist/gate.js"; import { judgeApiCall, judgeProtectionChange, ruleCovers, routedPath } from "../dist/guard.js"; interface Sent { method: string; path: string; body: any } @@ -97,7 +99,13 @@ const server = createServer(async (req, res) => { await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); after(() => new Promise((r) => server.close(() => r()))); const { port } = server.address() as { port: number }; -const tools = getGiteaTools(new GiteaClient(`http://127.0.0.1:${port}`, new ConfiguredToken("t"))); +const client = new GiteaClient(`http://127.0.0.1:${port}`, new ConfiguredToken("t")); +// gitea_commit_status is served from the watcher's bundle, beside the merge check's one writer (novox/hq issue 456). +const tools = [...getGiteaTools(client), commitStatusTool({ + setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s), + gates: new GateWriter({ setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s), + gateOf: async () => undefined, gateHistory: async () => [] }), +})]; const tool = (name: string) => tools.find((t) => t.name === name)!; const writes = () => state.sent.map((s) => `${s.method} ${s.path}`); diff --git a/modules/gitea/test/token.test.ts b/modules/gitea/test/token.test.ts index cd192a8c..3bbfc5e9 100644 --- a/modules/gitea/test/token.test.ts +++ b/modules/gitea/test/token.test.ts @@ -426,7 +426,7 @@ test("the tools register once there is a way to a token, and the first call mint "gitea_list_branches", "gitea_delete_branch", "gitea_branch_protection_get", "gitea_branch_protection_set", - "gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_contains", "gitea_commit_status", + "gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_contains", "gitea_list_labels", "gitea_create_label", "gitea_api", ], diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index ce5985a3..ede929c7 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -11,7 +11,7 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { emit } from "@novox/mesh-sdk/events"; import { GiteaClient, type BranchProtection } from "../client.js"; -import { appendNote, deliveryStatus, run, viewBody, viewComment } from "../delivery.js"; +import { appendNote, run, viewBody, viewComment } from "../delivery.js"; import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js"; import { MERGE_METHODS, Refused, checkName, judgeApiCall, judgeContentsWrite, judgeDelete, judgeProtectionChange, pathSegments, routedPath, sentQuery, @@ -639,25 +639,8 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] { return { commit, in: within, contains, ahead }; }, }, - { - name: "gitea_commit_status", - description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. The merge check's statuses (mesh/merge-gate, mesh/repo-check) take pending only — a recheck putting them back until the fresh verdict sets them.", - input: { - owner: { type: "string", description: "the repository owner" }, - repo: { type: "string", description: "the repository name" }, - sha: { type: "string", description: "the commit" }, - context: { type: "string", description: "the status's name, mesh/…" }, - state: { type: "string", description: "pending | success | error | failure | warning" }, - description: { type: "string", description: "one line" }, - target_url: { type: "string", description: "the page it links to (the pull request)" }, - }, - run: async (args) => { - const status = deliveryStatus(String(args.context), String(args.state), String(args.description ?? ""), - args.target_url ? String(args.target_url) : undefined); - await gitea.setCommitStatus(String(args.owner), String(args.repo), String(args.sha), status); - return { set: status }; - }, - }, + // gitea_commit_status is served from the watcher's bundle (index.ts), beside the merge check's one writer + // (novox/hq issue 456). // ---- Labels ---- {