Keep main's rule right at every start and copy a covering glob's checks (issue 948, review round 1)
This commit is contained in:
+19
-14
@@ -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<string, unknown> = { 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;
|
||||
}
|
||||
|
||||
@@ -530,6 +530,7 @@ async function listenForForge(client: GiteaClient): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
@@ -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<string, any[]>([["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<string, any>([["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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user