Merge pull request 'gitea: a recheck's pending goes through the one writer of mesh/merge-gate (issue 456)' (#234) from fix/456-a-recheck-writes-through-the-one-writer into main
This commit was merged in pull request #234.
This commit is contained in:
@@ -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<HeadCheck[]> {
|
||||
const raw = await this.request<any>(`${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[] }> {
|
||||
|
||||
@@ -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<void>;
|
||||
gates: { rechecked(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void> };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+115
-5
@@ -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-<unix nanoseconds>` 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<void>;
|
||||
/** 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<GateStatus[]>;
|
||||
}
|
||||
|
||||
/** 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-<unix nanoseconds>`.
|
||||
* 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<void>;
|
||||
}
|
||||
@@ -94,12 +142,38 @@ export class GateWriter {
|
||||
pending(owner: string, repo: string, sha: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void>): Promise<void> {
|
||||
verdict(owner: string, repo: string, sha: string, write: () => Promise<void>, id?: string): Promise<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
+18
-1
@@ -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<void> {
|
||||
@@ -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);
|
||||
|
||||
@@ -18,12 +18,19 @@ const startedOnShanks = { id: "build-1791680941048622697", repository: "http://1
|
||||
function aForge() {
|
||||
const kept: Record<string, { state: string; description: string; updated_at: string }> = {};
|
||||
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<void> | null = null;
|
||||
let release = () => {};
|
||||
let readHold: Promise<void> | 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<void>((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<typeof aForge>, state: string, description: string) => () =>
|
||||
forge.setCommitStatus("novox", "hq", SHA, { state, context: "mesh/merge-gate", description });
|
||||
const gateOf = (forge: ReturnType<typeof aForge>) =>
|
||||
`${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();
|
||||
|
||||
@@ -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<void>((r) => server.listen(0, "127.0.0.1", () => r()));
|
||||
after(() => new Promise<void>((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}`);
|
||||
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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 ----
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user