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:
@@ -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);
|
||||
|
||||
@@ -162,6 +162,12 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
],
|
||||
"needs-approval": [
|
||||
"gitea_branch_protection_loosen"
|
||||
],
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
|
||||
@@ -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][] = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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), " +
|
||||
|
||||
Reference in New Issue
Block a user