From 9fe3f41ac0aeced17f890b93cd38046118d271f9 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:17:44 +0100 Subject: [PATCH] AM-K2: the forge module's gitea_branch_protection_loosen alone removes a trunk's protection or lets a force-push onto it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool is marked in the manifest's needs-approval, so each call runs on the operator's Approve; the manifest uses operator-channel; set_full keeps refusing a force-push onto a trunk and names the new tool; both clients gain deleteBranchProtection. Builds: design 46 approval mark §21 (milestone G) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- modules/gitea/client.ts | 14 ++++++ modules/gitea/module.json | 6 +++ modules/gitea/protectfull.ts | 17 ++++--- modules/gitea/test/protection-full.test.ts | 39 ++++++++++++++++ modules/gitea/test/token.test.ts | 2 +- modules/gitea/tools/index.ts | 52 +++++++++++++++++++++- 6 files changed, 122 insertions(+), 8 deletions(-) diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index d7402f378..7720ff58d 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -794,6 +794,12 @@ export class GiteaClient { { method: "PATCH", body: JSON.stringify(body) }); } + /** Remove the protection rule named rule (gitea_branch_protection_loosen, design 46 approval mark §21). As this + * client's account, which the forge refuses where it is no administrator. */ + async deleteBranchProtection(owner: string, repo: string, rule: string): Promise { + await this.request(`${repoPath(owner, repo)}/branch_protections/${pathSegments("branch", rule)}`, { method: "DELETE" }); + } + async createPullRequest( owner: string, repo: string, @@ -1097,6 +1103,14 @@ export class GiteaAdmin { return res.body as BranchProtection; } + /** Remove the protection rule named rule, as the forge's administrator (gitea_branch_protection_loosen, design 46 + * approval mark §21): the forge answers 204. */ + async deleteBranchProtection(owner: string, repo: string, rule: string): Promise { + const path = `${repoPath(owner, repo)}/branch_protections/${pathSegments("branch", rule)}`; + const res = await this.request(path, { method: "DELETE" }); + if (res.status !== 204 && res.status !== 200) GiteaAdmin.fail(path, res); + } + /** 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); diff --git a/modules/gitea/module.json b/modules/gitea/module.json index e149a5518..24da1123d 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -162,6 +162,12 @@ ] } ], + "uses": [ + "operator-channel" + ], + "needs-approval": [ + "gitea_branch_protection_loosen" + ], "data": { "own": [ { diff --git a/modules/gitea/protectfull.ts b/modules/gitea/protectfull.ts index ddb173163..b288f0ca7 100644 --- a/modules/gitea/protectfull.ts +++ b/modules/gitea/protectfull.ts @@ -218,16 +218,23 @@ export function trunksOf(): { names: string[]; from: string } { return { names: [MAIN_BRANCH], from: TRUNK_FROM }; } +/** The tool that alone removes a trunk's protection or allows a force-push onto a trunk, marked in the manifest's + * `needs-approval`, so it runs only on the operator's Approve of that one call (design 46 approval mark §21). */ +export const LOOSEN_TOOL = "gitea_branch_protection_loosen"; + +/** The two acts LOOSEN_TOOL performs (design 46 approval mark §21). */ +export const LOOSEN_ACTS = ["remove-protection", "allow-force-push"] as const; + /** 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. */ + * 16:4x: a call waits for their Approve only if it removes branch protection or allows a force-push to a trunk). Those + * two acts are LOOSEN_TOOL's alone (design 46 approval mark §21), so such a call is refused with nothing sent, naming it. */ 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`; + return `the rule ${rule} covers ${covered.join(", ")}, and this call lets a force-push onto it: removing a trunk's protection ` + + `or allowing a force-push onto a trunk is ${LOOSEN_TOOL}'s alone, which runs on the operator's Approve of that one call. ` + + `Nothing was sent`; } diff --git a/modules/gitea/test/protection-full.test.ts b/modules/gitea/test/protection-full.test.ts index 5da3d6016..8830e8885 100644 --- a/modules/gitea/test/protection-full.test.ts +++ b/modules/gitea/test/protection-full.test.ts @@ -79,6 +79,12 @@ const server = createServer((req, res) => { giteaEdit(r, body); return send(200, r); } + if (req.method === "DELETE" && m[1]) { + const i = rules.findIndex((x) => x.rule_name === decodeURIComponent(m[1])); + if (i < 0) return send(404, { message: "no rule" }); + rules.splice(i, 1); + res.writeHead(204); return res.end(); + } 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" }); }); @@ -132,6 +138,39 @@ test("an unknown field, a field of the wrong kind, no field, no reason or a logi }); // judgedRule's cases (mesh-control cmd/mesh-controller/build_source.go), each in its own words. +// Design 46 approval mark §21: a trunk's protection is loosened only by gitea_branch_protection_loosen, marked in the +// manifest's needs-approval; gitea_branch_protection_set_full refuses a force-push onto a trunk, naming it. +const loosen = (args: Record) => + tools.find((t) => t.name === "gitea_branch_protection_loosen")!.run({ owner: "novox", repo: "de-spiegel", branch: "main", why: "L2", ...args }); + +test("set_full refuses a force-push onto a trunk, nothing sent, naming gitea_branch_protection_loosen", async () => { + for (const args of [{ enable_force_push: true }, { enable_force_push: true, enable_force_push_allowlist: true, force_push_allowlist_usernames: ["jschoubben"] }]) { + await assert.rejects(setFull(args), /gitea_branch_protection_loosen's alone/); + } + assert.deepEqual(sent, []); +}); + +test("gitea_branch_protection_loosen removes a trunk's protection, or lets a force-push onto it, and says the trunk rule no longer holds", async () => { + const forced: any = await loosen({ act: "allow-force-push" }); + assert.deepEqual(sent, [{ method: "PATCH", path: "/repos/novox/de-spiegel/branch_protections/main", body: { enable_force_push: true } }]); + assert.equal(forced.before.fields.enable_force_push, false); + assert.equal(forced.after.fields.enable_force_push, true); + sent = []; + const removed: any = await loosen({ act: "remove-protection" }); + assert.deepEqual(sent, [{ method: "DELETE", path: "/repos/novox/de-spiegel/branch_protections/main", body: undefined }]); + assert.equal(removed.after, null); + assert.deepEqual(rules, []); + assert.equal(removed.trunk_rule.holds, false); + assert.match(removed.trunk_rule.why, /no protection rule covers main/); +}); + +test("gitea_branch_protection_loosen refuses an unknown act, no reason or a rule the repository lacks, nothing sent", async () => { + await assert.rejects(loosen({ act: "unprotect" }), /one of remove-protection, allow-force-push/); + await assert.rejects(loosen({ act: "remove-protection", why: "" }), /say why/); + await assert.rejects(loosen({ act: "remove-protection", branch: "release/*" }), /no protection rule named release\/\*/); + assert.deepEqual(sent, []); +}); + test("the trunk rule is judged as the controller judges it", () => { const ok = { rule_name: "main", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"], block_admin_merge_override: true }; const cases: [any[], string, boolean, RegExp][] = [ diff --git a/modules/gitea/test/token.test.ts b/modules/gitea/test/token.test.ts index 4e9fd2af0..a33477b82 100644 --- a/modules/gitea/test/token.test.ts +++ b/modules/gitea/test/token.test.ts @@ -539,7 +539,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_repo_settings_set_full", + "gitea_branch_protection_get", "gitea_team_rights", "gitea_branch_protection_set", "gitea_branch_protection_set_full", "gitea_branch_protection_loosen", "gitea_repo_settings_set_full", "gitea_default_branch_set", "gitea_pull_base_set", "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 8c7e73e2b..86ad0d0c9 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, REPO_FIELDS, alsoChanged, forcePushToTrunk, fullProtectionBody, trunksOf, givenFields, trunkRule, unapplied } from "../protectfull.js"; +import { LOOSEN_ACTS, LOOSEN_TOOL, PROTECTION_FIELDS, REPO_FIELDS, alsoChanged, forcePushToTrunk, fullProtectionBody, trunksOf, givenFields, trunkRule, unapplied } from "../protectfull.js"; import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js"; import { MERGE_METHODS, Refused, checkName, judgeApiCall, judgeContentsWrite, judgeDelete, judgeProtectionChange, pathSegments, routedPath, sentQuery, @@ -484,7 +484,7 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined "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; but a call that lets a force-push onto a rule covering a trunk is refused, nothing sent, until the module can ask the operator's Approve for it. A repository's trunk, the branch the mesh installs from, is main, the mesh's one trunk of every repository (said in trunks.from). As the forge's " + + "tightens or loosens as told (the operator, 2026-10-28), so a reason (why) is required and logged; but a call that lets a force-push onto a rule covering a trunk is refused, nothing sent, naming gitea_branch_protection_loosen, which alone removes a trunk's protection or lets a force-push onto it, on the operator's Approve. A repository's trunk, the branch the mesh installs from, is main, the mesh's one trunk of every repository (said in trunks.from). As the forge's " + "administrator. Answers the rule before and after, the repository's trunks, and trunk_rule: whether the branch is a trunk (is_trunk) and 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.", @@ -530,6 +530,54 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined }, }, + { + name: LOOSEN_TOOL, + description: "Loosen a branch's protection rule in one of the two ways gitea_branch_protection_set_full refuses: remove the rule " + + "(act remove-protection), or let a force-push onto it (act allow-force-push, enable_force_push true). Marked in the " + + "module's needs-approval, so a call runs only on the operator's Approve of that one call (design 46 approval mark " + + "section 21); tightening stays gitea_branch_protection_set_full's, unasked. A reason (why) is required and logged. As " + + "the forge's administrator. Answers the rule before and after (null once removed), the repository's trunks, and " + + "trunk_rule: whether the controller still registers a module from the branch, and why not.", + input: { + owner: { type: "string", description: "the repository owner" }, + repo: { type: "string", description: "the repository name" }, + branch: { type: "string", description: "the branch (the rule's name, or a glob) whose rule is loosened" }, + act: { type: "string", enum: [...LOOSEN_ACTS], description: "remove-protection removes the rule; allow-force-push lets a force-push onto it" }, + why: { type: "string", description: "why, in a sentence: logged and answered" }, + }, + run: async (args) => { + const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim(); + const act = String(args.act ?? ""), why = String(args.why ?? "").trim(); + if (!branch) throw new Error("name the branch whose rule is loosened"); + if (!(LOOSEN_ACTS as readonly string[]).includes(act)) throw new Error(`act is ${JSON.stringify(act)}: it is one of ${LOOSEN_ACTS.join(", ")}`); + if (!why) throw new Error("say why (why): a branch's protection is loosened for a reason, which is logged"); + pathSegments("branch", branch); + const forge = admin ? admin() : gitea; + const rules = await forge.branchProtections(owner, repo); + const before = rules.find((p) => (p.rule_name ?? p.branch_name) === branch) ?? null; + if (!before) throw new Refused(`${owner}/${repo} has no protection rule named ${branch}: there is nothing to loosen. Nothing was sent`); + console.log(`[gitea] ${LOOSEN_TOOL} ${owner}/${repo} ${branch}: ${act} — why: ${why}`); + if (act === "remove-protection") await forge.deleteBranchProtection(owner, repo, branch); + else await forge.writeBranchProtection(owner, repo, branch, { enable_force_push: true }, false); + const after = await forge.branchProtections(owner, repo); + const read = after.find((p) => (p.rule_name ?? p.branch_name) === branch) ?? null; + if (act === "remove-protection" && read) { + throw new Error(`the forge answered the removal of ${owner}/${repo} ${branch}'s rule but still holds it: ${JSON.stringify(summarised(read))}`); + } + if (act === "allow-force-push") { + const missed = read ? unapplied({ enable_force_push: true }, read) : ["the rule itself (the forge no longer holds it)"]; + if (missed.length) throw new Error(`the forge answered the write of ${owner}/${repo} ${branch}'s rule but did not apply ${missed.join("; ")}`); + } + const trunks = trunksOf(); + return { + branch, act, why, + before: { ...summarised(before), fields: before }, + after: read ? { ...summarised(read), fields: read } : null, + trunks, trunk_rule: trunkRule(after, branch, trunks.names), + }; + }, + }, + { 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), " +