From 448310908c14de43ea5925ab09ce3ce078549444 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 16:25:16 +0100 Subject: [PATCH 1/5] 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", From 6e97ba386709811001cc5748d1a7e4c8d5d5fc77 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 16:39:54 +0100 Subject: [PATCH 2/5] gitea: fail a protection or settings write the forge answered and did not apply, and take every field gitea 1.27.3 edits Gitea reads the push whitelist and force-push allowlist flags only beside their parent set true and passes over a missing default branch, both with a 200, so the tool said set for what did not change. It now refuses a dependent field without its parent, reads the rule back and names each field not applied, and says what the forge changed beside the call. The bypass allowlist fields complete the 32. gitea_repo_settings_set_full sets a repository's settings, its default branch among them, as the forge's administrator: gitea_default_branch_set is refused as mesh-tools. --- modules/gitea/client.ts | 16 +++++ modules/gitea/protectfull.ts | 66 +++++++++++++++---- modules/gitea/test/protection-full.test.ts | 77 +++++++++++++++++++++- modules/gitea/test/token.test.ts | 2 +- modules/gitea/tools/index.ts | 66 +++++++++++++++---- 5 files changed, 199 insertions(+), 28 deletions(-) diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index f5b47f8a..8c022545 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -1070,6 +1070,22 @@ export class GiteaAdmin { return res.body as BranchProtection; } + /** A repository as the forge answers it to its administrator: every setting (gitea_repo_settings_set_full). */ + async repoSettings(owner: string, repo: string): Promise> { + const path = repoPath(owner, repo); + const res = await this.request(path); + if (res.status !== 200) GiteaAdmin.fail(path, res); + return res.body as Record; + } + + /** Edit a repository's settings with exactly the fields given, as the forge's administrator. */ + async editRepoSettings(owner: string, repo: string, body: Record): Promise> { + const path = repoPath(owner, repo); + const res = await this.request(path, { method: "PATCH", body: JSON.stringify(body) }); + if (res.status !== 200) GiteaAdmin.fail(path, res); + return res.body as Record; + } + /** 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 index 63f777a3..ee41a55c 100644 --- a/modules/gitea/protectfull.ts +++ b/modules/gitea/protectfull.ts @@ -8,20 +8,23 @@ import { TOOLS_ACCOUNT } from "./tools-account.js"; import type { BranchProtection } from "./protection.js"; type Kind = "boolean" | "logins" | "strings" | "number" | "string"; +type Field = { kind: Kind; says: string; parent?: 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" }, +/** Every field gitea's branch protection API takes on an edit at 1.27.3 (EditBranchProtectionOption, 32 fields), by its + * own name, and its kind; rule_name is the branch argument, never a field. A field with a parent is read by the forge + * only when the same call sets that parent true, and setting the parent false clears it (gitea's + * routers/api/v1/repo/branch.go): such a field is refused without its parent true in the call. */ +export const PROTECTION_FIELDS: Record = { + enable_push: { kind: "boolean", says: "whether anyone who writes may push to the branch directly (false: no direct push; also clears enable_push_whitelist and push_whitelist_deploy_keys)" }, + enable_push_whitelist: { kind: "boolean", says: "only the push whitelist may push; needs enable_push: true in the same call", parent: "enable_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" }, + push_whitelist_deploy_keys: { kind: "boolean", says: "whether deploy keys may push; needs enable_push: true in the same call", parent: "enable_push" }, + enable_force_push: { kind: "boolean", says: "whether a force push is taken (false also clears the force-push allowlist flags)" }, + enable_force_push_allowlist: { kind: "boolean", says: "only the force-push allowlist may force-push; needs enable_force_push: true in the same call", parent: "enable_force_push" }, 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" }, + force_push_allowlist_deploy_keys: { kind: "boolean", says: "whether deploy keys may force-push; needs enable_force_push: true in the same call", parent: "enable_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" }, @@ -40,19 +43,58 @@ export const PROTECTION_FIELDS: Record = { 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)" }, + enable_bypass_allowlist: { kind: "boolean", says: "whether the bypass allowlist may merge past the rule's checks" }, + bypass_allowlist_usernames: { kind: "logins", says: "the logins that may merge past the rule's checks" }, + bypass_allowlist_teams: { kind: "strings", says: "the teams that may merge past the rule's checks" }, priority: { kind: "number", says: "the rule's order among glob rules" }, }; +/** The repository settings gitea_repo_settings_set_full sets (gitea's EditRepoOption at 1.27.3), by their own names. Left + * out: name (a rename moves every module's source address) and the tracker and wiki objects. The forge ignores a + * default_branch that does not exist; the read-back below says so. */ +export const REPO_FIELDS: Record = Object.fromEntries(([ + ["description", "string"], ["website", "string"], ["private", "boolean"], ["template", "boolean"], + ["default_branch", "string"], ["has_issues", "boolean"], ["has_wiki", "boolean"], ["has_pull_requests", "boolean"], + ["has_projects", "boolean"], ["projects_mode", "string"], ["has_releases", "boolean"], ["has_packages", "boolean"], + ["has_actions", "boolean"], ["ignore_whitespace_conflicts", "boolean"], ["allow_merge_commits", "boolean"], + ["allow_rebase", "boolean"], ["allow_rebase_explicit", "boolean"], ["allow_squash_merge", "boolean"], + ["allow_fast_forward_only_merge", "boolean"], ["allow_manual_merge", "boolean"], ["autodetect_manual_merge", "boolean"], + ["allow_rebase_update", "boolean"], ["default_delete_branch_after_merge", "boolean"], ["default_merge_style", "string"], + ["default_allow_maintainer_edit", "boolean"], ["archived", "boolean"], ["mirror_interval", "string"], ["enable_prune", "boolean"], +] as [string, Kind][]).map(([name, kind]) => [name, { kind, says: `the repository's ${name.replace(/_/g, " ")}` }])); + +/** Fields the forge was sent but does not hold after the write, each with what was sent and what it holds: a write it + * answered 200 and did not apply (gitea drops a field whose parent is not set, or a branch that does not exist). */ +export function unapplied(sent: Record, held: Record): string[] { + const norm = (v: unknown) => Array.isArray(v) ? JSON.stringify([...v].map(String).sort()) : JSON.stringify(v ?? null); + return Object.keys(sent).filter((k) => norm(sent[k]) !== norm(held[k])) + .map((k) => `${k} (sent ${JSON.stringify(sent[k])}, the forge holds ${JSON.stringify(held[k] ?? null)})`); +} + +/** Fields that changed between two reads but were not sent: what the forge changed beside the call (enable_push: false + * clears the push whitelist flags). */ +export function alsoChanged(before: Record | null, after: Record, sent: Record): string[] { + if (!before) return []; + const keys = new Set([...Object.keys(before), ...Object.keys(after)]); + return [...keys].filter((k) => !(k in sent) && !["updated_at", "created_at"].includes(k) && + JSON.stringify(before[k] ?? null) !== JSON.stringify(after[k] ?? null)); +} + /** 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 { +export function fullProtectionBody(args: Record, given: string[], + fields: Record = PROTECTION_FIELDS, what = "a branch protection rule"): 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 field = fields[name]; + if (!field) throw new Error(`${name} is no field of ${what}; the fields are ${Object.keys(fields).join(", ")}`); + // The parent false clears the field, so only a field false goes beside a parent false. + if (field.parent && args[field.parent] !== true && !(args[field.parent] === false && args[name] === false)) { + throw new Error(`${name} is read by the forge only when the same call sets ${field.parent}: true (gitea ignores it otherwise). Nothing was sent`); + } const v = args[name]; switch (field.kind) { case "boolean": diff --git a/modules/gitea/test/protection-full.test.ts b/modules/gitea/test/protection-full.test.ts index 871f190c..99e65356 100644 --- a/modules/gitea/test/protection-full.test.ts +++ b/modules/gitea/test/protection-full.test.ts @@ -14,14 +14,40 @@ import { trunkRule } from "../dist/protectfull.js"; const adminBasic = "Basic " + Buffer.from("mesh-admin:secret").toString("base64"); let rules: any[] = []; +let repoState: any = {}; +const branches = ["master", "main"]; 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 }]; + enable_status_check: false, status_check_contexts: [], block_admin_merge_override: false, required_approvals: 0, + enable_push_whitelist: false, push_whitelist_deploy_keys: false, enable_force_push: false, enable_force_push_allowlist: false, + force_push_allowlist_deploy_keys: false, merge_whitelist_teams: [] }]; + repoState = { full_name: "novox/de-spiegel", default_branch: "master", description: "", has_wiki: true }; sent = []; }); +// What gitea 1.27.3's EditBranchProtection does with a body (routers/api/v1/repo/branch.go): the push whitelist flags are +// read only beside enable_push true and cleared by enable_push false, the force-push allowlist flags likewise with +// enable_force_push; teams are passed over on a repository no organisation owns (de-spiegel here, for the test). +function giteaEdit(r: any, body: any) { + const nested = ["enable_push", "enable_push_whitelist", "push_whitelist_deploy_keys", "enable_force_push", + "enable_force_push_allowlist", "force_push_allowlist_deploy_keys", "merge_whitelist_teams", "push_whitelist_teams"]; + for (const [k, v] of Object.entries(body)) if (!nested.includes(k) && k !== "rule_name") r[k] = v; + if (body.enable_push === false) Object.assign(r, { enable_push: false, enable_push_whitelist: false, push_whitelist_deploy_keys: false }); + if (body.enable_push === true) { + r.enable_push = true; + if (body.enable_push_whitelist !== undefined) r.enable_push_whitelist = body.enable_push_whitelist; + if (body.push_whitelist_deploy_keys !== undefined) r.push_whitelist_deploy_keys = body.push_whitelist_deploy_keys; + } + if (body.enable_force_push === false) Object.assign(r, { enable_force_push: false, enable_force_push_allowlist: false, force_push_allowlist_deploy_keys: false }); + if (body.enable_force_push === true) { + r.enable_force_push = true; + if (body.enable_force_push_allowlist !== undefined) r.enable_force_push_allowlist = body.enable_force_push_allowlist; + if (body.force_push_allowlist_deploy_keys !== undefined) r.force_push_allowlist_deploy_keys = body.force_push_allowlist_deploy_keys; + } +} + 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)); }; @@ -31,16 +57,26 @@ const server = createServer((req, res) => { req.on("end", () => { const body = raw ? JSON.parse(raw) : undefined; if (req.method !== "GET") sent.push({ method: req.method!, path, body }); + if (path === "/repos/novox/de-spiegel") { + if (req.method === "GET") return send(200, repoState); + if (req.method === "PATCH") { + // gitea's updateBasicProperties: a default branch that does not exist is passed over, the call still 200. + const { default_branch, ...rest } = body; + Object.assign(repoState, rest); + if (default_branch !== undefined && branches.includes(default_branch)) repoState.default_branch = default_branch; + return send(200, repoState); + } + } 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); + giteaEdit(r, body); return send(200, r); } - if (req.method === "POST" && !m[1]) { rules.push({ ...body }); return send(201, body); } + if (req.method === "POST" && !m[1]) { const r = { rule_name: body.rule_name }; giteaEdit(r, body); rules.push(r); return send(201, r); } send(405, { message: "no" }); }); }); @@ -49,6 +85,8 @@ 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 setRepo = (args: Record) => + tools.find((t) => t.name === "gitea_repo_settings_set_full")!.run({ owner: "novox", repo: "de-spiegel", why: "L2", ...args }); 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 }); @@ -110,3 +148,36 @@ test("the trunk rule is judged as the controller judges it", () => { assert.match(got.why, why); } }); + +// Review round 1 of feat/protect-as-trunk: gitea passes over a dependent field without its parent set true, and answers 200. +test("a field the forge reads only beside its parent is refused without it; with it, it is applied", async () => { + await assert.rejects(setFull({ enable_push_whitelist: true }), /only when the same call sets enable_push: true/); + await assert.rejects(setFull({ force_push_allowlist_deploy_keys: true, enable_force_push: false }), /enable_force_push: true/); + assert.deepEqual(sent, []); + const out: any = await setFull({ enable_push: true, enable_push_whitelist: true, push_whitelist_usernames: ["jschoubben"] }); + assert.equal(out.after.fields.enable_push_whitelist, true); + await setFull({ enable_push: false, enable_push_whitelist: false }); // a false beside a false parent is what the forge does +}); + +test("a field the forge answered and did not apply fails the call, naming it; what it changed beside the call is said", async () => { + await assert.rejects(setFull({ merge_whitelist_teams: ["owners"] }), /did not apply merge_whitelist_teams \(sent \["owners"\], the forge holds \[\]\)/); + rules[0].enable_push = true; rules[0].enable_push_whitelist = true; + const out: any = await setFull({ enable_push: false }); + assert.deepEqual(out.also_changed, ["enable_push_whitelist"]); +}); + +test("gitea_repo_settings_set_full sets the default branch, and fails loudly on a branch the forge passed over", async () => { + const out: any = await setRepo({ default_branch: "main" }); + assert.deepEqual(sent.at(-1), { method: "PATCH", path: "/repos/novox/de-spiegel", body: { default_branch: "main" } }); + assert.equal(out.before.default_branch, "master"); + assert.equal(out.after.default_branch, "main"); + await assert.rejects(setRepo({ default_branch: "trunk" }), /did not apply default_branch/); + await assert.rejects(setRepo({ name: "x" }), /no field of a repository's settings/); + await assert.rejects(setRepo({ why: "", has_wiki: false }), /say why/); +}); + +test("every field gitea 1.27.3 edits on a rule is taken: 32, the bypass allowlist among them", async () => { + const { PROTECTION_FIELDS } = await import("../dist/protectfull.js"); + assert.equal(Object.keys(PROTECTION_FIELDS).length, 32); + for (const f of ["enable_bypass_allowlist", "bypass_allowlist_usernames", "bypass_allowlist_teams"]) assert.ok(f in PROTECTION_FIELDS, f); +}); diff --git a/modules/gitea/test/token.test.ts b/modules/gitea/test/token.test.ts index 030af1d1..c6399fce 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_set_full", + "gitea_branch_protection_get", "gitea_team_rights", "gitea_branch_protection_set", "gitea_branch_protection_set_full", "gitea_repo_settings_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 226a1c1e..f7e56b9e 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -12,7 +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 { PROTECTION_FIELDS, REPO_FIELDS, alsoChanged, fullProtectionBody, givenFields, trunkRule, unapplied } from "../protectfull.js"; import { appendNote, run, viewBody, viewComment } from "../delivery.js"; import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js"; import { @@ -432,8 +432,9 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini }, run: async (args) => { const owner = String(args.owner), repo = String(args.repo); - // The one read made as the forge's administrator (novox/hq ADR 0375, issue 772): the rules' list is the - // repository administrator's, which the tools' account is not. No other tool uses this account. + // Read as the forge's administrator (novox/hq ADR 0375, issue 772): the rules' list is the repository + // administrator's, which the tools' account is not. gitea_team_rights reads, and gitea_branch_protection_set_full + // and gitea_repo_settings_set_full write, with the same account. const rules = await (admin ? admin() : gitea).branchProtections(owner, repo); if (!args.branch) return { rules: rules.map(summarised) }; const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === String(args.branch)) ?? null; @@ -492,14 +493,17 @@ 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.", + "push and push whitelist, force push and its allowlist, the bypass allowlist, required status checks, required approvals and " + + "the 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): the 32 " + + "fields gitea 1.27.3's API edits, by their own names. Only the fields given change; a branch with no rule gets one. " + + "enable_push_whitelist and push_whitelist_deploy_keys need enable_push: true in the same call, the force-push allowlist " + + "flags enable_force_push: true (the forge ignores them otherwise). The rule is read back after the write: a field the " + + "forge did not apply fails the call, naming it; also_changed names what the forge changed beside the call. 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" }, @@ -525,8 +529,13 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini 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; + const missed = unapplied(body, read); + if (missed.length) { + throw new Error(`the forge answered the write of ${owner}/${repo} ${branch}'s rule but did not apply ${missed.join("; ")}. ` + + `The rule it holds now: ${JSON.stringify(summarised(read))}`); + } return { - branch, created: !before, why, set: body, + branch, created: !before, why, set: body, also_changed: alsoChanged(before, read, body), before: before ? { ...summarised(before), fields: before } : null, after: { ...summarised(read), fields: read }, trunk_rule: trunkRule(after, branch), @@ -534,6 +543,39 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini }, }, + { + name: "gitea_repo_settings_set_full", + description: "Set a repository's settings as the forge's administrator — its default branch (e.g. master to main), " + + "description, website, private, template, archived, the issues, wiki, pull requests, projects, releases, packages and " + + "actions units, the merge styles it allows and its default merge style, delete the branch after merge, and the mirror " + + "settings: gitea 1.27.3's EditRepoOption by its own names, all but name and the tracker and wiki objects. Only the " + + "fields given change. The repository is read back: a field the forge did not apply (a default branch that does not " + + "exist) fails the call, naming it. A reason (why) is required and logged. Answers the settings before and after.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + why: { type: "string", description: "why, in a sentence: logged and answered" }, + ...Object.fromEntries(Object.entries(REPO_FIELDS).map(([name, f]) => [name, { type: f.kind, description: f.says }])), + }, + run: async (args) => { + const owner = String(args.owner), repo = String(args.repo), why = String(args.why ?? "").trim(); + if (!why) throw new Error("say why (why): a repository's settings are changed for a reason, which is logged"); + if (!admin) throw new Error("the forge's administrator login is not set: a repository's settings are written with it"); + const given = givenFields(args); + if (!given.length) throw new Error(`name at least one setting: ${Object.keys(REPO_FIELDS).join(", ")}`); + const body = fullProtectionBody(args, given, REPO_FIELDS, "a repository's settings"); + const forge = admin(); + const before = await forge.repoSettings(owner, repo); + console.log(`[gitea] gitea_repo_settings_set_full ${owner}/${repo}: ${JSON.stringify(body)} — why: ${why}`); + await forge.editRepoSettings(owner, repo, body); + const after = await forge.repoSettings(owner, repo); + const missed = unapplied(body, after); + if (missed.length) throw new Error(`the forge answered the write of ${owner}/${repo}'s settings but did not apply ${missed.join("; ")}`); + const pick = (r: Record) => Object.fromEntries(Object.keys(REPO_FIELDS).filter((k) => k in r).map((k) => [k, r[k]])); + return { repository: `${owner}/${repo}`, why, set: body, before: pick(before), after: pick(after) }; + }, + }, + // ---- Two settings gitea_api refuses (issue 1422 note 7; guard.ts), each its own named tool ---- { name: "gitea_default_branch_set", From 8068cf74a7b8fe3042f0439c2e27c0b876bb5caa Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 16:44:11 +0100 Subject: [PATCH 3/5] gitea: read a repository's settings back in the forge's own form, and take the settings it was missing enable_prune is never in the forge's answer and mirror_interval comes back as a Go duration, so a write that worked failed its read-back. The first is now said as not read back, the second compared as a duration. allow_merge_update, default_update_style and has_code are taken, projects_mode needs has_projects true, and the description names what is left out: the name, the tracker and wiki objects, and the mirror's credentials. --- modules/gitea/protectfull.ts | 44 ++++++++++++++++------ modules/gitea/test/protection-full.test.ts | 26 ++++++++++++- modules/gitea/tools/index.ts | 14 ++++--- 3 files changed, 66 insertions(+), 18 deletions(-) diff --git a/modules/gitea/protectfull.ts b/modules/gitea/protectfull.ts index ee41a55c..9622738f 100644 --- a/modules/gitea/protectfull.ts +++ b/modules/gitea/protectfull.ts @@ -8,7 +8,9 @@ import { TOOLS_ACCOUNT } from "./tools-account.js"; import type { BranchProtection } from "./protection.js"; type Kind = "boolean" | "logins" | "strings" | "number" | "string"; -type Field = { kind: Kind; says: string; parent?: string }; +/** unread: the forge never answers it, so it is not read back; duration: a Go duration, compared as one ("8h" is the + * forge's "8h0m0s"). */ +type Field = { kind: Kind; says: string; parent?: string; unread?: boolean; duration?: boolean }; /** Every field gitea's branch protection API takes on an edit at 1.27.3 (EditBranchProtectionOption, 32 fields), by its * own name, and its kind; rule_name is the branch argument, never a field. A field with a parent is read by the forge @@ -50,24 +52,41 @@ export const PROTECTION_FIELDS: Record = { }; /** The repository settings gitea_repo_settings_set_full sets (gitea's EditRepoOption at 1.27.3), by their own names. Left - * out: name (a rename moves every module's source address) and the tracker and wiki objects. The forge ignores a + * out: name (a rename moves every module's source address), the internal tracker, external tracker and external wiki + * objects, and mirror_username, mirror_password and mirror_token, which are credentials. The forge ignores a * default_branch that does not exist; the read-back below says so. */ export const REPO_FIELDS: Record = Object.fromEntries(([ ["description", "string"], ["website", "string"], ["private", "boolean"], ["template", "boolean"], - ["default_branch", "string"], ["has_issues", "boolean"], ["has_wiki", "boolean"], ["has_pull_requests", "boolean"], - ["has_projects", "boolean"], ["projects_mode", "string"], ["has_releases", "boolean"], ["has_packages", "boolean"], - ["has_actions", "boolean"], ["ignore_whitespace_conflicts", "boolean"], ["allow_merge_commits", "boolean"], - ["allow_rebase", "boolean"], ["allow_rebase_explicit", "boolean"], ["allow_squash_merge", "boolean"], - ["allow_fast_forward_only_merge", "boolean"], ["allow_manual_merge", "boolean"], ["autodetect_manual_merge", "boolean"], - ["allow_rebase_update", "boolean"], ["default_delete_branch_after_merge", "boolean"], ["default_merge_style", "string"], + ["default_branch", "string"], ["has_code", "boolean"], ["has_issues", "boolean"], ["has_wiki", "boolean"], + ["has_pull_requests", "boolean"], ["has_projects", "boolean"], ["projects_mode", "string"], ["has_releases", "boolean"], + ["has_packages", "boolean"], ["has_actions", "boolean"], ["ignore_whitespace_conflicts", "boolean"], + ["allow_merge_commits", "boolean"], ["allow_rebase", "boolean"], ["allow_rebase_explicit", "boolean"], + ["allow_squash_merge", "boolean"], ["allow_fast_forward_only_merge", "boolean"], ["allow_manual_merge", "boolean"], + ["autodetect_manual_merge", "boolean"], ["allow_rebase_update", "boolean"], ["allow_merge_update", "boolean"], + ["default_update_style", "string"], ["default_delete_branch_after_merge", "boolean"], ["default_merge_style", "string"], ["default_allow_maintainer_edit", "boolean"], ["archived", "boolean"], ["mirror_interval", "string"], ["enable_prune", "boolean"], -] as [string, Kind][]).map(([name, kind]) => [name, { kind, says: `the repository's ${name.replace(/_/g, " ")}` }])); +] as [string, Kind][]).map(([name, kind]) => [name, { + kind, says: `the repository's ${name.replace(/_/g, " ")}`, + ...(name === "projects_mode" ? { parent: "has_projects", says: "the repository's projects mode; needs has_projects: true in the same call" } : {}), + ...(name === "enable_prune" ? { unread: true, says: "a mirror's prune setting; the forge never answers it, so it is not read back" } : {}), + ...(name === "mirror_interval" ? { duration: true, says: "a mirror's interval, a Go duration such as 8h (the forge answers 8h0m0s)" } : {}), +}])); + +/** A Go duration ("8h", "1h30m", "8h0m0s", "90s") in milliseconds; NaN when it is none. */ +export function durationMs(v: unknown): number { + if (typeof v !== "string" || !/^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/.test(v)) return v === "0" ? 0 : NaN; + const unit: Record = { ns: 1e-6, us: 1e-3, "µs": 1e-3, ms: 1, s: 1e3, m: 6e4, h: 3.6e6 }; + let ms = 0; + for (const m of v.matchAll(/(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/g)) ms += Number(m[1]) * unit[m[2]]; + return ms; +} /** Fields the forge was sent but does not hold after the write, each with what was sent and what it holds: a write it * answered 200 and did not apply (gitea drops a field whose parent is not set, or a branch that does not exist). */ -export function unapplied(sent: Record, held: Record): string[] { - const norm = (v: unknown) => Array.isArray(v) ? JSON.stringify([...v].map(String).sort()) : JSON.stringify(v ?? null); - return Object.keys(sent).filter((k) => norm(sent[k]) !== norm(held[k])) +export function unapplied(sent: Record, held: Record, fields: Record = PROTECTION_FIELDS): string[] { + const norm = (k: string, v: unknown) => fields[k]?.duration ? String(durationMs(v)) + : Array.isArray(v) ? JSON.stringify([...v].map(String).sort()) : JSON.stringify(v ?? null); + return Object.keys(sent).filter((k) => !fields[k]?.unread && norm(k, sent[k]) !== norm(k, held[k])) .map((k) => `${k} (sent ${JSON.stringify(sent[k])}, the forge holds ${JSON.stringify(held[k] ?? null)})`); } @@ -107,6 +126,7 @@ export function fullProtectionBody(args: Record, given: string[ break; case "string": if (typeof v !== "string") throw new Error(`${name} is a string`); + if (field.duration && Number.isNaN(durationMs(v))) throw new Error(`${name} is a duration, such as 8h or 1h30m`); body[name] = v; break; case "strings": diff --git a/modules/gitea/test/protection-full.test.ts b/modules/gitea/test/protection-full.test.ts index 99e65356..947b07ab 100644 --- a/modules/gitea/test/protection-full.test.ts +++ b/modules/gitea/test/protection-full.test.ts @@ -61,8 +61,11 @@ const server = createServer((req, res) => { if (req.method === "GET") return send(200, repoState); if (req.method === "PATCH") { // gitea's updateBasicProperties: a default branch that does not exist is passed over, the call still 200. - const { default_branch, ...rest } = body; + // enable_prune is a mirror's, kept beside the repository and never in its answer; mirror_interval is answered in Go's + // duration form. + const { default_branch, enable_prune, mirror_interval, ...rest } = body; Object.assign(repoState, rest); + if (mirror_interval !== undefined) repoState.mirror_interval = mirror_interval === "8h" ? "8h0m0s" : mirror_interval; if (default_branch !== undefined && branches.includes(default_branch)) repoState.default_branch = default_branch; return send(200, repoState); } @@ -181,3 +184,24 @@ test("every field gitea 1.27.3 edits on a rule is taken: 32, the bypass allowlis assert.equal(Object.keys(PROTECTION_FIELDS).length, 32); for (const f of ["enable_bypass_allowlist", "bypass_allowlist_usernames", "bypass_allowlist_teams"]) assert.ok(f in PROTECTION_FIELDS, f); }); + +// Review round 2: the settings tool's read-back meets the forge's own answer. +test("A: enable_prune, which the forge never answers, is said as not read back, never failed", async () => { + const pruned: any = await setRepo({ enable_prune: true }); + assert.deepEqual(pruned.not_read_back, ["enable_prune"]); +}); + +test("B: mirror_interval is compared as a duration: 8h and the forge's 8h0m0s are one", async () => { + const mirror: any = await setRepo({ mirror_interval: "8h" }); + assert.equal(mirror.after.mirror_interval, "8h0m0s"); + await assert.rejects(setRepo({ mirror_interval: "often" }), /duration/); +}); + +test("C: allow_merge_update, default_update_style and has_code are taken; projects_mode needs has_projects; what is left out is named", async () => { + const more: any = await setRepo({ allow_merge_update: true, default_update_style: "rebase", has_code: true }); + assert.deepEqual(more.set, { allow_merge_update: true, default_update_style: "rebase", has_code: true }); + await assert.rejects(setRepo({ projects_mode: "repo" }), /only when the same call sets has_projects: true/); + await setRepo({ has_projects: true, projects_mode: "repo" }); + const desc = tools.find((t) => t.name === "gitea_repo_settings_set_full")!.description; + for (const w of ["name", "mirror_username", "mirror_password", "mirror_token", "tracker", "wiki"]) assert.match(desc, new RegExp(w)); +}); diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index f7e56b9e..fdff3104 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -548,9 +548,12 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini description: "Set a repository's settings as the forge's administrator — its default branch (e.g. master to main), " + "description, website, private, template, archived, the issues, wiki, pull requests, projects, releases, packages and " + "actions units, the merge styles it allows and its default merge style, delete the branch after merge, and the mirror " + - "settings: gitea 1.27.3's EditRepoOption by its own names, all but name and the tracker and wiki objects. Only the " + - "fields given change. The repository is read back: a field the forge did not apply (a default branch that does not " + - "exist) fails the call, naming it. A reason (why) is required and logged. Answers the settings before and after.", + "settings: gitea 1.27.3's EditRepoOption by its own names. Left out: name (a rename moves every module's source " + + "address), the internal tracker, external tracker and external wiki objects, and mirror_username, mirror_password " + + "and mirror_token, which are credentials. projects_mode needs has_projects: true in the same call. Only the fields " + + "given change. The repository is read back: a field the forge did not apply (a default branch that does not exist) " + + "fails the call, naming it; mirror_interval is compared as a duration, and enable_prune, which the forge never " + + "answers, is listed in not_read_back. A reason (why) is required and logged. Answers the settings before and after.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, @@ -569,10 +572,11 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini console.log(`[gitea] gitea_repo_settings_set_full ${owner}/${repo}: ${JSON.stringify(body)} — why: ${why}`); await forge.editRepoSettings(owner, repo, body); const after = await forge.repoSettings(owner, repo); - const missed = unapplied(body, after); + const missed = unapplied(body, after, REPO_FIELDS); if (missed.length) throw new Error(`the forge answered the write of ${owner}/${repo}'s settings but did not apply ${missed.join("; ")}`); const pick = (r: Record) => Object.fromEntries(Object.keys(REPO_FIELDS).filter((k) => k in r).map((k) => [k, r[k]])); - return { repository: `${owner}/${repo}`, why, set: body, before: pick(before), after: pick(after) }; + return { repository: `${owner}/${repo}`, why, set: body, before: pick(before), after: pick(after), + not_read_back: Object.keys(body).filter((k) => REPO_FIELDS[k]?.unread) }; }, }, From fabb62da0058ef38b4e99661510c80496b102111 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 16:48:55 +0100 Subject: [PATCH 4/5] gitea: refuse a force-push onto a rule covering main until the module can ask the operator's Approve for it The operator, 2026-10-28: a call waits for their Approve only if it removes branch protection or allows a force-push to main. No TypeScript tool can ask yet (no asker in the TypeScript SDK; ADR 0286's mark is not built in the runner), so such a call is refused with nothing sent, and every other call runs at once. No tool removes a rule. --- modules/gitea/protectfull.ts | 16 +++++++++++++++- modules/gitea/test/protection-full.test.ts | 16 ++++++++++++++++ modules/gitea/tools/index.ts | 6 ++++-- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/modules/gitea/protectfull.ts b/modules/gitea/protectfull.ts index 9622738f..bd4c2272 100644 --- a/modules/gitea/protectfull.ts +++ b/modules/gitea/protectfull.ts @@ -5,7 +5,8 @@ // cmd/mesh-controller/build_source.go). import { TOOLS_ACCOUNT } from "./tools-account.js"; -import type { BranchProtection } from "./protection.js"; +import { MAIN_BRANCH, type BranchProtection } from "./protection.js"; +import { ruleCovers } from "./guard.js"; type Kind = "boolean" | "logins" | "strings" | "number" | "string"; /** unread: the forge never answers it, so it is not read back; duration: a Go duration, compared as one ("8h" is the @@ -199,3 +200,16 @@ export function trunkRule(rules: BranchProtection[], branch: string): { holds: b 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` }; } + +/** Why a call is the operator's, or null: it lets a force-push onto a rule covering main (the operator, 2026-10-28 + * 16:4x: a call waits for their Approve only if it removes branch protection or allows a force-push to main). No tool of + * this module can ask for that Approve yet, so such a call is refused with nothing sent. */ +export function forcePushToMain(rule: string, body: Record): string | null { + if (!ruleCovers(rule, MAIN_BRANCH)) return null; + const opens = body.enable_force_push === true || body.force_push_allowlist_deploy_keys === true || + (["force_push_allowlist_usernames", "force_push_allowlist_teams"] as const).some((k) => Array.isArray(body[k]) && (body[k] as unknown[]).length > 0); + if (!opens) return null; + return `the rule ${rule} covers ${MAIN_BRANCH}, and this call lets a force-push onto it: a force-push to main is the ` + + `operator's Approve (the operator, 2026-10-28), and no tool of this module can ask it yet, so it is the operator's at ` + + `the forge. Nothing was sent`; +} diff --git a/modules/gitea/test/protection-full.test.ts b/modules/gitea/test/protection-full.test.ts index 947b07ab..0184723e 100644 --- a/modules/gitea/test/protection-full.test.ts +++ b/modules/gitea/test/protection-full.test.ts @@ -205,3 +205,19 @@ test("C: allow_merge_update, default_update_style and has_code are taken; projec const desc = tools.find((t) => t.name === "gitea_repo_settings_set_full")!.description; for (const w of ["name", "mirror_username", "mirror_password", "mirror_token", "tracker", "wiki"]) assert.match(desc, new RegExp(w)); }); + +// The operator, 2026-10-28 16:4x: a call waits for their Approve only if it removes branch protection or allows a +// force-push to main. No tool of this module can ask yet (no TypeScript asker; ADR 0286's mark is unbuilt in the +// runner), so until one can, such a call is refused, with nothing sent, and says it is the operator's; everything else +// runs at once. +test("a force-push onto a rule covering main is refused, nothing sent; on another branch, and tightening, it runs", async () => { + for (const args of [{ enable_force_push: true }, { enable_force_push: true, enable_force_push_allowlist: true }, + { force_push_allowlist_usernames: ["jschoubben"] }, { force_push_allowlist_teams: ["owners"] }, + { branch: "ma*", enable_force_push: true }, { branch: "**", force_push_allowlist_usernames: ["x"] }]) { + await assert.rejects(setFull(args), /force-push to main is the operator's/, JSON.stringify(args)); + } + assert.deepEqual(sent, []); + await setFull({ branch: "release/*", enable_force_push: true }); + const tight: any = await setFull({ enable_force_push: false, force_push_allowlist_usernames: [] }); + assert.equal(tight.trunk_rule.holds, false, "trunk_rule is in the answer"); +}); diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index fdff3104..27c80f60 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -12,7 +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, REPO_FIELDS, alsoChanged, fullProtectionBody, givenFields, trunkRule, unapplied } from "../protectfull.js"; +import { PROTECTION_FIELDS, REPO_FIELDS, alsoChanged, forcePushToMain, fullProtectionBody, givenFields, trunkRule, unapplied } from "../protectfull.js"; import { appendNote, run, viewBody, viewComment } from "../delivery.js"; import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js"; import { @@ -500,7 +500,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini "enable_push_whitelist and push_whitelist_deploy_keys need enable_push: true in the same call, the force-push allowlist " + "flags enable_force_push: true (the forge ignores them otherwise). The rule is read back after the write: a field the " + "forge did not apply fails the call, naming it; also_changed names what the forge changed beside the call. Generic: it " + - "tightens or loosens as told (the operator, 2026-10-28), so a reason (why) is required and logged. As the forge's " + + "tightens or loosens as told (the operator, 2026-10-28), so a reason (why) is required and logged; but a call that lets a force-push onto a rule covering main is refused, nothing sent, until the module can ask the operator's Approve for it. 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.", @@ -522,6 +522,8 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini 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 operators = forcePushToMain(branch, body); + if (operators) throw new Refused(operators); 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; From 16ea3824b5eaa8e53c6fa33b2da80d6671ebbad7 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 16:52:50 +0100 Subject: [PATCH 5/5] gitea: gate and judge every trunk the gate's source names, not main alone The operator, 2026-10-28: the gates and trunk_rule apply to every trunk, the branches the mesh installs from, and later there may be more than main. The trunks are read at the call from mesh-delivery-gate's source, in one function (trunksOf), and fall back to main, said, only when it names none. A force-push onto any trunk is refused, and trunk_rule says whether the branch asked is a trunk. --- modules/gitea/module.json | 3 +- modules/gitea/protectfull.ts | 60 ++++++++++++++++++---- modules/gitea/test/protection-full.test.ts | 31 ++++++++++- modules/gitea/tools/index.ts | 18 ++++--- 4 files changed, 92 insertions(+), 20 deletions(-) diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 954a796b..e416637b 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -569,6 +569,7 @@ "mesh-build-node.keys", "mesh-build-image.keys", "mesh-delivery-checker.owed", - "mesh-delivery-indexer.modules" + "mesh-delivery-indexer.modules", + "mesh-delivery-gate.source" ] } diff --git a/modules/gitea/protectfull.ts b/modules/gitea/protectfull.ts index bd4c2272..e3c836b7 100644 --- a/modules/gitea/protectfull.ts +++ b/modules/gitea/protectfull.ts @@ -182,7 +182,12 @@ function controllerCovers(rule: string, branch: string): boolean { * 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 } { +export function trunkRule(rules: BranchProtection[], branch: string, trunks: string[] = [MAIN_BRANCH]): { holds: boolean; why: string; is_trunk: boolean } { + const judged = judgedTrunkRule(rules, branch); + return { ...judged, is_trunk: trunks.includes(branch) }; +} + +function judgedTrunkRule(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 }); @@ -201,15 +206,52 @@ export function trunkRule(rules: BranchProtection[], branch: string): { holds: b return { holds: true, why: `the rule ${r.rule} takes no push, only ${r.merge_whitelist.join(", ")} merge, and no administrator merges past it` }; } -/** Why a call is the operator's, or null: it lets a force-push onto a rule covering main (the operator, 2026-10-28 - * 16:4x: a call waits for their Approve only if it removes branch protection or allows a force-push to main). No tool of - * this module can ask for that Approve yet, so such a call is refused with nothing sent. */ -export function forcePushToMain(rule: string, body: Record): string | null { - if (!ruleCovers(rule, MAIN_BRANCH)) return null; +/** The gate's source as it is read: one key per trunk, each with the heads of the repositories installed from it. */ +export type SourceRead = () => Promise>; + +/** The state the trunks are read from: mesh-delivery-gate's source, whose key `main` is today's one trunk of each + * repository it names (each repository's trunk head the checker judges on); a later trunk is a key of its own. */ +export const TRUNK_SOURCE = "mesh-delivery-gate.source"; + +/** A repository's trunks, the branches the mesh installs from (the operator, 2026-10-28 16:5x): each key of the gate's + * source whose heads name the repository. "main" only when the source names none of its trunks or cannot be read, and + * `from` says which. The one place trunks are read: several trunks later change only this. */ +export async function trunksOf(owner: string, repo: string, read: SourceRead): Promise<{ names: string[]; from: string }> { + const full = `${owner}/${repo}`; + let src: Record; + try { + src = await read(); + } catch (err) { + return { names: [MAIN_BRANCH], from: `${TRUNK_SOURCE} could not be read (${err instanceof Error ? err.message : String(err)}): ${MAIN_BRANCH} assumed` }; + } + const names = Object.keys(src).filter((k) => { + const heads = (src[k] as { heads?: Record } | null)?.heads; + return !!heads && typeof heads === "object" && full in heads; + }).sort(); + if (names.length) return { names, from: TRUNK_SOURCE }; + return { names: [MAIN_BRANCH], from: `${TRUNK_SOURCE} names no trunk of ${full}: ${MAIN_BRANCH} assumed` }; +} + +/** The gate's source read from the bus, every key and its value (the tools bundle's state channel). */ +export function sourceFromBus(get: (name: string) => { keys(): Promise; get(k: string): Promise<{ value: unknown } | null> }): SourceRead { + return async () => { + const st = get(TRUNK_SOURCE); + const out: Record = {}; + for (const k of await st.keys()) out[k] = (await st.get(k))?.value ?? null; + return out; + }; +} + +/** Why a call is the operator's, or null: it lets a force-push onto a rule covering a trunk (the operator, 2026-10-28 + * 16:4x: a call waits for their Approve only if it removes branch protection or allows a force-push to a trunk). No + * tool of this module can ask for that Approve yet, so such a call is refused with nothing sent. */ +export function forcePushToTrunk(rule: string, body: Record, trunks: string[]): string | null { + const covered = trunks.filter((t) => ruleCovers(rule, t)); + if (!covered.length) return null; const opens = body.enable_force_push === true || body.force_push_allowlist_deploy_keys === true || (["force_push_allowlist_usernames", "force_push_allowlist_teams"] as const).some((k) => Array.isArray(body[k]) && (body[k] as unknown[]).length > 0); if (!opens) return null; - return `the rule ${rule} covers ${MAIN_BRANCH}, and this call lets a force-push onto it: a force-push to main is the ` + - `operator's Approve (the operator, 2026-10-28), and no tool of this module can ask it yet, so it is the operator's at ` + - `the forge. Nothing was sent`; + return `the rule ${rule} covers ${covered.join(", ")}, and this call lets a force-push onto it: a force-push to a trunk is ` + + `the operator's Approve (the operator, 2026-10-28), and no tool of this module can ask it yet, so it is the operator's ` + + `at the forge. Nothing was sent`; } diff --git a/modules/gitea/test/protection-full.test.ts b/modules/gitea/test/protection-full.test.ts index 0184723e..f07e22e7 100644 --- a/modules/gitea/test/protection-full.test.ts +++ b/modules/gitea/test/protection-full.test.ts @@ -23,6 +23,7 @@ beforeEach(() => { enable_status_check: false, status_check_contexts: [], block_admin_merge_override: false, required_approvals: 0, enable_push_whitelist: false, push_whitelist_deploy_keys: false, enable_force_push: false, enable_force_push_allowlist: false, force_push_allowlist_deploy_keys: false, merge_whitelist_teams: [] }]; + source = {}; repoState = { full_name: "novox/de-spiegel", default_branch: "master", description: "", has_wiki: true }; sent = []; }); @@ -87,7 +88,11 @@ 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")); +// The gate's source as the tools read it (mesh-delivery-gate.source): one key per trunk, each naming the repositories +// installed from it. A test sets it; empty is a source that names no trunk of de-spiegel. +let source: Record }> = {}; +const readSource = async () => source; +const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret"), readSource); const setRepo = (args: Record) => tools.find((t) => t.name === "gitea_repo_settings_set_full")!.run({ owner: "novox", repo: "de-spiegel", why: "L2", ...args }); const setFull = (args: Record) => @@ -214,10 +219,32 @@ test("a force-push onto a rule covering main is refused, nothing sent; on anothe for (const args of [{ enable_force_push: true }, { enable_force_push: true, enable_force_push_allowlist: true }, { force_push_allowlist_usernames: ["jschoubben"] }, { force_push_allowlist_teams: ["owners"] }, { branch: "ma*", enable_force_push: true }, { branch: "**", force_push_allowlist_usernames: ["x"] }]) { - await assert.rejects(setFull(args), /force-push to main is the operator's/, JSON.stringify(args)); + await assert.rejects(setFull(args), /force-push to a trunk is the operator's/, JSON.stringify(args)); } assert.deepEqual(sent, []); await setFull({ branch: "release/*", enable_force_push: true }); const tight: any = await setFull({ enable_force_push: false, force_push_allowlist_usernames: [] }); assert.equal(tight.trunk_rule.holds, false, "trunk_rule is in the answer"); }); + +// The operator, 2026-10-28 16:5x: the gates and trunk_rule apply to every trunk, the branches the mesh installs from, as +// the gate's source names them; "main" only when it names none, and said. +test("a trunk the gate's source names, beta, is gated and judged as main is; main is then no trunk of the repository", async () => { + source = { main: { heads: { "novox/okioki": "a1" } }, beta: { heads: { "novox/de-spiegel": "b2" } } }; + await assert.rejects(setFull({ branch: "beta", enable_force_push: true }), /force-push to a trunk is the operator's/); + await assert.rejects(setFull({ branch: "b*", force_push_allowlist_usernames: ["x"] }), /covers beta/); + assert.deepEqual(sent, []); + const main: any = await setFull({ enable_force_push: true }); + assert.deepEqual(main.trunks, { names: ["beta"], from: "mesh-delivery-gate.source" }); + assert.equal(main.trunk_rule.is_trunk, false); + const beta: any = await setFull({ branch: "beta", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"], block_admin_merge_override: true }); + assert.equal(beta.trunk_rule.is_trunk, true); + assert.equal(beta.trunk_rule.holds, true, beta.trunk_rule.why); +}); + +test("a source that names no trunk of the repository falls back to main, and says so", async () => { + const out: any = await setFull({ block_admin_merge_override: true }); + assert.equal(out.trunks.names[0], "main"); + assert.match(out.trunks.from, /names no trunk of novox\/de-spiegel: main assumed/); + assert.equal(out.trunk_rule.is_trunk, true); +}); diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 27c80f60..e1029d65 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -12,7 +12,8 @@ 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, REPO_FIELDS, alsoChanged, forcePushToMain, fullProtectionBody, givenFields, trunkRule, unapplied } from "../protectfull.js"; +import { state } from "@novox/mesh-sdk/state"; +import { PROTECTION_FIELDS, REPO_FIELDS, alsoChanged, forcePushToTrunk, fullProtectionBody, sourceFromBus, trunksOf, type SourceRead, givenFields, trunkRule, unapplied } from "../protectfull.js"; import { appendNote, run, viewBody, viewComment } from "../delivery.js"; import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js"; import { @@ -89,13 +90,13 @@ function listedDirs(raw: unknown): string[] | undefined { return raw as string[]; } -export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] { - return namesChecked(allGiteaTools(gitea, admin)); +export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin, source: SourceRead = sourceFromBus(state)): ToolDefinition[] { + return namesChecked(allGiteaTools(gitea, admin, source)); } // gitea_merge_pull_request is served from the watcher's bundle (merge.ts, index.ts), beside the merge check's one // writer (novox/hq issue 456, ADR 0323). -function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] { +function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined, source: SourceRead): ToolDefinition[] { return [ // ---- Repositories ---- { @@ -500,8 +501,8 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini "enable_push_whitelist and push_whitelist_deploy_keys need enable_push: true in the same call, the force-push allowlist " + "flags enable_force_push: true (the forge ignores them otherwise). The rule is read back after the write: a field the " + "forge did not apply fails the call, naming it; also_changed names what the forge changed beside the call. Generic: it " + - "tightens or loosens as told (the operator, 2026-10-28), so a reason (why) is required and logged; but a call that lets a force-push onto a rule covering main is refused, nothing sent, until the module can ask the operator's Approve for it. As the forge's " + - "administrator. Answers the rule before and after, and trunk_rule: whether the controller now registers a module from " + + "tightens or loosens as told (the operator, 2026-10-28), so a reason (why) is required and logged; but a call that lets a force-push onto a rule covering a trunk is refused, nothing sent, until the module can ask the operator's Approve for it. A repository's trunks, the branches the mesh installs from, are read from mesh-delivery-gate's source at the call (main when it names none, said in trunks.from). As the forge's " + + "administrator. Answers the rule before and after, the repository's trunks, and trunk_rule: whether the branch is a trunk (is_trunk) and 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: { @@ -522,7 +523,8 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini 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 operators = forcePushToMain(branch, body); + const trunks = await trunksOf(owner, repo, source); + const operators = forcePushToTrunk(branch, body, trunks.names); if (operators) throw new Refused(operators); const forge = admin ? admin() : gitea; const rules = await forge.branchProtections(owner, repo); @@ -540,7 +542,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini branch, created: !before, why, set: body, also_changed: alsoChanged(before, read, body), before: before ? { ...summarised(before), fields: before } : null, after: { ...summarised(read), fields: read }, - trunk_rule: trunkRule(after, branch), + trunks, trunk_rule: trunkRule(after, branch, trunks.names), }; }, },