From 448310908c14de43ea5925ab09ce3ce078549444 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 16:25:16 +0100 Subject: [PATCH] gitea: set any field of a branch protection rule through a tool, so a trunk's merge whitelist and override need no hand at the forge gitea_branch_protection_set_full takes every field of gitea's branch protection API by its own name, changes only those given, writes as the forge's administrator, logs the required why, answers the rule before and after, and says whether the controller's trunk rule (judgedRule in mesh-control build_source.go) now holds. The operator, 2026-10-28: a generic tool for any configuration; design 47 L2 needs de-spiegel's main set as a trunk. --- modules/gitea/client.ts | 18 +++ modules/gitea/protectfull.ts | 139 +++++++++++++++++++++ modules/gitea/test/protection-full.test.ts | 112 +++++++++++++++++ modules/gitea/test/token.test.ts | 2 +- modules/gitea/tools/index.ts | 46 +++++++ 5 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 modules/gitea/protectfull.ts create mode 100644 modules/gitea/test/protection-full.test.ts diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 24667981..f5b47f8a 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -758,6 +758,15 @@ export class GiteaClient { return { created: true, rule }; } + /** Write a branch's rule with exactly the fields given (gitea_branch_protection_set_full): an edit of the rule named + * for the branch, or a new one. As this client's account, which the forge refuses where it is no administrator. */ + async writeBranchProtection(owner: string, repo: string, branch: string, body: Record, creating: boolean): Promise { + return creating + ? this.request(`${repoPath(owner, repo)}/branch_protections`, { method: "POST", body: JSON.stringify({ rule_name: branch, ...body }) }) + : this.request(`${repoPath(owner, repo)}/branch_protections/${pathSegments("branch", branch)}`, + { method: "PATCH", body: JSON.stringify(body) }); + } + async createPullRequest( owner: string, repo: string, @@ -1052,6 +1061,15 @@ export class GiteaAdmin { return Array.isArray(res.body) ? res.body : []; } + /** Write a branch's rule with exactly the fields given, as the forge's administrator (gitea_branch_protection_set_full; + * the operator, 2026-10-28): an edit of the rule named for the branch, or a new one. */ + async writeBranchProtection(owner: string, repo: string, branch: string, body: Record, creating: boolean): Promise { + const path = creating ? `${repoPath(owner, repo)}/branch_protections` : `${repoPath(owner, repo)}/branch_protections/${pathSegments("branch", branch)}`; + const res = await this.request(path, { method: creating ? "POST" : "PATCH", body: JSON.stringify(creating ? { rule_name: branch, ...body } : body) }); + if (res.status !== 200 && res.status !== 201) GiteaAdmin.fail(path, res); + return res.body as BranchProtection; + } + /** Put main's rule right in every repository of an organisation (novox/hq ADR 0398 §2, issue 948): no push, only * the tools' account merges, and no status required (ADR 0534 §1). The rule there is edited; a repository with no * rule for main gets one. Answers the repositories it touched. With the administrator's login: diff --git a/modules/gitea/protectfull.ts b/modules/gitea/protectfull.ts new file mode 100644 index 00000000..63f777a3 --- /dev/null +++ b/modules/gitea/protectfull.ts @@ -0,0 +1,139 @@ +// Every field of a branch protection rule, set through one tool (gitea_branch_protection_set_full; the operator, +// 2026-10-28: "just make a generic tool so you can setup any configuration"). Pure, so it is tested without a forge: +// which fields the forge's rule takes and of what kind, the body a call sends, and whether the rule the forge then +// applies to the branch is one the controller registers a module from (judgedRule, mesh-control +// cmd/mesh-controller/build_source.go). + +import { TOOLS_ACCOUNT } from "./tools-account.js"; +import type { BranchProtection } from "./protection.js"; + +type Kind = "boolean" | "logins" | "strings" | "number" | "string"; + +/** Every field gitea's branch protection API takes (CreateBranchProtectionOption / EditBranchProtectionOption), by its + * own name, and its kind. A call names them as the forge does; rule_name is the branch argument, never a field. */ +export const PROTECTION_FIELDS: Record = { + enable_push: { kind: "boolean", says: "whether anyone who writes may push to the branch directly (false: no direct push)" }, + enable_push_whitelist: { kind: "boolean", says: "with enable_push, only the push whitelist may push" }, + push_whitelist_usernames: { kind: "logins", says: "the logins that may push directly" }, + push_whitelist_teams: { kind: "strings", says: "the teams that may push directly" }, + push_whitelist_deploy_keys: { kind: "boolean", says: "whether deploy keys may push" }, + enable_force_push: { kind: "boolean", says: "whether a force push is taken" }, + enable_force_push_allowlist: { kind: "boolean", says: "with enable_force_push, only the force-push allowlist may" }, + force_push_allowlist_usernames: { kind: "logins", says: "the logins that may force-push" }, + force_push_allowlist_teams: { kind: "strings", says: "the teams that may force-push" }, + force_push_allowlist_deploy_keys: { kind: "boolean", says: "whether deploy keys may force-push" }, + enable_merge_whitelist: { kind: "boolean", says: "whether only the merge whitelist may merge" }, + merge_whitelist_usernames: { kind: "logins", says: "the logins that may merge (the controller's trunk rule wants mesh-tools, no agent-)" }, + merge_whitelist_teams: { kind: "strings", says: "the teams that may merge" }, + enable_status_check: { kind: "boolean", says: "whether the statuses in status_check_contexts must pass to merge" }, + status_check_contexts: { kind: "strings", says: "the required commit statuses, e.g. mesh/check" }, + required_approvals: { kind: "number", says: "how many approving reviews a merge needs" }, + enable_approvals_whitelist: { kind: "boolean", says: "whether only the approvals whitelist's reviews count" }, + approvals_whitelist_username: { kind: "logins", says: "the logins whose approvals count" }, + approvals_whitelist_teams: { kind: "strings", says: "the teams whose approvals count" }, + block_on_rejected_reviews: { kind: "boolean", says: "a review asking changes blocks the merge" }, + block_on_official_review_requests: { kind: "boolean", says: "an open official review request blocks the merge" }, + block_on_outdated_branch: { kind: "boolean", says: "a head behind the branch blocks the merge" }, + dismiss_stale_approvals: { kind: "boolean", says: "a new commit dismisses earlier approvals" }, + ignore_stale_approvals: { kind: "boolean", says: "approvals of an earlier commit do not count" }, + require_signed_commits: { kind: "boolean", says: "every commit must be signed" }, + protected_file_patterns: { kind: "string", says: "files no push may change, ';'-separated globs" }, + unprotected_file_patterns: { kind: "string", says: "files a push may change even without push rights, ';'-separated globs" }, + block_admin_merge_override: { kind: "boolean", says: "true: an administrator cannot merge past the rule (the controller's trunk rule wants true)" }, + priority: { kind: "number", says: "the rule's order among glob rules" }, +}; + +/** A forge login. */ +const LOGIN = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,39}$/; + +/** The forge body for the fields given: each checked against its kind, a field not given left out (the forge keeps it on + * an edit), an unknown field refused before anything is sent. Throws on the first wrong field. */ +export function fullProtectionBody(args: Record, given: string[]): Record { + const body: Record = {}; + for (const name of given) { + const field = PROTECTION_FIELDS[name]; + if (!field) throw new Error(`${name} is no field of a branch protection rule; the fields are ${Object.keys(PROTECTION_FIELDS).join(", ")}`); + const v = args[name]; + switch (field.kind) { + case "boolean": + if (typeof v !== "boolean") throw new Error(`${name} is true or false`); + body[name] = v; + break; + case "number": + if (typeof v !== "number" || !Number.isInteger(v) || v < 0) throw new Error(`${name} is a whole number, 0 or more`); + body[name] = v; + break; + case "string": + if (typeof v !== "string") throw new Error(`${name} is a string`); + body[name] = v; + break; + case "strings": + case "logins": { + if (!Array.isArray(v) || v.some((s) => typeof s !== "string" || s.trim() === "")) throw new Error(`${name} is a list of names`); + const names = [...new Set((v as string[]).map((s) => s.trim()))]; + if (field.kind === "logins") { + const bad = names.find((s) => !LOGIN.test(s) || s.includes("..")); + if (bad !== undefined) throw new Error(`${name}: ${JSON.stringify(bad)} is no forge login`); + } + body[name] = names; + break; + } + } + } + return body; +} + +/** The fields of a call that are protection fields: every argument but owner, repo, branch and why. */ +export function givenFields(args: Record): string[] { + return Object.keys(args).filter((k) => !["owner", "repo", "branch", "why"].includes(k) && args[k] !== undefined); +} + +/** A rule as the controller reads it from gitea_branch_protection_get (protectionRule in build_source.go). */ +interface ControllerRule { rule: string; push: boolean; admin_may_override: boolean; merge_whitelist: string[] | null } + +function asControllerRule(p: BranchProtection): ControllerRule { + return { + rule: String(p.rule_name ?? p.branch_name ?? ""), + push: p.enable_push ?? false, + admin_may_override: !(p.block_admin_merge_override ?? false), + merge_whitelist: p.enable_merge_whitelist ? (p.merge_whitelist_usernames ?? []) : null, + }; +} + +/** ruleCovers of build_source.go, as the controller reads a rule's glob: `**` any, `*` within a segment, `?` one. */ +function controllerCovers(rule: string, branch: string): boolean { + if (rule === branch) return true; + if (!/[*?[]/.test(rule)) return false; + let re = "^"; + for (let i = 0; i < rule.length; i++) { + const c = rule[i]; + if (c === "*" && rule[i + 1] === "*") { re += ".*"; i++; } + else if (c === "*") re += "[^/]*"; + else if (c === "?") re += "[^/]"; + else re += c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + return new RegExp(re + "$").test(branch); +} + +/** Whether the controller registers a module from this branch, judged as judgedRule in mesh-control + * cmd/mesh-controller/build_source.go does (ADR 0534 §6): the rule named for the branch, else the first glob rule that + * covers it; it takes no push, its merge whitelist names mesh-tools and no agent-, and no administrator merges + * past it. A mirror, not the judge: the controller's own verdict is the one that counts. */ +export function trunkRule(rules: BranchProtection[], branch: string): { holds: boolean; why: string } { + const all = rules.map(asControllerRule); + const r = all.find((x) => x.rule === branch) ?? all.find((x) => controllerCovers(x.rule, branch)); + const no = (why: string) => ({ holds: false, why }); + if (!r) return no(`no protection rule covers ${branch}`); + if (r.push) return no(`the rule ${r.rule} lets a person push to ${branch} directly`); + if (!r.merge_whitelist || r.merge_whitelist.length === 0) { + return no(`the rule ${r.rule} keeps no merge whitelist naming ${TOOLS_ACCOUNT} alone for ${branch}`); + } + if (!r.merge_whitelist.includes(TOOLS_ACCOUNT)) { + return no(`the rule ${r.rule} lets ${r.merge_whitelist.join(", ")} merge into ${branch}, and not ${TOOLS_ACCOUNT}`); + } + if (r.merge_whitelist.some((l) => l.startsWith("agent-"))) { + return no(`the rule ${r.rule} lets an agent's account merge into ${branch}: ${r.merge_whitelist.join(", ")}`); + } + if (r.admin_may_override) return no(`the rule ${r.rule} lets an administrator merge into ${branch} past it`); + return { holds: true, why: `the rule ${r.rule} takes no push, only ${r.merge_whitelist.join(", ")} merge, and no administrator merges past it` }; +} diff --git a/modules/gitea/test/protection-full.test.ts b/modules/gitea/test/protection-full.test.ts new file mode 100644 index 00000000..871f190c --- /dev/null +++ b/modules/gitea/test/protection-full.test.ts @@ -0,0 +1,112 @@ +// gitea_branch_protection_set_full (the operator, 2026-10-28: "just make a generic tool so you can setup any +// configuration"): any field of a branch protection rule, only the fields given, as the forge's administrator; it +// answers the rule before and after, and whether the controller's trunk rule (judgedRule, mesh-control +// build_source.go) now holds. Against a fake forge that keeps the rules and answers them to the administrator alone. + +import { test, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; + +import { GiteaAdmin, GiteaClient } from "../dist/client.js"; +import { ConfiguredToken } from "../dist/token.js"; +import { getGiteaTools } from "../dist/tools/index.js"; +import { trunkRule } from "../dist/protectfull.js"; + +const adminBasic = "Basic " + Buffer.from("mesh-admin:secret").toString("base64"); +let rules: any[] = []; +let sent: { method: string; path: string; body: any }[] = []; +beforeEach(() => { + // de-spiegel's main as found: no push, but the operator alone merges and an administrator merges past it. + rules = [{ rule_name: "main", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["jschoubben"], + enable_status_check: false, status_check_contexts: [], block_admin_merge_override: false, required_approvals: 0 }]; + sent = []; +}); + +const server = createServer((req, res) => { + const path = (req.url ?? "").replace(/^\/api\/v1/, ""); + const send = (status: number, body: unknown) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); }; + if (req.headers.authorization !== adminBasic) return send(403, { message: "not an administrator of the repository" }); + let raw = ""; + req.on("data", (c) => (raw += c)); + req.on("end", () => { + const body = raw ? JSON.parse(raw) : undefined; + if (req.method !== "GET") sent.push({ method: req.method!, path, body }); + const m = path.match(/^\/repos\/novox\/de-spiegel\/branch_protections(?:\/(.+))?$/); + if (!m) return send(404, { message: "not found" }); + if (req.method === "GET" && !m[1]) return send(200, rules); + if (req.method === "PATCH" && m[1]) { + const r = rules.find((x) => x.rule_name === decodeURIComponent(m[1])); + if (!r) return send(404, { message: "no rule" }); + Object.assign(r, body); + return send(200, r); + } + if (req.method === "POST" && !m[1]) { rules.push({ ...body }); return send(201, body); } + send(405, { message: "no" }); + }); +}); +await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); +after(() => new Promise((r) => server.close(() => r()))); +const { port } = server.address() as { port: number }; +const url = `http://127.0.0.1:${port}`; +const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret")); +const setFull = (args: Record) => + tools.find((t) => t.name === "gitea_branch_protection_set_full")!.run({ owner: "novox", repo: "de-spiegel", branch: "main", why: "L2", ...args }); + +test("it sets the merge whitelist and the administrator's override, only the fields given, and says the trunk rule now holds", async () => { + const out: any = await setFull({ merge_whitelist_usernames: ["mesh-tools", "jschoubben"], block_admin_merge_override: true }); + assert.deepEqual(sent, [{ method: "PATCH", path: "/repos/novox/de-spiegel/branch_protections/main", + body: { merge_whitelist_usernames: ["mesh-tools", "jschoubben"], block_admin_merge_override: true } }]); + assert.deepEqual(out.before.merge_whitelist, ["jschoubben"]); + assert.equal(out.before.admin_may_override, true); + assert.deepEqual(out.after.merge_whitelist, ["mesh-tools", "jschoubben"]); + assert.equal(out.after.admin_may_override, false); + assert.equal(out.why, "L2"); + assert.equal(out.trunk_rule.holds, true, out.trunk_rule.why); +}); + +test("it loosens as told too, and says the trunk rule no longer holds and why", async () => { + const out: any = await setFull({ enable_push: true, status_check_contexts: ["mesh/check"], enable_status_check: true, required_approvals: 1 }); + assert.equal(out.after.push, true); + assert.deepEqual(out.after.required_statuses, ["mesh/check"]); + assert.equal(out.after.required_approvals, 1); + assert.equal(out.trunk_rule.holds, false); + assert.match(out.trunk_rule.why, /push to main directly/); +}); + +test("a branch with no rule gets one with the fields given", async () => { + const out: any = await setFull({ branch: "release/*", enable_push: false, protected_file_patterns: "*.lock" }); + assert.equal(out.created, true); + assert.deepEqual(sent[0], { method: "POST", path: "/repos/novox/de-spiegel/branch_protections", + body: { rule_name: "release/*", enable_push: false, protected_file_patterns: "*.lock" } }); +}); + +test("an unknown field, a field of the wrong kind, no field, no reason or a login that is none: refused, nothing sent", async () => { + await assert.rejects(setFull({ enable_pushes: true }), /no field of a branch protection rule/); + await assert.rejects(setFull({ enable_push: "yes" }), /true or false/); + await assert.rejects(setFull({ required_approvals: -1 }), /whole number/); + await assert.rejects(setFull({ merge_whitelist_usernames: ["../x"] }), /no forge login/); + await assert.rejects(setFull({}), /at least one field/); + await assert.rejects(setFull({ why: "", enable_push: false }), /say why/); + assert.deepEqual(sent, []); +}); + +// judgedRule's cases (mesh-control cmd/mesh-controller/build_source.go), each in its own words. +test("the trunk rule is judged as the controller judges it", () => { + const ok = { rule_name: "main", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"], block_admin_merge_override: true }; + const cases: [any[], string, boolean, RegExp][] = [ + [[], "main", false, /no protection rule covers main/], + [[{ ...ok, enable_push: true }], "main", false, /push to main directly/], + [[{ ...ok, enable_merge_whitelist: false }], "main", false, /no merge whitelist/], + [[{ ...ok, merge_whitelist_usernames: ["jschoubben"] }], "main", false, /and not mesh-tools/], + [[{ ...ok, merge_whitelist_usernames: ["mesh-tools", "agent-g14"] }], "main", false, /agent's account/], + [[{ ...ok, block_admin_merge_override: false }], "main", false, /administrator/], + [[ok], "main", true, /takes no push/], + [[{ ...ok, rule_name: "ma*" }], "main", true, /ma\*/], + [[{ ...ok, rule_name: "ma*", enable_push: true }, { ...ok }], "main", true, /rule main/], + ]; + for (const [rs, branch, holds, why] of cases) { + const got = trunkRule(rs, branch); + assert.equal(got.holds, holds, `${JSON.stringify(rs)}: ${got.why}`); + assert.match(got.why, why); + } +}); diff --git a/modules/gitea/test/token.test.ts b/modules/gitea/test/token.test.ts index ad7c9e18..030af1d1 100644 --- a/modules/gitea/test/token.test.ts +++ b/modules/gitea/test/token.test.ts @@ -538,7 +538,7 @@ test("the tools register once there is a way to a token, and the first call mint "gitea_get_file", "gitea_list_branches", "gitea_delete_branch", - "gitea_branch_protection_get", "gitea_team_rights", "gitea_branch_protection_set", + "gitea_branch_protection_get", "gitea_team_rights", "gitea_branch_protection_set", "gitea_branch_protection_set_full", "gitea_default_branch_set", "gitea_pull_base_set", "gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_commit_trees", "gitea_contains", "gitea_list_labels", "gitea_create_label", diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 9a343d63..226a1c1e 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -12,6 +12,7 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools" import { emit } from "@novox/mesh-sdk/events"; import { GiteaAdmin, GiteaClient, type BranchProtection } from "../client.js"; import { readTeamRights } from "../teamrights.js"; +import { PROTECTION_FIELDS, fullProtectionBody, givenFields, trunkRule } from "../protectfull.js"; import { appendNote, run, viewBody, viewComment } from "../delivery.js"; import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js"; import { @@ -488,6 +489,51 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini }, }, + { + name: "gitea_branch_protection_set_full", + description: "Set any field of a branch protection rule — protect a branch as a trunk, set its merge whitelist (users and teams), " + + "push and push whitelist, force push, required status checks, required approvals and approvals whitelist, block on " + + "rejected reviews, block on an outdated branch, dismiss stale approvals, signed commits, protected and unprotected file " + + "patterns, and the administrator's merge override (block_admin_merge_override). Every field the forge's API takes, by " + + "its own name; only the fields given change, a branch with no rule gets one. Generic: it tightens or loosens as told " + + "(the operator, 2026-10-28), so a reason (why) is required and logged. As the forge's administrator. Answers the rule " + + "before and after, and trunk_rule: whether the controller now registers a module from this branch (judgedRule, " + + "mesh-control build_source.go: no push, a merge whitelist naming mesh-tools and no agent-, no administrator " + + "override) and why not.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + branch: { type: "string", description: "the branch (the rule's name, or a glob) whose rule is set" }, + why: { type: "string", description: "why, in a sentence: logged and answered" }, + ...Object.fromEntries(Object.entries(PROTECTION_FIELDS).map(([name, f]) => [name, f.kind === "logins" || f.kind === "strings" + ? { type: "array", items: { type: "string" }, description: f.says } + : { type: f.kind === "number" ? "number" : f.kind, description: f.says }])), + }, + run: async (args) => { + const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim(); + const why = String(args.why ?? "").trim(); + if (!branch) throw new Error("name the branch whose rule is set"); + if (!why) throw new Error("say why (why): a branch's protection is changed for a reason, which is logged"); + pathSegments("branch", branch); + const given = givenFields(args); + if (!given.length) throw new Error(`name at least one field to set: ${Object.keys(PROTECTION_FIELDS).join(", ")}`); + const body = fullProtectionBody(args, given); + const forge = admin ? admin() : gitea; + const rules = await forge.branchProtections(owner, repo); + const before = rules.find((p) => (p.rule_name ?? p.branch_name) === branch) ?? null; + console.log(`[gitea] gitea_branch_protection_set_full ${owner}/${repo} ${branch}: ${JSON.stringify(body)} — why: ${why}`); + const rule = await forge.writeBranchProtection(owner, repo, branch, body, !before); + const after = await forge.branchProtections(owner, repo); + const read = after.find((p) => (p.rule_name ?? p.branch_name) === branch) ?? rule; + return { + branch, created: !before, why, set: body, + before: before ? { ...summarised(before), fields: before } : null, + after: { ...summarised(read), fields: read }, + trunk_rule: trunkRule(after, branch), + }; + }, + }, + // ---- Two settings gitea_api refuses (issue 1422 note 7; guard.ts), each its own named tool ---- { name: "gitea_default_branch_set",