diff --git a/modules/gitea/README.md b/modules/gitea/README.md index bd4bd433..cb97c96f 100644 --- a/modules/gitea/README.md +++ b/modules/gitea/README.md @@ -211,3 +211,13 @@ run before anything is deleted; an unreadable range keeping its whole package; a without saying the password. And the range matcher against npm's rules — exact, `^`, `~`, `x` and `*`, comparisons with partial versions, hyphen ranges, `||`, and the pre-release rule — and each lockfile format. + +## Main takes no push, and only the tools' account merges (novox/hq ADR 0398 §2, issue 948) + +The rule for `main` is the same in every repository of the organisation `novox`: `enable_push` false, and a merge +whitelist of the tools' account (`mesh-tools`) alone. `protection.ts` composes it (`mainRuleBody`, and +`protectionBody` for the branch `main` always carries it); `setUpToolsAccount` puts it right in every repository +whenever the tools' account is set up, with the administrator's login (`GiteaAdmin.ensureMainMerges`) — the tools' +account is no repository's administrator, so no tool can. A rule found keeps the statuses it requires; a repository +with none is given one that requires none. `judgeProtectionChange` refuses, for `main`, a change that enables +push, drops the merge whitelist or widens it past that account. diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index bb8de0d2..e0b80364 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -5,7 +5,7 @@ import { readFileSync } from "node:fs"; import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js"; -import { protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js"; +import { MAIN_BRANCH, mainRuleBody, protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js"; import { Refused, checkSentPath, pathSegment, pathSegments } from "./guard.js"; import { isCommit } from "./checks.js"; import { TOOLS_ACCOUNT } from "./tools-account.js"; @@ -713,6 +713,9 @@ export class GiteaClient { enable_status_check: Boolean(b.enable_status_check), status_check_contexts: Array.isArray(b.status_check_contexts) ? b.status_check_contexts.map(String) : [], required_approvals: Number(b.required_approvals ?? 0), + // Not every forge version sends these on a branch; absent they stay absent, never read as "no whitelist". + ...(b.enable_merge_whitelist === undefined ? {} : { enable_merge_whitelist: Boolean(b.enable_merge_whitelist) }), + ...(Array.isArray(b.merge_whitelist_usernames) ? { merge_whitelist_usernames: b.merge_whitelist_usernames.map(String) } : {}), }; } @@ -721,7 +724,7 @@ export class GiteaClient { * asked — or created, refusing direct pushes unless asked otherwise. Answers the rule as it is now. */ async setBranchProtection(owner: string, repo: string, branch: string, want: ProtectionWanted): Promise<{ created: boolean; rule: BranchProtection }> { const had = await this.branchProtection(owner, repo, branch); - const body = protectionBody(want, !had); + const body = protectionBody(want, !had, branch); if (had) { const rule = await this.request(`${repoPath(owner, repo)}/branch_protections/${pathSegments("branch", branch)}`, { method: "PATCH", body: JSON.stringify(body) }); @@ -988,6 +991,39 @@ export class GiteaAdmin { return Array.isArray(res.body) ? res.body : []; } + /** Put main's rule right in every repository of an organisation (novox/hq ADR 0398 §2, issue 948): no push, and + * only the tools' account merges. The rule there is edited, its statuses left as they are; a repository with no + * rule for main gets one that requires none. Answers the repositories it touched. With the administrator's login: + * the tools' account is no repository's administrator, so no tool can do this (ADR 0365 §2). */ + async ensureMainMerges(org: string): Promise { + const repos: string[] = []; + for (let page = 1; page <= 200; page++) { + const res = await this.request(`/orgs/${encodeURIComponent(org)}/repos?limit=50&page=${page}`); + if (res.status !== 200) GiteaAdmin.fail(`/orgs/${org}/repos`, res); + const list = Array.isArray(res.body) ? res.body : []; + for (const r of list) if (r?.name) repos.push(String(r.name)); + if (list.length < 50) break; + } + for (const repo of repos) { + 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; + } + GiteaAdmin.fail(`${repoPath(org, repo)}/branch_protections`, made); + } + return repos; + } + /** Ensure the npm-owner org exists. 201 created, 2xx/404-then-created, and 422/409 (a concurrent * create won the race) are all success. */ async ensureOrg(name: string): Promise { diff --git a/modules/gitea/guard.ts b/modules/gitea/guard.ts index daee4265..fb7a31ee 100644 --- a/modules/gitea/guard.ts +++ b/modules/gitea/guard.ts @@ -17,7 +17,8 @@ // // Pure, so it is tested without a forge; the tools do the asking (tools/index.ts). -import type { BranchProtection, ProtectionWanted } from "./protection.js"; +import { MAIN_BRANCH, type BranchProtection, type ProtectionWanted } from "./protection.js"; +import { TOOLS_ACCOUNT } from "./tools-account.js"; /** A refusal of policy, in the words the caller sees. Never an error of the forge's. */ export class Refused extends Error { @@ -287,7 +288,10 @@ export function judgeContentsWrite(target: string, defaultBranch: string, rules: * override allowed. */ export function judgeProtectionChange(before: BranchProtection | null, want: ProtectionWanted, - covering: BranchProtection[] = []): string | null { + covering: BranchProtection[] = [], branch = ""): string | null { + const isMain = branch === MAIN_BRANCH || (before !== null && (before.rule_name ?? before.branch_name) === MAIN_BRANCH); + const main = isMain ? judgeMain(before, want) : null; + if (main) return main; if (!before) { for (const glob of covering) { const weaker = judgeProtectionChange(glob, { @@ -315,6 +319,24 @@ export function judgeProtectionChange(before: BranchProtection | null, want: Pro `operator's, at the forge as themselves. Nothing was changed`; } +/** Main's rule takes no push and lets only the tools' account merge (novox/hq ADR 0398 §2, issue 948): a change + * that enables push, drops the merge whitelist or widens it past that account is refused, whatever the rule was. */ +function judgeMain(before: BranchProtection | null, want: ProtectionWanted): string | null { + const why: string[] = []; + if (want.push === true) why.push("it lets a person push to main directly"); + if (want.mergeWhitelist !== undefined) { + const names = want.mergeWhitelist ?? []; + if (names.length === 0) why.push("it drops the merge whitelist of main"); + else if (names.some((n) => n !== TOOLS_ACCOUNT)) { + why.push(`it widens who may merge into main beyond ${TOOLS_ACCOUNT}`); + } + } + if (!why.length) return null; + return `the protection of ${before?.rule_name ?? before?.branch_name ?? "main"} is only made stronger through a tool: ` + + `${why.join("; ")}. Main takes no push, and only ${TOOLS_ACCOUNT} merges into it (novox/hq ADR 0398 §2) — ` + + `weakening that is the operator's, at the forge as themselves. Nothing was changed`; +} + /** The token's own user's repositories, untouched for this long, are the only ones a tool deletes. */ export const DELETABLE_AFTER_DAYS = 30; diff --git a/modules/gitea/protection.ts b/modules/gitea/protection.ts index 5751c2c7..923d10c1 100644 --- a/modules/gitea/protection.ts +++ b/modules/gitea/protection.ts @@ -2,6 +2,11 @@ // `mesh/merge-gate`, the module graph's gate, and `mesh/repo-check`, the repository's own tests — before it // may merge. Pure, so it is tested without a forge; the client does the asking (client.ts). +import { TOOLS_ACCOUNT } from "./tools-account.js"; + +/** The branch whose rule takes no push and lets only the tools' account merge (novox/hq ADR 0398 §2, issue 948). */ +export const MAIN_BRANCH = "main"; + /** A branch protection rule, as the forge keeps it — the fields the mesh reads; the forge sends more. */ export interface BranchProtection { rule_name?: string; @@ -11,6 +16,8 @@ export interface BranchProtection { status_check_contexts?: string[] | null; required_approvals?: number; block_admin_merge_override?: boolean; + enable_merge_whitelist?: boolean; + merge_whitelist_usernames?: string[] | null; [field: string]: unknown; } @@ -23,15 +30,24 @@ export interface ProtectionWanted { push?: boolean; /** Whether an administrator is stopped from merging past a status that has not succeeded. */ blockAdminOverride?: boolean; + /** Who may merge into the branch; for main only ever the tools' account (ADR 0398 §2). Not given: left as it is. + * Empty or null asks for no whitelist, which the guard refuses for main; the body for main ignores it. */ + mergeWhitelist?: string[] | null; +} + +/** What main's rule always says: no push, and only the tools' account merges (novox/hq ADR 0398 §2, issue 948). */ +export function mainRuleBody(): Record { + return { enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: [TOOLS_ACCOUNT] }; } /** The forge's body for a wanted protection. */ -export function protectionBody(want: ProtectionWanted, creating: boolean): Record { +export function protectionBody(want: ProtectionWanted, creating: boolean, branch = ""): Record { const checks = [...new Set(want.statusChecks.map((c) => c.trim()).filter(Boolean))]; const body: Record = { enable_status_check: checks.length > 0, status_check_contexts: checks }; if (want.push !== undefined) body.enable_push = want.push; else if (creating) body.enable_push = false; if (want.blockAdminOverride !== undefined) body.block_admin_merge_override = want.blockAdminOverride; + if (branch === MAIN_BRANCH) Object.assign(body, mainRuleBody()); // whatever was asked: main takes no push (ADR 0398 §2) return body; } diff --git a/modules/gitea/test/guard.test.ts b/modules/gitea/test/guard.test.ts index 034bf238..fda32d5d 100644 --- a/modules/gitea/test/guard.test.ts +++ b/modules/gitea/test/guard.test.ts @@ -491,6 +491,24 @@ test("the judges, without a forge", () => { assert.equal(judgeProtectionChange(protectedMain(), { statusChecks: ["mesh/repo-check", "mesh/merge-gate"] }), null); }); +test("main's protection is never weakened in its push or its merge whitelist (issue 948, ADR 0398 §2)", () => { + const strong = () => ({ ...protectedMain(), enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"] }); + const checks = ["mesh/merge-gate", "mesh/repo-check"]; + assert.equal(judgeProtectionChange(strong(), { statusChecks: checks }, [], "main"), null); + assert.equal(judgeProtectionChange(strong(), { statusChecks: checks, mergeWhitelist: ["mesh-tools"] }, [], "main"), null); + assert.match(judgeProtectionChange(strong(), { statusChecks: checks, mergeWhitelist: [] }, [], "main") ?? "", /merge whitelist/); + assert.match(judgeProtectionChange(strong(), { statusChecks: checks, mergeWhitelist: null }, [], "main") ?? "", /merge whitelist/); + assert.match(judgeProtectionChange(strong(), { statusChecks: checks, mergeWhitelist: ["mesh-tools", "agent-ace"] }, [], "main") ?? "", /widens/); + assert.match(judgeProtectionChange(strong(), { statusChecks: checks, mergeWhitelist: ["agent-ace"] }, [], "main") ?? "", /widens/); + assert.match(judgeProtectionChange(strong(), { statusChecks: checks, push: true }, [], "main") ?? "", /push/); + // Even a rule that lets a person push already, or has no rule yet, is not given a push through a tool. + assert.match(judgeProtectionChange({ ...strong(), enable_push: true }, { statusChecks: checks, push: true }, [], "main") ?? "", /push/); + assert.match(judgeProtectionChange(null, { statusChecks: checks, push: true }, [], "main") ?? "", /push/); + assert.match(judgeProtectionChange(null, { statusChecks: checks, mergeWhitelist: ["agent-ace"] }, [], "main") ?? "", /widens/); + // Another branch is judged as before. + assert.equal(judgeProtectionChange(null, { statusChecks: checks, push: true }, [], "feat/x"), null); +}); + test("gitea_api never acts as another user: sudo, in any case, and any query key it does not send are refused", async () => { for (const path of ["/user?sudo=jschoubben", "/user?SUDO=jschoubben", "/repos/novox/mesh-catalog/issues?state=open&Sudo=jschoubben", "/repos/novox/mesh-catalog/issues?token=abc", "/repos/novox/mesh-catalog/issues?access_token=abc"]) { diff --git a/modules/gitea/test/protection.test.ts b/modules/gitea/test/protection.test.ts index 9dc122de..d75422d7 100644 --- a/modules/gitea/test/protection.test.ts +++ b/modules/gitea/test/protection.test.ts @@ -5,7 +5,7 @@ import { test } from "node:test"; // mesh's merge check before it merges. test("the statuses asked for replace the rule's, and an empty list requires none", async () => { - const { protectionBody } = await import("../protection.ts"); + const { protectionBody } = await import("../dist/protection.js"); assert.deepEqual(protectionBody({ statusChecks: ["mesh/merge-gate", " mesh/repo-check", "", "mesh/merge-gate"] }, false), { enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] }, "an edited rule keeps its pushes as they are, and each status once"); @@ -13,9 +13,37 @@ test("the statuses asked for replace the rule's, and an empty list requires none }); test("a new rule refuses direct pushes unless asked; an administrator's override only when said", async () => { - const { protectionBody } = await import("../protection.ts"); + const { protectionBody } = await import("../dist/protection.js"); assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"] }, true).enable_push, false); assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"], push: true }, true).enable_push, true); assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"] }, true).block_admin_merge_override, undefined); assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"], blockAdminOverride: true }, false).block_admin_merge_override, true); }); + +// Issue 948 (novox/hq ADR 0398 §2): main takes no push, and only the tools' account merges. +test("main's body always refuses pushes and lets only the tools' account merge, whatever is asked", async () => { + const { protectionBody } = await import("../dist/protection.js"); + const { TOOLS_ACCOUNT } = await import("../dist/tools-account.js"); + for (const creating of [true, false]) { + for (const want of [{ statusChecks: ["mesh/merge-gate"] }, { statusChecks: [], push: true }, { statusChecks: [], push: false }]) { + const body = protectionBody(want, creating, "main"); + assert.equal(body.enable_push, false, "main takes no push, an edit and a push=true included"); + assert.equal(body.enable_merge_whitelist, true); + assert.deepEqual(body.merge_whitelist_usernames, [TOOLS_ACCOUNT]); + } + } + assert.deepEqual(TOOLS_ACCOUNT, "mesh-tools"); +}); + +test("another branch's body carries no merge whitelist", async () => { + const { protectionBody } = await import("../dist/protection.js"); + const body = protectionBody({ statusChecks: ["mesh/merge-gate"] }, false, "release/1"); + assert.equal(body.enable_merge_whitelist, undefined); + assert.equal(body.merge_whitelist_usernames, undefined); + assert.equal(protectionBody({ statusChecks: [] }, false).enable_merge_whitelist, undefined); +}); + +test("the rule main is put right in the forge's own words: the body a setup sends carries no status checks of its own", async () => { + const { mainRuleBody } = await import("../dist/protection.js"); + assert.deepEqual(mainRuleBody(), { enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"] }); +}); diff --git a/modules/gitea/test/token.test.ts b/modules/gitea/test/token.test.ts index d72202f2..1df14e36 100644 --- a/modules/gitea/test/token.test.ts +++ b/modules/gitea/test/token.test.ts @@ -204,6 +204,8 @@ function fakeForge(): Promise { if (req.method === "GET" && p === "/api/v1/orgs/novox/teams") return json(res, 200, [{ id: 7, name: "mesh-tools" }]); if (req.method === "PATCH" && p === "/api/v1/teams/7") return json(res, 200, { id: 7 }); if (req.method === "PUT" && p === "/api/v1/teams/7/members/mesh-tools") return json(res, 204, null); + // Main's rule put right in every repository (issue 948): this forge has none. + if (req.method === "GET" && p === "/api/v1/orgs/novox/repos") return json(res, 200, []); } const adminUser = url.pathname.match(/^\/api\/v1\/admin\/users\/([^/]+)$/); if (adminUser && req.method === "PATCH") { diff --git a/modules/gitea/test/tools-account.test.ts b/modules/gitea/test/tools-account.test.ts index 6941a13a..02c947ad 100644 --- a/modules/gitea/test/tools-account.test.ts +++ b/modules/gitea/test/tools-account.test.ts @@ -31,6 +31,10 @@ function fakeForge() { const tokens: Token[] = []; const teams: Team[] = []; 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 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). const faults = { mint: false, deleteToken: false, teamRace: false }; @@ -131,6 +135,10 @@ function fakeForge() { if (req.method === "PATCH") { t.units = await read(req); return json(res, 200, { id: t.id }); } if (req.method === "PUT" && m[2]) { t.members.add(decodeURIComponent(m[2])); return json(res, 204, null); } } + if (req.method === "GET" && p === "/orgs/novox/repos") { + if (!isAdmin(me)) return json(res, 403, { message: "must be an owner of the organisation" }); + return json(res, 200, url.searchParams.get("page") === "1" ? repos.map((name) => ({ name })) : []); + } // Repository administration: a site administrator, or a team member whose team administers the repository. m = p.match(/^\/repos\/novox\/([^/]+)\/branches\/(.+)$/); if (m && req.method === "GET") { @@ -140,10 +148,21 @@ function fakeForge() { return json(res, 404, { message: "branch does not exist" }); } // gitea's reqAdmin on the whole group: reading the rules is administration too. - m = p.match(/^\/repos\/novox\/([^/]+)\/branch_protections(?:\/.*)?$/); + m = p.match(/^\/repos\/novox\/([^/]+)\/branch_protections(?:\/(.*))?$/); 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 === "PATCH" && m[2] === "main") { + const rule = mainRules.get(m[1]); + if (!rule) return json(res, 404, { message: "branch protection does not exist" }); + Object.assign(rule, await read(req)); + return json(res, 200, rule); + } + if (req.method === "POST" && p.endsWith("/branch_protections")) { + const b = await read(req); + mainRules.set(m[1], b); + return json(res, 201, b); + } return json(res, 201, { rule_name: "main" }); } m = p.match(/^\/repos\/novox\/([^/]+)\/statuses\/([0-9a-f]+)$/); @@ -155,11 +174,11 @@ function fakeForge() { if (req.method === "GET" && p === "/repos/search") return json(res, 200, { data: [] }); return json(res, 404, { message: "no such route in the fake" }); }); - return new Promise<{ url: string; users: typeof users; tokens: typeof tokens; teams: typeof teams; calls: string[]; + return new Promise<{ url: string; users: typeof users; tokens: typeof tokens; teams: typeof teams; calls: string[]; mainRules: typeof mainRules; faults: typeof faults; close(): Promise }>((resolve) => { server.listen(0, "127.0.0.1", () => { const { port } = server.address() as { port: number }; - resolve({ url: `http://127.0.0.1:${port}`, users, tokens, teams, calls, faults, + resolve({ url: `http://127.0.0.1:${port}`, users, tokens, teams, calls, faults, mainRules, close: () => new Promise((r) => server.close(() => r())) }); }); }); @@ -330,3 +349,21 @@ test("a team another process made between the look and the create is found, not const team = forge.teams.find((t) => t.name === "mesh-tools"); assert.ok(team?.members.has(TOOLS_ACCOUNT)); }); + +test("setting the tools' account up puts main's rule right in every repository: no push, only the tools' account merges (issue 948)", async () => { + const env = await environment(); + const from = forge.calls.length; + await fresh(env).listRepos(); + const hq = forge.mainRules.get("hq"); + assert.equal(hq.enable_push, false); + assert.equal(hq.enable_merge_whitelist, true); + assert.deepEqual(hq.merge_whitelist_usernames, [TOOLS_ACCOUNT]); + assert.deepEqual(hq.status_check_contexts, ["mesh/merge-gate"], "a rule found keeps the statuses it requires"); + const catalog = forge.mainRules.get("mesh-catalog"); + assert.ok(catalog, "a repository with no rule for main is given one"); + assert.equal(catalog.rule_name, "main"); + assert.equal(catalog.enable_push, false); + assert.deepEqual(catalog.merge_whitelist_usernames, [TOOLS_ACCOUNT]); + 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"); +}); diff --git a/modules/gitea/tools-account.ts b/modules/gitea/tools-account.ts index 03df8e32..087e49d0 100644 --- a/modules/gitea/tools-account.ts +++ b/modules/gitea/tools-account.ts @@ -41,6 +41,8 @@ export interface ToolsForge { ensureUser(username: string, password: string, email: string, plain?: boolean): Promise; ensureTeamWith(org: string, team: string, units: Record): Promise; addUserToTeam(teamId: number, username: string): Promise; + /** Main's rule in every repository of the organisation: no push, only the tools' account merges (ADR 0398 §2). */ + ensureMainMerges?(org: string): Promise; } /** Make the tools' account, plain, a member of its team on every repository of the organisation. Its password is @@ -49,4 +51,7 @@ export async function setUpToolsAccount(forge: ToolsForge, account = TOOLS_ACCOU await forge.ensureUser(account, password(), `${account}@noreply.localhost`, true); const team = await forge.ensureTeamWith(TOOLS_ORG, TOOLS_TEAM, { name: TOOLS_TEAM, ...TOOLS_TEAM_UNITS }); await forge.addUserToTeam(team, account); + // Main takes no push and only this account merges into it (novox/hq ADR 0398 §2, issue 948): put right in every + // repository now, by the administrator's login, which the account itself is not. + await forge.ensureMainMerges?.(TOOLS_ORG); } diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 27a8c610..6914c412 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -32,6 +32,7 @@ export function summarised(p: BranchProtection) { required_statuses: p.enable_status_check ? (p.status_check_contexts ?? []) : [], required_approvals: p.required_approvals ?? 0, admin_may_override: !(p.block_admin_merge_override ?? false), + merge_whitelist: p.enable_merge_whitelist ? (p.merge_whitelist_usernames ?? []) : null, }; } @@ -411,7 +412,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini }, { name: "gitea_branch_protection_set", - description: "Only ever stronger (novox/hq ADR 0266): a change that drops a required status, lets a person push directly or lets an administrator merge past a status is refused — that is the operator's, at the forge. Make a branch require commit statuses before a pull request merges into it — the mesh's merge check sets `mesh/merge-gate` (the module graph's gate) and `mesh/repo-check` (the repository's own tests). Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.", + description: "Only ever stronger (novox/hq ADR 0266): a change that drops a required status, lets a person push directly or lets an administrator merge past a status is refused, and on main so is a change that drops the merge whitelist (only mesh-tools merges there) or widens it (novox/hq ADR 0398 §2, issue 948) — that is the operator's, at the forge. Make a branch require commit statuses before a pull request merges into it — the mesh's merge check sets `mesh/merge-gate` (the module graph's gate) and `mesh/repo-check` (the repository's own tests). Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, @@ -433,7 +434,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini }; const covering = before ? [] : (await gitea.branchProtections(owner, repo)) .filter((r) => ruleCovers(String(r.rule_name ?? r.branch_name ?? ""), branch)); - const weakens = judgeProtectionChange(before, want, covering); + const weakens = judgeProtectionChange(before, want, covering, branch); if (weakens) throw new Refused(weakens); const { created, rule } = await gitea.setBranchProtection(owner, repo, branch, want); return { branch, created, before: before ? summarised(before) : null, after: summarised(rule) };