diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 24667981..8c022545 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,31 @@ 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; + } + + /** 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/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 new file mode 100644 index 00000000..e3c836b7 --- /dev/null +++ b/modules/gitea/protectfull.ts @@ -0,0 +1,257 @@ +// 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 { 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 + * 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 + * 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; 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; 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" }, + 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)" }, + 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), 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_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, " ")}`, + ...(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, 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)})`); +} + +/** 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[], + fields: Record = PROTECTION_FIELDS, what = "a branch protection rule"): Record { + const body: Record = {}; + for (const name of given) { + 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": + 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`); + 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": + 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, 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 }); + 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` }; +} + +/** 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 ${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 new file mode 100644 index 00000000..f07e22e7 --- /dev/null +++ b/modules/gitea/test/protection-full.test.ts @@ -0,0 +1,250 @@ +// 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 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_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 = []; +}); + +// 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)); }; + 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 }); + 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. + // 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); + } + } + 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" }); + giteaEdit(r, body); + return send(200, r); + } + 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" }); + }); +}); +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}`; +// 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) => + 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); + } +}); + +// 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); +}); + +// 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)); +}); + +// 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 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/test/token.test.ts b/modules/gitea/test/token.test.ts index ad7c9e18..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_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 9a343d63..e1029d65 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -12,6 +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 { 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 { @@ -88,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 ---- { @@ -431,8 +433,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; @@ -488,6 +491,99 @@ 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 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; 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: { + 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 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); + 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; + 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, also_changed: alsoChanged(before, read, body), + before: before ? { ...summarised(before), fields: before } : null, + after: { ...summarised(read), fields: read }, + trunks, trunk_rule: trunkRule(after, branch, trunks.names), + }; + }, + }, + + { + 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. 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" }, + 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, 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), + not_read_back: Object.keys(body).filter((k) => REPO_FIELDS[k]?.unread) }; + }, + }, + // ---- Two settings gitea_api refuses (issue 1422 note 7; guard.ts), each its own named tool ---- { name: "gitea_default_branch_set",