Gitea reads the push whitelist and force-push allowlist flags only beside their parent set true and passes over a missing default branch, both with a 200, so the tool said set for what did not change. It now refuses a dependent field without its parent, reads the rule back and names each field not applied, and says what the forge changed beside the call. The bypass allowlist fields complete the 32. gitea_repo_settings_set_full sets a repository's settings, its default branch among them, as the forge's administrator: gitea_default_branch_set is refused as mesh-tools.
182 lines
12 KiB
TypeScript
182 lines
12 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 type { BranchProtection } from "./protection.js";
|
|
|
|
type Kind = "boolean" | "logins" | "strings" | "number" | "string";
|
|
type Field = { kind: Kind; says: string; parent?: string };
|
|
|
|
/** Every field gitea's branch protection API takes 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) and the tracker and wiki objects. The forge ignores a
|
|
* default_branch that does not exist; the read-back below says so. */
|
|
export const REPO_FIELDS: Record<string, Field> = Object.fromEntries(([
|
|
["description", "string"], ["website", "string"], ["private", "boolean"], ["template", "boolean"],
|
|
["default_branch", "string"], ["has_issues", "boolean"], ["has_wiki", "boolean"], ["has_pull_requests", "boolean"],
|
|
["has_projects", "boolean"], ["projects_mode", "string"], ["has_releases", "boolean"], ["has_packages", "boolean"],
|
|
["has_actions", "boolean"], ["ignore_whitespace_conflicts", "boolean"], ["allow_merge_commits", "boolean"],
|
|
["allow_rebase", "boolean"], ["allow_rebase_explicit", "boolean"], ["allow_squash_merge", "boolean"],
|
|
["allow_fast_forward_only_merge", "boolean"], ["allow_manual_merge", "boolean"], ["autodetect_manual_merge", "boolean"],
|
|
["allow_rebase_update", "boolean"], ["default_delete_branch_after_merge", "boolean"], ["default_merge_style", "string"],
|
|
["default_allow_maintainer_edit", "boolean"], ["archived", "boolean"], ["mirror_interval", "string"], ["enable_prune", "boolean"],
|
|
] as [string, Kind][]).map(([name, kind]) => [name, { kind, says: `the repository's ${name.replace(/_/g, " ")}` }]));
|
|
|
|
/** Fields the forge was sent but does not hold after the write, each with what was sent and what it holds: a write it
|
|
* answered 200 and did not apply (gitea drops a field whose parent is not set, or a branch that does not exist). */
|
|
export function unapplied(sent: Record<string, unknown>, held: Record<string, unknown>): string[] {
|
|
const norm = (v: unknown) => Array.isArray(v) ? JSON.stringify([...v].map(String).sort()) : JSON.stringify(v ?? null);
|
|
return Object.keys(sent).filter((k) => norm(sent[k]) !== norm(held[k]))
|
|
.map((k) => `${k} (sent ${JSON.stringify(sent[k])}, the forge holds ${JSON.stringify(held[k] ?? null)})`);
|
|
}
|
|
|
|
/** Fields that changed between two reads but were not sent: what the forge changed beside the call (enable_push: false
|
|
* clears the push whitelist flags). */
|
|
export function alsoChanged(before: Record<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`);
|
|
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` };
|
|
}
|