// 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`; }