Check every pull request before it merges, and show the verdict on it (hq ADR 0237, to-be 45 §9)

The forge's announcer announces each new head of an open pull request as pull.updated, once,
and marks the head pending; the controller asks the build seat to check it against every
machine of the mesh's facts, and says the verdict as checked, which the forge's holder sets as
the head commit's status mesh/merge-gate - an error never as a success - with the check's own
account as a comment when it is not a pass. merge-check.sh is the catalogue's check: every
manifest through the running controller's module check and merge gate, and the Go tests of each
module the change touches, a module whose dependencies cannot be fetched said as not tested.
This commit is contained in:
jochen
2026-10-06 21:16:32 +02:00
parent 1ba2c0513f
commit 9951718623
6 changed files with 259 additions and 2 deletions
+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");
});