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",