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",