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 ---- {