Merge pull request 'Phase 5: check every pull request before it merges, and show the verdict on it (hq ADR 0237)' (#97) from feat/merge-gate into main
mesh/delivery delivered

This commit was merged in pull request #97.
This commit is contained in:
2026-10-06 19:35:57 +00:00
6 changed files with 259 additions and 2 deletions
Executable
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
# The merge check of the catalogue (novox/hq to-be 45 §9), run by the build seat on every pull request
# before it merges. MESH_GATE is the controller the mesh runs — the judge a catalogue change must be
# readable by — built beside this check; MESH_FACTS the facts snapshot it keeps.
#
# 1. every manifest through the controller's own module check;
# 2. the merge gate: every machine of the snapshot composed with this tree's manifests and validated
# by the node-engine's own validator; a manifest the running controller cannot read, an identity a
# real machine's name makes too long, a module removed while a machine runs it, all fail here; how
# wide the merge's rebuild would be is said;
# 3. the Go tests of every module the change touches that has them, its replays among them — and a
# module whose dependencies cannot be fetched here is said as not tested, never passed silently.
set -eu
gate="${MESH_GATE:?the build seat builds the controller the mesh runs as MESH_GATE}"
"$gate" module check modules/*/module.json > /dev/null
"$gate" merge-gate --facts "$MESH_FACTS" --store "$MESH_GATE_POSTGRES" \
--repository "${MESH_CHECK_REPOSITORY:-novox/mesh-catalog}" --tree . \
--changed "${MESH_CHECK_CHANGED:-}" --json > "${MESH_CHECK_VERDICT:-/dev/null}"
touched=$(printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p' | sort -u)
for m in $touched; do
[ -f "modules/$m/go.mod" ] || continue
if ! (cd "modules/$m" && GOPRIVATE=git.novox.be go mod download >/dev/null 2>&1); then
echo "NOT TESTED: modules/$m — its dependencies cannot be fetched by the build seat"
continue
fi
echo "testing modules/$m"
(cd "modules/$m" && GOPRIVATE=git.novox.be go test -count=1 ./...)
done
+19
View File
@@ -43,10 +43,21 @@ export interface GiteaPull {
merged_at?: string;
user?: string;
head?: string;
/** The commit the pull request's head is at now: what is checked before it merges (novox/hq to-be 45 §9). */
head_sha?: string;
base?: string;
updated_at?: string;
html_url: string;
}
/** A commit status, as the forge keeps it: what a pull request shows beside its head commit. */
export interface CommitStatus {
state: "pending" | "success" | "error" | "failure" | "warning";
context: string;
description: string;
target_url?: string;
}
export interface GiteaComment {
id: number;
user?: string;
@@ -314,6 +325,12 @@ export class GiteaClient {
return true;
}
/** Set a commit's status — what a pull request whose head it is shows beside it (novox/hq to-be 45 §9).
* The forge keeps one per context, the newest, so setting it again replaces it. */
async setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void> {
await this.request(`/repos/${owner}/${repo}/statuses/${sha}`, { method: "POST", body: JSON.stringify(status) });
}
async createPullRequest(
owner: string,
repo: string,
@@ -427,7 +444,9 @@ export class GiteaClient {
merged_at: p.merged_at ?? undefined,
user: p.user?.login,
head: p.head?.ref,
head_sha: p.head?.sha ?? undefined,
base: p.base?.ref,
updated_at: p.updated_at ?? undefined,
html_url: p.html_url,
};
}
+90 -1
View File
@@ -4,6 +4,11 @@
// Emits (novox/hq ADR 0041/0042):
// module.gitea.repo.created — a repository appeared, however it was made (push, web UI, or tool)
// module.gitea.pull.merged — a pull request was merged, however it was merged (web UI, API, or tool)
// module.gitea.pull.updated — an open pull request's head moved, opened or pushed to: the mesh checks it
// before it merges (novox/hq to-be 45 §9)
//
// Consumes mesh-controller.checked — a pull request's merge check, judged — and sets it as the head
// commit's status, with a comment saying why when it is not a pass.
//
// issue.opened is emitted from its tool (tools/index.ts). repo.created and pull.merged belong here: a
// repository or a merge is as often made by the web UI or a plain API call, which no tool sees, so
@@ -13,8 +18,9 @@
// The polling is deliberately unhurried: an event a minute late is still an event, whereas hammering
// the forge for an immediacy nobody asked for is not.
import { emit } from "@novox/mesh-sdk/events";
import { emit, on } from "@novox/mesh-sdk/events";
import { GiteaClient, movedSince } from "./client.js";
import { CHECK_CONTEXT, commentFor, headsToAnnounce, statusFor, type Announced, type Checked } from "./pulls.js";
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
@@ -151,6 +157,87 @@ async function pollMerged(client: GiteaClient): Promise<void> {
lastLook = began;
}
// **Every new head of an open pull request is announced, once** (novox/hq to-be 45 §9): the mesh checks it
// against every machine of its facts before it merges. Kept beside the merges' record, so a restart
// announces nothing twice; the first look on a machine with no record announces only what moved in the
// last day, so a forge's whole backlog is not checked at once.
const pullsRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "pulls-announced.json") : null;
let heads: Announced = {};
let primedPulls = false;
if (pullsRecord && existsSync(pullsRecord)) {
try {
heads = (JSON.parse(readFileSync(pullsRecord, "utf8")) as { heads: Announced }).heads ?? {};
primedPulls = true;
} catch {
// An unreadable record is no record: the first look announces only the last day's.
}
}
function keepHeads(): void {
if (!pullsRecord) return;
mkdirSync(join(pullsRecord, ".."), { recursive: true });
const tmp = pullsRecord + ".tmp";
writeFileSync(tmp, JSON.stringify({ heads }));
renameSync(tmp, pullsRecord);
}
let lastPullLook = "";
async function pollPulls(client: GiteaClient): Promise<void> {
const began = new Date().toISOString();
const floor = lastPullLook ? new Date(Date.parse(lastPullLook) - MARGIN_MS).toISOString() : "";
const dayAgo = new Date(Date.now() - 24 * 3600_000).toISOString();
let changed = false;
for (const repo of movedSince(await client.listAllRepos(), floor)) {
const open = await client.listPullRequests(repo.owner, repo.name, { state: "open", sort: "recentupdate", limit: "20" });
for (const pull of headsToAnnounce(repo.full_name, open, heads)) {
const key = `${repo.full_name}#${pull.number}`;
const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo);
if (fresh) {
const files = await client.listPullFiles(repo.owner, repo.name, pull.number);
await emit("pull.updated", {
owner: repo.owner,
repo: repo.name,
number: pull.number,
title: pull.title,
base: pull.base,
head: pull.head,
head_sha: pull.head_sha,
clone_url: repo.clone_url,
html_url: pull.html_url,
paths: files.paths,
paths_truncated: files.truncated,
});
// Said, so an operator knows the mesh was asked to check it.
console.log(`[gitea] announced ${key} at ${String(pull.head_sha).slice(0, 8)} to be checked before it merges`);
// Pending until the verdict comes, so the pull request says a check is running rather than nothing.
await client
.setCommitStatus(repo.owner, repo.name, String(pull.head_sha), {
state: "pending", context: CHECK_CONTEXT, description: "the mesh is checking this head against every machine",
})
.catch((err) => console.error(`[gitea] ${key}: could not say a check is pending — ${err instanceof Error ? err.message : err}`));
}
heads[key] = String(pull.head_sha);
changed = true;
}
}
if (!primedPulls || changed) keepHeads();
primedPulls = true;
lastPullLook = began;
}
// **The verdict, set where the pull request shows it.** Every verdict is the head commit's status; one
// that is not a pass also leaves the check's own account as a comment, so the reason is read where the
// change is reviewed. An error — the check could not run — is the forge's `error`, never a success.
async function setVerdict(client: GiteaClient, event: { body: unknown }): Promise<void> {
const c = (event.body ?? {}) as Checked;
if (!c.owner || !c.repo || !c.commit || !c.verdict) {
console.error("[gitea] a merge check's verdict named no repository, commit or verdict; ignored");
return;
}
await client.setCommitStatus(c.owner, c.repo, c.commit, statusFor(c));
const comment = commentFor(c);
if (comment && c.number) await client.addComment(c.owner, c.repo, c.number, comment);
console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}`);
}
if (gitea) {
const client = gitea;
// A poll that fails says so once, not once a minute: the same reason repeating (the forge not up
@@ -176,5 +263,7 @@ if (gitea) {
};
tick(() => pollRepos(client), 60_000);
tick(() => pollMerged(client), 30_000);
tick(() => pollPulls(client), 30_000);
await on("mesh-controller.checked", (event) => setVerdict(client, event));
console.log("[gitea] watching for new repositories and merged pull requests");
}
+5 -1
View File
@@ -40,7 +40,11 @@
"emits": [
"repo.created",
"issue.opened",
"pull.merged"
"pull.merged",
"pull.updated"
],
"consumes": [
"mesh-controller.checked"
],
"listens": [
{
+56
View File
@@ -0,0 +1,56 @@
// A pull request's merge check (novox/hq to-be 45 §9): what the forge's announcer says when a pull
// request's head moves, and what it sets as the pull request's status when the mesh says the verdict.
//
// **Before merge, never after.** Every check the mesh had ran after a merge, on a machine: a manifest the
// node-engine refuses (issue 236), an identity a real machine's name made too long (263). So each new head
// of an open pull request is announced as `pull.updated`; the controller asks the build seat to check it
// against every machine of the mesh's facts; and the verdict comes back as the controller's `checked`,
// which this sets as the head commit's status — and, when it is not a pass, as a comment saying why.
//
// Pure functions here, so they are tested without a forge; index.ts does the asking and the setting.
import type { CommitStatus, GiteaPull } from "./client.js";
/** The status context a merge check is kept under: one per commit, the newest replacing the last. */
export const CHECK_CONTEXT = "mesh/merge-gate";
/** What the controller says as `checked` (mesh-controller internal/link, Checked). */
export interface Checked {
owner: string;
repo: string;
number?: number;
commit: string;
verdict: string;
summary: string;
report?: string;
id: string;
on?: string;
}
/** The heads already announced, keyed `owner/repo#number`, so a restart announces nothing twice. */
export type Announced = Record<string, string>;
/** Which open pull requests have a head not yet announced. A pull request merged or closed is not
* open and is never asked about. */
export function headsToAnnounce(full: string, pulls: GiteaPull[], announced: Announced): GiteaPull[] {
return pulls.filter((p) => p.state === "open" && !!p.head_sha && announced[`${full}#${p.number}`] !== p.head_sha);
}
/** The forge's status for a verdict: an error is the forge's `error`, never a success. */
export function statusFor(c: Checked): CommitStatus {
const state: CommitStatus["state"] =
c.verdict === "pass" ? "success" : c.verdict === "warning" ? "warning" : c.verdict === "fail" ? "failure" : "error";
// The forge keeps a short description; the rest is the comment's.
let description = `${c.verdict}: ${c.summary}`.replace(/\s+/g, " ").trim();
if (description.length > 140) description = description.slice(0, 139) + "…";
return { state, context: CHECK_CONTEXT, description };
}
/** The comment a verdict that is not a pass leaves on its pull request: the check's own account. */
export function commentFor(c: Checked): string | null {
if (c.verdict === "pass") return null;
const head = `**Merge check: ${c.verdict.toUpperCase()}** at \`${c.commit.slice(0, 8)}\` — ${c.summary}`;
const ran = c.on ? `\n\nRun by the build seat on ${c.on} as \`${c.id}\` (\`builds --log ${c.id}\`).` : "";
const report = c.report ? `\n\n\`\`\`\n${c.report.replace(/```/g, "'''")}\n\`\`\`` : "";
return head + ran + report;
}
+59
View File
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createServer } from "node:http";
// A pull request's merge check (novox/hq to-be 45 §9): each new head announced once, and the verdict set
// where the pull request shows it — an error never as a success.
test("each open pull request's new head is announced once, and nothing closed or merged", async () => {
const { headsToAnnounce } = await import("../pulls.ts");
const pulls = [
{ number: 1, title: "a", state: "open", merged: false, head_sha: "aaa", html_url: "" },
{ number: 2, title: "b", state: "open", merged: false, head_sha: "bbb", html_url: "" },
{ number: 3, title: "c", state: "closed", merged: true, head_sha: "ccc", html_url: "" },
{ number: 4, title: "d", state: "open", merged: false, html_url: "" },
];
const announced = { "novox/mesh-catalog#1": "aaa", "novox/mesh-catalog#2": "old" };
assert.deepEqual(headsToAnnounce("novox/mesh-catalog", pulls, announced).map((p) => p.number), [2],
"only a head not announced, of a pull request that is open");
});
test("a verdict is the head commit's status; an error is the forge's error, never a success", async () => {
const { statusFor, commentFor, CHECK_CONTEXT } = await import("../pulls.ts");
const base = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "build-1", on: "laptop" };
assert.equal(statusFor({ ...base, verdict: "pass", summary: "every machine composes" }).state, "success");
assert.equal(statusFor({ ...base, verdict: "warning", summary: "a merge rebuilds 14 module(s)" }).state, "warning");
assert.equal(statusFor({ ...base, verdict: "fail", summary: "x" }).state, "failure");
assert.equal(statusFor({ ...base, verdict: "error", summary: "the check could not run" }).state, "error");
assert.equal(statusFor({ ...base, verdict: "", summary: "" }).state, "error", "no verdict is no pass");
const long = statusFor({ ...base, verdict: "fail", summary: "y".repeat(500) });
assert.ok(long.description.length <= 140 && long.context === CHECK_CONTEXT);
assert.equal(commentFor({ ...base, verdict: "pass", summary: "fine" }), null, "a pass leaves no comment");
const said = commentFor({ ...base, verdict: "fail", summary: "lemurs refused", report: "fails:\n - ```x```" }) ?? "";
assert.match(said, /Merge check: FAIL/);
assert.match(said, /01234567/);
assert.match(said, /builds --log build-1/);
assert.ok(!said.slice(said.indexOf("```") + 3, said.lastIndexOf("```")).includes("```"), "the report cannot close its own block");
});
test("a commit status is set on the commit, under the merge check's context", async () => {
const { GiteaClient } = await import("../client.ts");
let seen: { path: string; body: any } | null = null;
const server = createServer((req, res) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => {
seen = { path: `${req.method} ${req.url}`, body: JSON.parse(raw) };
res.statusCode = 201;
res.end("{}");
});
});
await new Promise<void>((r) => server.listen(0, r));
const port = (server.address() as any).port;
const client = new GiteaClient(`http://127.0.0.1:${port}`, "t");
await client.setCommitStatus("novox", "mesh-catalog", "abc123", { state: "failure", context: "mesh/merge-gate", description: "x" });
server.close();
assert.equal(seen!.path, "POST /api/v1/repos/novox/mesh-catalog/statuses/abc123");
assert.equal(seen!.body.state, "failure");
assert.equal(seen!.body.context, "mesh/merge-gate");
});