diff --git a/merge-check.sh b/merge-check.sh new file mode 100755 index 0000000..4c77f16 --- /dev/null +++ b/merge-check.sh @@ -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 diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 61aba60..1c46890 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -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 { + 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, }; } diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index 57f5f48..8a96c9d 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -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 { 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 { + 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 { + 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"); } diff --git a/modules/gitea/module.json b/modules/gitea/module.json index d5a29f7..6e677a4 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -40,7 +40,11 @@ "emits": [ "repo.created", "issue.opened", - "pull.merged" + "pull.merged", + "pull.updated" + ], + "consumes": [ + "mesh-controller.checked" ], "listens": [ { diff --git a/modules/gitea/pulls.ts b/modules/gitea/pulls.ts new file mode 100644 index 0000000..bfb8017 --- /dev/null +++ b/modules/gitea/pulls.ts @@ -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; + +/** 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; +} diff --git a/modules/gitea/test/pulls.test.ts b/modules/gitea/test/pulls.test.ts new file mode 100644 index 0000000..b56181c --- /dev/null +++ b/modules/gitea/test/pulls.test.ts @@ -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((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"); +});