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
mesh/delivery delivered
This commit was merged in pull request #97.
This commit is contained in:
Executable
+30
@@ -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
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -40,7 +40,11 @@
|
||||
"emits": [
|
||||
"repo.created",
|
||||
"issue.opened",
|
||||
"pull.merged"
|
||||
"pull.merged",
|
||||
"pull.updated"
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-controller.checked"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user