From 3cdea45f9072befa5971978e87d8dcbc123bd824 Mon Sep 17 00:00:00 2001 From: fix-forge-awg Date: Fri, 16 Oct 2026 21:35:51 +0200 Subject: [PATCH] Keep main's rule right at every start and copy a covering glob's checks (issue 948, review round 1) --- modules/gitea/client.ts | 33 ++++++++++++++---------- modules/gitea/index.ts | 8 ++++++ modules/gitea/test/tools-account.test.ts | 30 ++++++++++++++++++++- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index e0b80364..536420b8 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -6,7 +6,7 @@ import { readFileSync } from "node:fs"; import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js"; import { MAIN_BRANCH, mainRuleBody, protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js"; -import { Refused, checkSentPath, pathSegment, pathSegments } from "./guard.js"; +import { Refused, checkSentPath, ruleCovers, pathSegment, pathSegments } from "./guard.js"; import { isCommit } from "./checks.js"; import { TOOLS_ACCOUNT } from "./tools-account.js"; @@ -1005,21 +1005,26 @@ export class GiteaAdmin { if (list.length < 50) break; } for (const repo of repos) { + const rules = await this.branchProtections(org, repo); + const own = rules.find((r) => (r.rule_name ?? r.branch_name) === MAIN_BRANCH); const rule = `${repoPath(org, repo)}/branch_protections/${MAIN_BRANCH}`; - const patch = await this.request(rule, { method: "PATCH", body: JSON.stringify(mainRuleBody()) }); - if (patch.status === 200) continue; - if (patch.status !== 404) GiteaAdmin.fail(rule, patch); - const made = await this.request(`${repoPath(org, repo)}/branch_protections`, { - method: "POST", - body: JSON.stringify({ rule_name: MAIN_BRANCH, enable_status_check: false, status_check_contexts: [], ...mainRuleBody() }), - }); - if (made.status === 201) continue; - if (made.status === 403 || made.status === 409 || made.status === 422) { - // A rule made between the two calls: edit it. - const again = await this.request(rule, { method: "PATCH", body: JSON.stringify(mainRuleBody()) }); - if (again.status === 200) continue; + if (own) { + const patch = await this.request(rule, { method: "PATCH", body: JSON.stringify(mainRuleBody()) }); + if (patch.status !== 200) GiteaAdmin.fail(rule, patch); + continue; } - GiteaAdmin.fail(`${repoPath(org, repo)}/branch_protections`, made); + // A rule named exactly main is matched before any glob rule, so it would hide the statuses, approvals and + // administrator limit a glob rule covering main asks: they are copied into it (issue 948, review round 1). + const covering = rules.filter((r) => ruleCovers(String(r.rule_name ?? r.branch_name ?? ""), MAIN_BRANCH)); + const checks = [...new Set(covering.filter((r) => r.enable_status_check).flatMap((r) => (r.status_check_contexts ?? []).filter(Boolean)))]; + const copied: Record = { enable_status_check: checks.length > 0, status_check_contexts: checks }; + const approvals = Math.max(0, ...covering.map((r) => Number(r.required_approvals ?? 0))); + if (approvals > 0) copied.required_approvals = approvals; + if (covering.some((r) => r.block_admin_merge_override === true)) copied.block_admin_merge_override = true; + const made = await this.request(`${repoPath(org, repo)}/branch_protections`, { + method: "POST", body: JSON.stringify({ rule_name: MAIN_BRANCH, ...copied, ...mainRuleBody() }), + }); + if (made.status !== 201) GiteaAdmin.fail(`${repoPath(org, repo)}/branch_protections`, made); } return repos; } diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index 4c444060..b80aed1e 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -530,6 +530,7 @@ async function listenForForge(client: GiteaClient): Promise { } } +let mainTouched = -1; if (gitea) { const client = gitea; // A poll that fails says so once, not once a minute: the same reason repeating (the forge not up @@ -557,6 +558,13 @@ if (gitea) { // **The forge's events first, the polls as the safety net** (novox/hq issue 729): while verified deliveries arrive the // polls slow to every five minutes and announce only what no event said (Pace); otherwise they keep 30 s. await listenForForge(client); + // Main takes no push and only the tools' account merges, in every repository, kept and not only when a token is + // minted: at the start and every ten minutes after, so a repository made later gets it too (issue 948, ADR 0398 §2). + tick(async () => { + const touched = await GiteaAdmin.fromEnv().ensureMainMerges("novox"); + if (touched.length !== mainTouched) console.log(`[gitea] main's rule is right in ${touched.length} repositories`); + mainTouched = touched.length; + }, 600_000); tick(() => pollRepos(client), 60_000); tick(() => pollMerged(client), () => pace.every()); tick(() => pollPulls(client), () => pace.every()); diff --git a/modules/gitea/test/tools-account.test.ts b/modules/gitea/test/tools-account.test.ts index 02c947ad..249ac5ba 100644 --- a/modules/gitea/test/tools-account.test.ts +++ b/modules/gitea/test/tools-account.test.ts @@ -33,7 +33,9 @@ function fakeForge() { const calls: string[] = []; // The organisation's repositories, and the rule each has for main, which the administrator's setup puts right // (issue 948, ADR 0398 §2). - const repos = ["hq", "mesh-catalog"]; + const repos = ["hq", "mesh-catalog", "globbed"]; + const globRules = new Map([["globbed", [{ rule_name: "m*", enable_status_check: true, + status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"], block_admin_merge_override: true, required_approvals: 1 }]]]); const mainRules = new Map([["hq", { rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/merge-gate"] }]]); // What the forge does wrong when a test says so: a token mint or delete it fails, a team another process made // between this one's look and its create (gitea answers the create 422). @@ -152,6 +154,9 @@ function fakeForge() { if (m) { const admins = isAdmin(me) || teams.some((t) => t.members.has(me) && t.units.permission === "admin"); if (!admins) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" }); + if (req.method === "GET" && m[2] === undefined) { + return json(res, 200, [...(mainRules.has(m[1]) ? [mainRules.get(m[1])] : []), ...(globRules.get(m[1]) ?? [])]); + } if (req.method === "PATCH" && m[2] === "main") { const rule = mainRules.get(m[1]); if (!rule) return json(res, 404, { message: "branch protection does not exist" }); @@ -367,3 +372,26 @@ test("setting the tools' account up puts main's rule right in every repository: assert.ok(forge.calls.slice(from).filter((c) => /branch_protections/.test(c)).every((c) => c.endsWith(` as ${ADMIN}`)), "only the administrator's login touches a rule: the tools' account is no repository's administrator"); }); + +test("a glob rule covering main keeps its statuses when main is given a rule of its own name (issue 948, review round 1)", async () => { + const env = await environment(); + await fresh(env).listRepos(); + const rule = forge.mainRules.get("globbed"); + assert.ok(rule, "main is given its own rule"); + assert.deepEqual(rule.status_check_contexts, ["mesh/merge-gate", "mesh/repo-check"]); + assert.equal(rule.enable_status_check, true); + assert.equal(rule.block_admin_merge_override, true); + assert.equal(rule.required_approvals, 1); + assert.equal(rule.enable_push, false); + assert.deepEqual(rule.merge_whitelist_usernames, [TOOLS_ACCOUNT]); +}); + +test("a start with the token kept still puts main's rules right: the module's start asks for it each time (issue 948, review round 1)", async () => { + const src = await readFile(new URL("../index.ts", import.meta.url), "utf8"); + assert.match(src, /ensureMainMerges\("novox"\)/, "index.ts calls it outside the token's mint"); + const { GiteaAdmin } = await import("../dist/client.js"); + const env = await environment(); + forge.mainRules.delete("hq"); + await GiteaAdmin.fromEnv(env).ensureMainMerges("novox"); + assert.equal(forge.mainRules.get("hq").enable_push, false, "a rule gone is made again with no token minted"); +});