gitea: refuse a force-push onto a rule covering main until the module can ask the operator's Approve for it

The operator, 2026-10-28: a call waits for their Approve only if it removes
branch protection or allows a force-push to main. No TypeScript tool can
ask yet (no asker in the TypeScript SDK; ADR 0286's mark is not built in
the runner), so such a call is refused with nothing sent, and every other
call runs at once. No tool removes a rule.
This commit is contained in:
2026-10-28 16:48:55 +01:00
parent 8068cf74a7
commit fabb62da00
3 changed files with 35 additions and 3 deletions
+15 -1
View File
@@ -5,7 +5,8 @@
// cmd/mesh-controller/build_source.go).
import { TOOLS_ACCOUNT } from "./tools-account.js";
import type { BranchProtection } from "./protection.js";
import { MAIN_BRANCH, type BranchProtection } from "./protection.js";
import { ruleCovers } from "./guard.js";
type Kind = "boolean" | "logins" | "strings" | "number" | "string";
/** unread: the forge never answers it, so it is not read back; duration: a Go duration, compared as one ("8h" is the
@@ -199,3 +200,16 @@ export function trunkRule(rules: BranchProtection[], branch: string): { holds: b
if (r.admin_may_override) return no(`the rule ${r.rule} lets an administrator merge into ${branch} past it`);
return { holds: true, why: `the rule ${r.rule} takes no push, only ${r.merge_whitelist.join(", ")} merge, and no administrator merges past it` };
}
/** Why a call is the operator's, or null: it lets a force-push onto a rule covering main (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 main). No tool of
* this module can ask for that Approve yet, so such a call is refused with nothing sent. */
export function forcePushToMain(rule: string, body: Record<string, unknown>): string | null {
if (!ruleCovers(rule, MAIN_BRANCH)) 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 ${MAIN_BRANCH}, and this call lets a force-push onto it: a force-push to main 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`;
}
@@ -205,3 +205,19 @@ test("C: allow_merge_update, default_update_style and has_code are taken; projec
const desc = tools.find((t) => t.name === "gitea_repo_settings_set_full")!.description;
for (const w of ["name", "mirror_username", "mirror_password", "mirror_token", "tracker", "wiki"]) assert.match(desc, new RegExp(w));
});
// 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 main. No tool of this module can ask yet (no TypeScript asker; ADR 0286's mark is unbuilt in the
// runner), so until one can, such a call is refused, with nothing sent, and says it is the operator's; everything else
// runs at once.
test("a force-push onto a rule covering main is refused, nothing sent; on another branch, and tightening, it runs", async () => {
for (const args of [{ enable_force_push: true }, { enable_force_push: true, enable_force_push_allowlist: true },
{ force_push_allowlist_usernames: ["jschoubben"] }, { force_push_allowlist_teams: ["owners"] },
{ branch: "ma*", enable_force_push: true }, { branch: "**", force_push_allowlist_usernames: ["x"] }]) {
await assert.rejects(setFull(args), /force-push to main is the operator's/, JSON.stringify(args));
}
assert.deepEqual(sent, []);
await setFull({ branch: "release/*", enable_force_push: true });
const tight: any = await setFull({ enable_force_push: false, force_push_allowlist_usernames: [] });
assert.equal(tight.trunk_rule.holds, false, "trunk_rule is in the answer");
});
+4 -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, fullProtectionBody, givenFields, trunkRule, unapplied } from "../protectfull.js";
import { PROTECTION_FIELDS, REPO_FIELDS, alsoChanged, forcePushToMain, fullProtectionBody, givenFields, trunkRule, unapplied } from "../protectfull.js";
import { appendNote, run, viewBody, viewComment } from "../delivery.js";
import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js";
import {
@@ -500,7 +500,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
"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 " +
"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 main is refused, nothing sent, until the module can ask the operator's Approve for it. 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-<node>, no administrator override) and why not.",
@@ -522,6 +522,8 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
const given = givenFields(args);
if (!given.length) throw new Error(`name at least one field to set: ${Object.keys(PROTECTION_FIELDS).join(", ")}`);
const body = fullProtectionBody(args, given);
const operators = forcePushToMain(branch, body);
if (operators) throw new Refused(operators);
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;