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.
216 lines
15 KiB
TypeScript
216 lines
15 KiB
TypeScript
// 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<string, Field> = {
|
|
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-<node>)" },
|
|
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<string, Field> = 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<string, number> = { 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<string, unknown>, held: Record<string, unknown>, fields: Record<string, Field> = 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<string, unknown> | null, after: Record<string, unknown>, sent: Record<string, unknown>): 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<string, unknown>, given: string[],
|
|
fields: Record<string, Field> = PROTECTION_FIELDS, what = "a branch protection rule"): Record<string, unknown> {
|
|
const body: Record<string, unknown> = {};
|
|
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, unknown>): 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-<node>, 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` };
|
|
}
|
|
|
|
/** 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, unknown>): 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`;
|
|
}
|