From bd7b757dd99de7899dde29dce74294720f82a8e2 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 6 Oct 2026 21:56:04 +0200 Subject: [PATCH 1/4] gitea: give the controller what it maps a pull request onto the graph with; set both check statuses; protect a branch by tool (hq ADR 0237) The controller now decides what a pull request's check runs from the mesh's module graph, so the announcement carries the changed directories that hold a module at the head and whether the head has a merge-check.sh. A verdict sets mesh/merge-gate (the gate, with the modules it judged) and mesh/repo-check (the repository's own tests). gitea_branch_protection_get/set let the operator's agent make those statuses required. The catalogue's merge-check.sh leaves the gate to the build seat and keeps its own layer: every manifest through module check, and the touched Go modules' tests. --- merge-check.sh | 53 +++++++++++++++------- modules/gitea/client.ts | 35 +++++++++++++++ modules/gitea/index.ts | 28 ++++++++++-- modules/gitea/protection.ts | 37 +++++++++++++++ modules/gitea/pulls.ts | 65 +++++++++++++++++++++------ modules/gitea/test/protection.test.ts | 21 +++++++++ modules/gitea/test/pulls.test.ts | 28 +++++++++++- modules/gitea/tools/index.ts | 53 +++++++++++++++++++++- modules/gitea/tsconfig.json | 2 +- 9 files changed, 285 insertions(+), 37 deletions(-) create mode 100644 modules/gitea/protection.ts create mode 100644 modules/gitea/test/protection.test.ts diff --git a/merge-check.sh b/merge-check.sh index 4c77f16..d175136 100755 --- a/merge-check.sh +++ b/merge-check.sh @@ -1,30 +1,49 @@ #!/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. +# mesh-check-toolchain: go # -# 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. +# The catalogue's own check (novox/hq ADR 0237 as amended): the second layer of a pull request's merge +# check, `mesh/repo-check`, run by the build seat in the mesh's Go toolchain. +# +# The gate — the manifests the change touches through the running controller's module check, every machine +# of the facts snapshot composed with them and validated by the node-engine's own validator, the replays +# against this tree — is the build seat's first layer (`mesh/merge-gate`), run because the mesh's module +# graph builds these modules from here. It is not repeated here. This is the repository's own: +# +# 1. every manifest through the controller's module check, so one module's change cannot leave another +# it shares a rule with refused (MESH_GATE is the controller the mesh runs); +# 2. the Go tests of every module the change touches that has them, under the race detector when the +# toolchain has a C compiler — and a module whose dependencies cannot be fetched here, or that is +# written in TypeScript, 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}" +if [ -n "${MESH_GATE:-}" ]; then + "$MESH_GATE" module check modules/*/module.json > /dev/null +else + echo "NOT CHECKED: no controller was built beside this check, so the manifests were not read by one" +fi + +race="" +if command -v gcc >/dev/null 2>&1; then race="-race"; else echo "NOT RACE-CHECKED: the toolchain holds no C compiler"; fi 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 + [ -d "modules/$m" ] || continue + if [ ! -f "modules/$m/go.mod" ]; then + [ -f "modules/$m/package.json" ] && echo "NOT TESTED HERE: modules/$m is TypeScript; the gate judges its manifest" + continue + fi 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 ./...) + unformatted=$(cd "modules/$m" && gofmt -l .) + if [ -n "$unformatted" ]; then + echo "modules/$m is not gofmt'd: $unformatted" + exit 1 + fi + cgo=0 + [ -n "$race" ] && cgo=1 + (cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet ./... && + CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test $race -count=1 ./...) done diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 1c46890..9d23755 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -5,6 +5,8 @@ import { readFileSync } from "node:fs"; import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js"; +import { protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js"; +export type { BranchProtection, ProtectionWanted } from "./protection.js"; /** A repository, trimmed to what the mesh cares about. */ export interface GiteaRepo { @@ -311,6 +313,11 @@ export class GiteaClient { return out; } + /** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */ + async holdsFile(owner: string, repo: string, sha: string, file: string): Promise { + return this.exists(`/repos/${owner}/${repo}/contents/${encodePath(file)}?ref=${encodeURIComponent(sha)}`); + } + /** Whether the forge has something at a path: true for an answer, false for a 404, thrown otherwise. */ private async exists(path: string): Promise { let token = await this.tokens.current(); @@ -331,6 +338,34 @@ export class GiteaClient { await this.request(`/repos/${owner}/${repo}/statuses/${sha}`, { method: "POST", body: JSON.stringify(status) }); } + // ---- Branch protection ---- + + /** A repository's branch protection rules. */ + async branchProtections(owner: string, repo: string): Promise { + return (await this.request(`/repos/${owner}/${repo}/branch_protections`)) ?? []; + } + + /** The rule named for a branch, null when it has none. */ + async branchProtection(owner: string, repo: string, rule: string): Promise { + return (await this.branchProtections(owner, repo)).find((p) => (p.rule_name ?? p.branch_name) === rule) ?? null; + } + + /** Make a branch require these commit statuses to merge (novox/hq ADR 0237): the rule named for the + * branch is edited — its required statuses replaced by these, everything else it says kept unless + * asked — or created, refusing direct pushes unless asked otherwise. Answers the rule as it is now. */ + async setBranchProtection(owner: string, repo: string, branch: string, want: ProtectionWanted): Promise<{ created: boolean; rule: BranchProtection }> { + const had = await this.branchProtection(owner, repo, branch); + const body = protectionBody(want, !had); + if (had) { + const rule = await this.request(`/repos/${owner}/${repo}/branch_protections/${encodeURIComponent(branch)}`, + { method: "PATCH", body: JSON.stringify(body) }); + return { created: false, rule }; + } + const rule = await this.request(`/repos/${owner}/${repo}/branch_protections`, + { method: "POST", body: JSON.stringify({ rule_name: branch, ...body }) }); + return { created: true, rule }; + } + async createPullRequest( owner: string, repo: string, diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index 8a96c9d..b0cfac0c 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -8,7 +8,12 @@ // 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. +// commit's statuses: `mesh/merge-gate`, the modules of the mesh's graph the change touches, and +// `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment +// saying why when the gate is not a pass or the repository's own check failed. +// +// 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. // // 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 @@ -20,7 +25,7 @@ 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"; +import { CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, 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 @@ -192,6 +197,19 @@ async function pollPulls(client: GiteaClient): Promise { const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo); if (fresh) { const files = await client.listPullFiles(repo.owner, repo.name, pull.number); + const head = String(pull.head_sha); + // What the controller maps the change onto the mesh's module graph with (novox/hq ADR 0237 as + // amended): which changed directories hold a module at the head — the merge's rule (issue 278) — + // and whether the head holds the repository's own merge-check.sh. Not said when it could not be + // read; the controller then reads the change as touching everything built from the repository. + let moduleDirs: string[] | null = null; + let mergeCheck: boolean | null = null; + try { + if (!files.truncated) moduleDirs = await client.moduleDirsAt(repo.owner, repo.name, head, files.paths); + mergeCheck = await client.holdsFile(repo.owner, repo.name, head, "merge-check.sh"); + } catch (err) { + console.error(`[gitea] ${key}: what the head holds could not be read — ${err instanceof Error ? err.message : err}`); + } await emit("pull.updated", { owner: repo.owner, repo: repo.name, @@ -204,13 +222,15 @@ async function pollPulls(client: GiteaClient): Promise { html_url: pull.html_url, paths: files.paths, paths_truncated: files.truncated, + ...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}), + ...(mergeCheck !== null ? { merge_check: mergeCheck, merge_check_said: true } : {}), }); // 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", + state: "pending", context: CHECK_CONTEXT, description: "the mesh is mapping this head onto its module graph", }) .catch((err) => console.error(`[gitea] ${key}: could not say a check is pending — ${err instanceof Error ? err.message : err}`)); } @@ -232,7 +252,7 @@ async function setVerdict(client: GiteaClient, event: { body: unknown }): Promis 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)); + for (const status of statusesFor(c)) await client.setCommitStatus(c.owner, c.repo, c.commit, status); 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}`); diff --git a/modules/gitea/protection.ts b/modules/gitea/protection.ts new file mode 100644 index 0000000..5751c2c --- /dev/null +++ b/modules/gitea/protection.ts @@ -0,0 +1,37 @@ +// A branch's protection (novox/hq ADR 0237): what makes a pull request wait for the mesh's merge check — +// `mesh/merge-gate`, the module graph's gate, and `mesh/repo-check`, the repository's own tests — before it +// may merge. Pure, so it is tested without a forge; the client does the asking (client.ts). + +/** A branch protection rule, as the forge keeps it — the fields the mesh reads; the forge sends more. */ +export interface BranchProtection { + rule_name?: string; + branch_name?: string; + enable_push?: boolean; + enable_status_check?: boolean; + status_check_contexts?: string[] | null; + required_approvals?: number; + block_admin_merge_override?: boolean; + [field: string]: unknown; +} + +/** What a branch's protection is set to. A field not given is left as the rule has it (or the forge's + * default on a new rule), except pushes, which a new rule refuses unless `push` says otherwise. */ +export interface ProtectionWanted { + /** The statuses a pull request must have succeeded before it merges, e.g. mesh/merge-gate. Empty: none. */ + statusChecks: string[]; + /** Whether a person may push to the branch directly. */ + push?: boolean; + /** Whether an administrator is stopped from merging past a status that has not succeeded. */ + blockAdminOverride?: boolean; +} + +/** The forge's body for a wanted protection. */ +export function protectionBody(want: ProtectionWanted, creating: boolean): Record { + const checks = [...new Set(want.statusChecks.map((c) => c.trim()).filter(Boolean))]; + const body: Record = { enable_status_check: checks.length > 0, status_check_contexts: checks }; + if (want.push !== undefined) body.enable_push = want.push; + else if (creating) body.enable_push = false; + if (want.blockAdminOverride !== undefined) body.block_admin_merge_override = want.blockAdminOverride; + return body; +} + diff --git a/modules/gitea/pulls.ts b/modules/gitea/pulls.ts index bfb8017..8eac731 100644 --- a/modules/gitea/pulls.ts +++ b/modules/gitea/pulls.ts @@ -11,9 +11,19 @@ import type { CommitStatus, GiteaPull } from "./client.js"; -/** The status context a merge check is kept under: one per commit, the newest replacing the last. */ +/** The status context a merge check's gate is kept under: one per commit, the newest replacing the last. */ export const CHECK_CONTEXT = "mesh/merge-gate"; +/** The status context of the repository's own merge-check.sh, the check's second layer (novox/hq ADR 0237). */ +export const REPO_CHECK_CONTEXT = "mesh/repo-check"; + +/** One layer of a check, judged. */ +export interface Layer { + verdict: string; + summary: string; + modules?: string[]; +} + /** What the controller says as `checked` (mesh-controller internal/link, Checked). */ export interface Checked { owner: string; @@ -25,6 +35,10 @@ export interface Checked { report?: string; id: string; on?: string; + /** The gate — the modules of the mesh's graph the change touches — and the repository's own check. A + * controller from before the layers says neither, and its verdict is the gate's. */ + gate?: Layer; + "repo-check"?: Layer; } /** The heads already announced, keyed `owner/repo#number`, so a restart announces nothing twice. */ @@ -36,21 +50,46 @@ export function headsToAnnounce(full: string, pulls: GiteaPull[], announced: Ann 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 forge's state for a verdict: an error is the forge's `error`, never a success. */ +function stateOf(verdict: string): CommitStatus["state"] { + return verdict === "pass" ? "success" : verdict === "warning" ? "warning" : verdict === "fail" ? "failure" : "error"; } -/** The comment a verdict that is not a pass leaves on its pull request: the check's own account. */ +function described(verdict: string, summary: string, modules?: string[]): string { + // The forge keeps a short description; the rest is the comment's. + let d = `${verdict || "error"}: ${summary}`; + if (modules?.length) d += ` [${modules.join(", ")}]`; + d = d.replace(/\s+/g, " ").trim(); + return d.length > 140 ? d.slice(0, 139) + "…" : d; +} + +/** The forge's status for the gate. */ +export function statusFor(c: Checked): CommitStatus { + const gate = c.gate ?? { verdict: c.verdict, summary: c.summary }; + return { state: stateOf(gate.verdict), context: CHECK_CONTEXT, description: described(gate.verdict, gate.summary, gate.modules) }; +} + +/** Every status a verdict sets: the gate's, and the repository's own check's when it was said. */ +export function statusesFor(c: Checked): CommitStatus[] { + const out = [statusFor(c)]; + const repo = c["repo-check"]; + if (repo) out.push({ state: stateOf(repo.verdict), context: REPO_CHECK_CONTEXT, description: described(repo.verdict, repo.summary) }); + return out; +} + +/** The comment a verdict leaves on its pull request, with the check's own account: when the gate is not a + * pass, or the repository's own check failed or could not run. A repository with no merge-check.sh is + * said by its status alone, not by a comment on every push. */ 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 gate = c.gate ?? { verdict: c.verdict, summary: c.summary }; + const repo = c["repo-check"]; + const repoWrong = !!repo && repo.verdict !== "pass" && repo.verdict !== "warning"; + if (gate.verdict === "pass" && !repoWrong) return null; + const lines = [`**Merge check** at \`${c.commit.slice(0, 8)}\``, ""]; + lines.push(`- \`${CHECK_CONTEXT}\`: **${(gate.verdict || "error").toUpperCase()}** — ${gate.summary}` + + (gate.modules?.length ? ` (modules: ${gate.modules.join(", ")})` : "")); + if (repo) lines.push(`- \`${REPO_CHECK_CONTEXT}\`: **${(repo.verdict || "error").toUpperCase()}** — ${repo.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; + return lines.join("\n") + ran + report; } diff --git a/modules/gitea/test/protection.test.ts b/modules/gitea/test/protection.test.ts new file mode 100644 index 0000000..9dc122d --- /dev/null +++ b/modules/gitea/test/protection.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +// A branch's protection (novox/hq ADR 0237): what the operator's agent sets so a pull request waits for the +// mesh's merge check before it merges. + +test("the statuses asked for replace the rule's, and an empty list requires none", async () => { + const { protectionBody } = await import("../protection.ts"); + assert.deepEqual(protectionBody({ statusChecks: ["mesh/merge-gate", " mesh/repo-check", "", "mesh/merge-gate"] }, false), + { enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] }, + "an edited rule keeps its pushes as they are, and each status once"); + assert.deepEqual(protectionBody({ statusChecks: [] }, false), { enable_status_check: false, status_check_contexts: [] }); +}); + +test("a new rule refuses direct pushes unless asked; an administrator's override only when said", async () => { + const { protectionBody } = await import("../protection.ts"); + assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"] }, true).enable_push, false); + assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"], push: true }, true).enable_push, true); + assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"] }, true).block_admin_merge_override, undefined); + assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"], blockAdminOverride: true }, false).block_admin_merge_override, true); +}); diff --git a/modules/gitea/test/pulls.test.ts b/modules/gitea/test/pulls.test.ts index b56181c..0e54c19 100644 --- a/modules/gitea/test/pulls.test.ts +++ b/modules/gitea/test/pulls.test.ts @@ -30,12 +30,38 @@ test("a verdict is the head commit's status; an error is the forge's error, neve 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, /mesh\/merge-gate`: \*\*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("each layer is its own status: the gate with the modules it judged, the repository's own check beside it", async () => { + const { statusesFor, commentFor, CHECK_CONTEXT, REPO_CHECK_CONTEXT } = await import("../pulls.ts"); + const base = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "build-1" }; + const both = statusesFor({ ...base, verdict: "pass", summary: "every machine composes", + gate: { verdict: "pass", summary: "every machine composes", modules: ["gitea", "keycloak"] }, + "repo-check": { verdict: "fail", summary: "its merge-check.sh failed: FAIL x" } }); + assert.deepEqual(both.map((s) => [s.context, s.state]), [[CHECK_CONTEXT, "success"], [REPO_CHECK_CONTEXT, "failure"]]); + assert.match(both[0].description, /gitea, keycloak/); + assert.match(commentFor({ ...base, verdict: "pass", summary: "", gate: { verdict: "pass", summary: "" }, + "repo-check": { verdict: "fail", summary: "its merge-check.sh failed" } }) ?? "", /mesh\/repo-check`: \*\*FAIL/); + + // Nothing of the graph touched, no script: a pass and a warning, and no comment on every push. + const quiet = { ...base, verdict: "pass", summary: "the change touches no module of the mesh's graph", + gate: { verdict: "pass", summary: "the change touches no module of the mesh's graph" }, + "repo-check": { verdict: "warning", summary: "the repository declares no merge-check.sh" } }; + assert.deepEqual(statusesFor(quiet).map((s) => s.state), ["success", "warning"]); + assert.equal(commentFor(quiet), null); + + // A repository outside the mesh, touching nothing: the gate alone, a pass. + assert.equal(statusesFor({ ...base, verdict: "pass", summary: "x", gate: { verdict: "pass", summary: "x" } }).length, 1); + + // A controller from before the layers: its verdict is the gate's. + assert.deepEqual(statusesFor({ ...base, verdict: "warning", summary: "wide" }).map((s) => [s.context, s.state]), + [[CHECK_CONTEXT, "warning"]]); +}); + 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; diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 5c4cabe..ea5399b 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -10,7 +10,18 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { emit } from "@novox/mesh-sdk/events"; -import { GiteaClient } from "../client.js"; +import { GiteaClient, type BranchProtection } from "../client.js"; + +/** A protection rule as a person reads it: what it guards, not every field the forge keeps. */ +export function summarised(p: BranchProtection) { + return { + rule: p.rule_name ?? p.branch_name, + push: p.enable_push ?? false, + required_statuses: p.enable_status_check ? (p.status_check_contexts ?? []) : [], + required_approvals: p.required_approvals ?? 0, + admin_may_override: !(p.block_admin_merge_override ?? false), + }; +} /** Coerce a comma-separated label string into names; empty/absent yields none. */ function parseLabels(raw: unknown): string[] { @@ -361,6 +372,46 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] { }, }, + // ---- Branch protection (novox/hq ADR 0237) ---- + { + name: "gitea_branch_protection_get", + description: "A repository's branch protection: the rule for one branch (null when it has none) or every rule — whether direct pushes are refused, which commit statuses a pull request must have succeeded to merge (e.g. mesh/merge-gate), approvals, and whether an administrator may merge past them.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + branch: { type: "string", description: "the branch (rule name); every rule when not given" }, + }, + run: async (args) => { + const owner = String(args.owner), repo = String(args.repo); + if (!args.branch) return { rules: (await gitea.branchProtections(owner, repo)).map(summarised) }; + const rule = await gitea.branchProtection(owner, repo, String(args.branch)); + return { branch: String(args.branch), rule: rule ? summarised(rule) : null }; + }, + }, + { + name: "gitea_branch_protection_set", + description: "Make a branch require commit statuses before a pull request merges into it — the mesh's merge check sets `mesh/merge-gate` (the module graph's gate) and `mesh/repo-check` (the repository's own tests). Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + branch: { type: "string", description: "the branch to protect, e.g. main" }, + status_checks: { type: "string", description: "comma-separated statuses required to merge, e.g. mesh/merge-gate,mesh/repo-check; empty requires none" }, + push: { type: "boolean", description: "whether a person may push to the branch directly (a new rule refuses it when not given)" }, + block_admin_override: { type: "boolean", description: "stop an administrator merging past a status that has not succeeded (left as it is when not given)" }, + }, + run: async (args) => { + const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim(); + if (!branch) throw new Error("name the branch to protect"); + const before = await gitea.branchProtection(owner, repo, branch); + const { created, rule } = await gitea.setBranchProtection(owner, repo, branch, { + statusChecks: String(args.status_checks ?? "").split(","), + push: args.push === undefined ? undefined : Boolean(args.push), + blockAdminOverride: args.block_admin_override === undefined ? undefined : Boolean(args.block_admin_override), + }); + return { branch, created, before: before ? summarised(before) : null, after: summarised(rule) }; + }, + }, + // ---- Labels ---- { name: "gitea_list_labels", diff --git a/modules/gitea/tsconfig.json b/modules/gitea/tsconfig.json index 49f42ec..e416b00 100644 --- a/modules/gitea/tsconfig.json +++ b/modules/gitea/tsconfig.json @@ -8,5 +8,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["client.ts", "token.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"] + "include": ["client.ts", "token.ts", "pulls.ts", "protection.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"] } From dba71a97a876d2100cacbc43b781a52eae12029d Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 6 Oct 2026 22:34:53 +0200 Subject: [PATCH 2/4] gitea: tell the controller which files a pull request deletes; say the dependents a merge would build (hq ADR 0238) The controller asks its planner what a pull request reaches, as if merged: a module whose manifest the change deletes is one the merge removes, and the plan's dependents are said on the gate's status beside the modules it moves. --- modules/gitea/index.ts | 1 + modules/gitea/pulls.ts | 15 +++++++++++---- modules/gitea/test/pulls.test.ts | 4 ++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index b0cfac0c..f5aeb79 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -222,6 +222,7 @@ async function pollPulls(client: GiteaClient): Promise { html_url: pull.html_url, paths: files.paths, paths_truncated: files.truncated, + removed: files.removed, ...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}), ...(mergeCheck !== null ? { merge_check: mergeCheck, merge_check_said: true } : {}), }); diff --git a/modules/gitea/pulls.ts b/modules/gitea/pulls.ts index 8eac731..9990d84 100644 --- a/modules/gitea/pulls.ts +++ b/modules/gitea/pulls.ts @@ -21,7 +21,10 @@ export const REPO_CHECK_CONTEXT = "mesh/repo-check"; export interface Layer { verdict: string; summary: string; + /** The modules a merge of the change would move, as the controller's planner reckons it… */ modules?: string[]; + /** …and those it would build after them because they stand on them. */ + dependents?: string[]; } /** What the controller says as `checked` (mesh-controller internal/link, Checked). */ @@ -55,10 +58,10 @@ function stateOf(verdict: string): CommitStatus["state"] { return verdict === "pass" ? "success" : verdict === "warning" ? "warning" : verdict === "fail" ? "failure" : "error"; } -function described(verdict: string, summary: string, modules?: string[]): string { +function described(verdict: string, summary: string, modules?: string[], dependents?: string[]): string { // The forge keeps a short description; the rest is the comment's. let d = `${verdict || "error"}: ${summary}`; - if (modules?.length) d += ` [${modules.join(", ")}]`; + if (modules?.length) d += ` [${modules.join(", ")}${dependents?.length ? ` +${dependents.length} dependent(s)` : ""}]`; d = d.replace(/\s+/g, " ").trim(); return d.length > 140 ? d.slice(0, 139) + "…" : d; } @@ -66,7 +69,10 @@ function described(verdict: string, summary: string, modules?: string[]): string /** The forge's status for the gate. */ export function statusFor(c: Checked): CommitStatus { const gate = c.gate ?? { verdict: c.verdict, summary: c.summary }; - return { state: stateOf(gate.verdict), context: CHECK_CONTEXT, description: described(gate.verdict, gate.summary, gate.modules) }; + return { + state: stateOf(gate.verdict), context: CHECK_CONTEXT, + description: described(gate.verdict, gate.summary, gate.modules, gate.dependents), + }; } /** Every status a verdict sets: the gate's, and the repository's own check's when it was said. */ @@ -87,7 +93,8 @@ export function commentFor(c: Checked): string | null { if (gate.verdict === "pass" && !repoWrong) return null; const lines = [`**Merge check** at \`${c.commit.slice(0, 8)}\``, ""]; lines.push(`- \`${CHECK_CONTEXT}\`: **${(gate.verdict || "error").toUpperCase()}** — ${gate.summary}` + - (gate.modules?.length ? ` (modules: ${gate.modules.join(", ")})` : "")); + (gate.modules?.length ? ` (modules: ${gate.modules.join(", ")}` + + (gate.dependents?.length ? `; built after them: ${gate.dependents.join(", ")}` : "") + ")" : "")); if (repo) lines.push(`- \`${REPO_CHECK_CONTEXT}\`: **${(repo.verdict || "error").toUpperCase()}** — ${repo.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\`\`\`` : ""; diff --git a/modules/gitea/test/pulls.test.ts b/modules/gitea/test/pulls.test.ts index 0e54c19..29342a4 100644 --- a/modules/gitea/test/pulls.test.ts +++ b/modules/gitea/test/pulls.test.ts @@ -40,10 +40,10 @@ test("each layer is its own status: the gate with the modules it judged, the rep const { statusesFor, commentFor, CHECK_CONTEXT, REPO_CHECK_CONTEXT } = await import("../pulls.ts"); const base = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "build-1" }; const both = statusesFor({ ...base, verdict: "pass", summary: "every machine composes", - gate: { verdict: "pass", summary: "every machine composes", modules: ["gitea", "keycloak"] }, + gate: { verdict: "pass", summary: "every machine composes", modules: ["gitea", "keycloak"], dependents: ["node-tools"] }, "repo-check": { verdict: "fail", summary: "its merge-check.sh failed: FAIL x" } }); assert.deepEqual(both.map((s) => [s.context, s.state]), [[CHECK_CONTEXT, "success"], [REPO_CHECK_CONTEXT, "failure"]]); - assert.match(both[0].description, /gitea, keycloak/); + assert.match(both[0].description, /gitea, keycloak \+1 dependent/); assert.match(commentFor({ ...base, verdict: "pass", summary: "", gate: { verdict: "pass", summary: "" }, "repo-check": { verdict: "fail", summary: "its merge-check.sh failed" } }) ?? "", /mesh\/repo-check`: \*\*FAIL/); From cc1305a3541d6d52b1a3ab7eb201d44ca056b122 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 6 Oct 2026 22:50:32 +0200 Subject: [PATCH 3/4] gitea: post a pull request's change plan with its verdict (hq ADR 0238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate's status says what the change does and how it was judged; a change that builds something gets its plan as a comment — the tiers, what each machine receives, and what is not an ordinary send. --- modules/gitea/pulls.ts | 57 +++++++++++++++++++++++++++----- modules/gitea/test/pulls.test.ts | 18 ++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/modules/gitea/pulls.ts b/modules/gitea/pulls.ts index 9990d84..0e565fd 100644 --- a/modules/gitea/pulls.ts +++ b/modules/gitea/pulls.ts @@ -42,6 +42,43 @@ export interface Checked { * controller from before the layers says neither, and its verdict is the gate's. */ gate?: Layer; "repo-check"?: Layer; + /** The change plan of the commit checked (novox/hq ADR 0238): what a merge of it would build and send. */ + plan?: ChangePlan; +} + +/** A change plan, as the controller says it (mesh-controller internal/link, ChangePlan). */ +export interface ChangePlan { + repository: string; + base: string; + head: string; + moved?: string[]; + dependents?: string[]; + new?: string[]; + unread?: string[]; + tiers?: string[][]; + machines?: { machine: string; receives?: string[]; waits?: string[] }[]; + steps?: string[]; + summary: string; +} + +/** Whether a plan builds anything: a change that touches the mesh's graph. */ +function builds(plan?: ChangePlan): boolean { + return !!plan && ((plan.moved?.length ?? 0) > 0 || (plan.new?.length ?? 0) > 0); +} + +/** A change plan as a person reads it on the pull request. */ +export function planText(plan: ChangePlan): string { + const lines = [`**Change plan** — ${plan.summary}`]; + (plan.tiers ?? []).forEach((tier, i) => lines.push(`- tier ${i}: ${tier.join(", ")}`)); + for (const m of plan.machines ?? []) { + const parts: string[] = []; + if (m.receives?.length) parts.push(`receives ${m.receives.join(", ")}`); + if (m.waits?.length) parts.push(`waits for a person: ${m.waits.join(", ")}`); + lines.push(`- ${m.machine}: ${parts.join("; ")}`); + } + for (const step of plan.steps ?? []) lines.push(`- ${step}`); + if (plan.unread?.length) lines.push(`- read by no module's build: ${plan.unread.join(", ")}`); + return lines.join("\n"); } /** The heads already announced, keyed `owner/repo#number`, so a restart announces nothing twice. */ @@ -69,10 +106,12 @@ function described(verdict: string, summary: string, modules?: string[], depende /** The forge's status for the gate. */ export function statusFor(c: Checked): CommitStatus { const gate = c.gate ?? { verdict: c.verdict, summary: c.summary }; - return { - state: stateOf(gate.verdict), context: CHECK_CONTEXT, - description: described(gate.verdict, gate.summary, gate.modules, gate.dependents), - }; + // With a plan, the status says what the change does and how it was judged: "pass: builds gitea → anchor; + // no bus step; every machine composes…". + const description = builds(c.plan) + ? described(gate.verdict, `${c.plan!.summary}; ${gate.summary}`) + : described(gate.verdict, gate.summary, gate.modules, gate.dependents); + return { state: stateOf(gate.verdict), context: CHECK_CONTEXT, description }; } /** Every status a verdict sets: the gate's, and the repository's own check's when it was said. */ @@ -83,19 +122,21 @@ export function statusesFor(c: Checked): CommitStatus[] { return out; } -/** The comment a verdict leaves on its pull request, with the check's own account: when the gate is not a - * pass, or the repository's own check failed or could not run. A repository with no merge-check.sh is - * said by its status alone, not by a comment on every push. */ +/** The comment a verdict leaves on its pull request, with the check's own account: the change plan of a + * change that builds something (novox/hq ADR 0238), and why, when the gate is not a pass or the + * repository's own check failed or could not run. A repository with no merge-check.sh, touching nothing, + * is said by its statuses alone, not by a comment on every push. */ export function commentFor(c: Checked): string | null { const gate = c.gate ?? { verdict: c.verdict, summary: c.summary }; const repo = c["repo-check"]; const repoWrong = !!repo && repo.verdict !== "pass" && repo.verdict !== "warning"; - if (gate.verdict === "pass" && !repoWrong) return null; + if (gate.verdict === "pass" && !repoWrong && !builds(c.plan)) return null; const lines = [`**Merge check** at \`${c.commit.slice(0, 8)}\``, ""]; lines.push(`- \`${CHECK_CONTEXT}\`: **${(gate.verdict || "error").toUpperCase()}** — ${gate.summary}` + (gate.modules?.length ? ` (modules: ${gate.modules.join(", ")}` + (gate.dependents?.length ? `; built after them: ${gate.dependents.join(", ")}` : "") + ")" : "")); if (repo) lines.push(`- \`${REPO_CHECK_CONTEXT}\`: **${(repo.verdict || "error").toUpperCase()}** — ${repo.summary}`); + if (builds(c.plan)) lines.push("", planText(c.plan!)); 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 lines.join("\n") + ran + report; diff --git a/modules/gitea/test/pulls.test.ts b/modules/gitea/test/pulls.test.ts index 29342a4..45cd521 100644 --- a/modules/gitea/test/pulls.test.ts +++ b/modules/gitea/test/pulls.test.ts @@ -83,3 +83,21 @@ test("a commit status is set on the commit, under the merge check's context", as assert.equal(seen!.body.state, "failure"); assert.equal(seen!.body.context, "mesh/merge-gate"); }); + +test("a change plan is the gate's result: said on the status and, when it builds something, as a comment", async () => { + const { statusFor, commentFor } = await import("../pulls.ts"); + const plan = { + repository: "novox/mesh-catalog", base: "main", head: "0123456789abcdef", moved: ["gitea"], + tiers: [["gitea"]], machines: [{ machine: "anchor", receives: ["gitea"] }], steps: [], + summary: "builds gitea → anchor; no bus step", + }; + const c = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "b", verdict: "pass", + summary: "every machine composes", gate: { verdict: "pass", summary: "every machine composes", modules: ["gitea"] }, plan }; + assert.equal(statusFor(c).description, "pass: builds gitea → anchor; no bus step; every machine composes"); + const said = commentFor(c) ?? ""; + assert.match(said, /Change plan\*\* — builds gitea → anchor/); + assert.match(said, /- anchor: receives gitea/); + // A plan that builds nothing, passing: the statuses say it, no comment. + const nothing = { ...c, plan: { ...plan, moved: [], tiers: [], machines: [], summary: "builds nothing" } }; + assert.equal(commentFor(nothing), null); +}); From 81641a4b22389fe780f9a52833f9b9a8e212690d Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 6 Oct 2026 22:54:52 +0200 Subject: [PATCH 4/4] Test the modules the planner says the change reaches (hq ADR 0238) --- merge-check.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/merge-check.sh b/merge-check.sh index d175136..0e59e8d 100755 --- a/merge-check.sh +++ b/merge-check.sh @@ -25,7 +25,14 @@ fi race="" if command -v gcc >/dev/null 2>&1; then race="-race"; else echo "NOT RACE-CHECKED: the toolchain holds no C compiler"; fi -touched=$(printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p' | sort -u) +# The modules the change reaches are the controller's planner's answer (MESH_CHECK_MODULES, hq ADR 0238): +# a module by its name, a new one by its directory. By hand, without it, the directories the changed files +# are in. +if [ -n "${MESH_CHECK_MODULES:-}" ]; then + touched=$(printf '%s\n' "$MESH_CHECK_MODULES" | tr ',' '\n' | sed -e 's#^modules/##' -e '/\//d' | sort -u) +else + touched=$(printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p' | sort -u) +fi for m in $touched; do [ -d "modules/$m" ] || continue if [ ! -f "modules/$m/go.mod" ]; then