From 6e97ba386709811001cc5748d1a7e4c8d5d5fc77 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 16:39:54 +0100 Subject: [PATCH] gitea: fail a protection or settings write the forge answered and did not apply, and take every field gitea 1.27.3 edits 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. --- modules/gitea/client.ts | 16 +++++ modules/gitea/protectfull.ts | 66 +++++++++++++++---- modules/gitea/test/protection-full.test.ts | 77 +++++++++++++++++++++- modules/gitea/test/token.test.ts | 2 +- modules/gitea/tools/index.ts | 66 +++++++++++++++---- 5 files changed, 199 insertions(+), 28 deletions(-) diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index f5b47f8a..8c022545 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -1070,6 +1070,22 @@ export class GiteaAdmin { 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/protectfull.ts b/modules/gitea/protectfull.ts index 63f777a3..ee41a55c 100644 --- a/modules/gitea/protectfull.ts +++ b/modules/gitea/protectfull.ts @@ -8,20 +8,23 @@ 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 (CreateBranchProtectionOption / EditBranchProtectionOption), by its - * own name, and its kind. A call names them as the forge does; rule_name is the branch argument, never a field. */ -export const PROTECTION_FIELDS: Record = { - enable_push: { kind: "boolean", says: "whether anyone who writes may push to the branch directly (false: no direct push)" }, - enable_push_whitelist: { kind: "boolean", says: "with enable_push, only the push whitelist may push" }, +/** 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" }, - enable_force_push: { kind: "boolean", says: "whether a force push is taken" }, - enable_force_push_allowlist: { kind: "boolean", says: "with enable_force_push, only the force-push allowlist may" }, + 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" }, + 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" }, @@ -40,19 +43,58 @@ export const PROTECTION_FIELDS: Record = { 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 = 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, held: Record): 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 | 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[]): Record { +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 = PROTECTION_FIELDS[name]; - if (!field) throw new Error(`${name} is no field of a branch protection rule; the fields are ${Object.keys(PROTECTION_FIELDS).join(", ")}`); + const 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": diff --git a/modules/gitea/test/protection-full.test.ts b/modules/gitea/test/protection-full.test.ts index 871f190c..99e65356 100644 --- a/modules/gitea/test/protection-full.test.ts +++ b/modules/gitea/test/protection-full.test.ts @@ -14,14 +14,40 @@ 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_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: [] }]; + 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)); }; @@ -31,16 +57,26 @@ const server = createServer((req, res) => { 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. + const { default_branch, ...rest } = body; + Object.assign(repoState, rest); + 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" }); - Object.assign(r, body); + giteaEdit(r, body); return send(200, r); } - if (req.method === "POST" && !m[1]) { rules.push({ ...body }); return send(201, body); } + 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" }); }); }); @@ -49,6 +85,8 @@ after(() => new Promise((r) => server.close(() => r()))); const { port } = server.address() as { port: number }; const url = `http://127.0.0.1:${port}`; const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret")); +const 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 }); @@ -110,3 +148,36 @@ test("the trunk rule is judged as the controller judges it", () => { 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); +}); diff --git a/modules/gitea/test/token.test.ts b/modules/gitea/test/token.test.ts index 030af1d1..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_set_full", + "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 226a1c1e..f7e56b9e 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -12,7 +12,7 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools" import { emit } from "@novox/mesh-sdk/events"; import { GiteaAdmin, GiteaClient, type BranchProtection } from "../client.js"; import { readTeamRights } from "../teamrights.js"; -import { PROTECTION_FIELDS, fullProtectionBody, givenFields, trunkRule } from "../protectfull.js"; +import { PROTECTION_FIELDS, REPO_FIELDS, alsoChanged, fullProtectionBody, givenFields, trunkRule, unapplied } from "../protectfull.js"; import { appendNote, run, viewBody, viewComment } from "../delivery.js"; import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js"; import { @@ -432,8 +432,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; @@ -492,14 +493,17 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini { name: "gitea_branch_protection_set_full", description: "Set any field of a branch protection rule — protect a branch as a trunk, set its merge whitelist (users and teams), " + - "push and push whitelist, force push, required status checks, required approvals and approvals whitelist, block on " + - "rejected reviews, block on an outdated branch, dismiss stale approvals, signed commits, protected and unprotected file " + - "patterns, and the administrator's merge override (block_admin_merge_override). Every field the forge's API takes, by " + - "its own name; only the fields given change, a branch with no rule gets one. Generic: it tightens or loosens as told " + - "(the operator, 2026-10-28), so a reason (why) is required and logged. As the forge's administrator. Answers the rule " + - "before and after, and trunk_rule: whether the controller now registers a module from this branch (judgedRule, " + - "mesh-control build_source.go: no push, a merge whitelist naming mesh-tools and no agent-, no administrator " + - "override) and why not.", + "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. As the forge's " + + "administrator. Answers the rule before and after, and trunk_rule: whether the controller now registers a module from " + + "this branch (judgedRule, mesh-control build_source.go: no push, a merge whitelist naming mesh-tools and no " + + "agent-, no administrator override) and why not.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, @@ -525,8 +529,13 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini 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, + branch, created: !before, why, set: body, also_changed: alsoChanged(before, read, body), before: before ? { ...summarised(before), fields: before } : null, after: { ...summarised(read), fields: read }, trunk_rule: trunkRule(after, branch), @@ -534,6 +543,39 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini }, }, + { + 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, all but name and the tracker and wiki objects. 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. 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); + 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) }; + }, + }, + // ---- Two settings gitea_api refuses (issue 1422 note 7; guard.ts), each its own named tool ---- { name: "gitea_default_branch_set",