diff --git a/modules/gitea/protectfull.ts b/modules/gitea/protectfull.ts index 9622738f..bd4c2272 100644 --- a/modules/gitea/protectfull.ts +++ b/modules/gitea/protectfull.ts @@ -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 | 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`; +} diff --git a/modules/gitea/test/protection-full.test.ts b/modules/gitea/test/protection-full.test.ts index 947b07ab..0184723e 100644 --- a/modules/gitea/test/protection-full.test.ts +++ b/modules/gitea/test/protection-full.test.ts @@ -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"); +}); diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index fdff3104..27c80f60 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, 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-, 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;