AM-K2: the forge module's gitea_branch_protection_loosen alone removes a trunk's protection or lets a force-push onto it

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
This commit is contained in:
2026-11-12 05:17:44 +01:00
parent 64669575c8
commit 9fe3f41ac0
6 changed files with 122 additions and 8 deletions
+14
View File
@@ -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<void> {
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<void> {
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<Record<string, unknown>> {
const path = repoPath(owner, repo);
+6
View File
@@ -162,6 +162,12 @@
]
}
],
"uses": [
"operator-channel"
],
"needs-approval": [
"gitea_branch_protection_loosen"
],
"data": {
"own": [
{
+12 -5
View File
@@ -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<string, unknown>, 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`;
}
@@ -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<string, unknown>) =>
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][] = [
+1 -1
View File
@@ -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",
+50 -2
View File
@@ -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-<node>, 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), " +