gitea: mesh-tools writes a user's delivered repository and main's rule stays the operator's: only its merge whitelist gets mesh-tools, read back; what else blocks the mesh's merge is said (issue 1945, the operator's answer 2026-10-26 18:30)

This commit is contained in:
2026-10-26 18:34:03 +01:00
parent c85472285d
commit e1e5a8ff0d
3 changed files with 124 additions and 85 deletions
+31 -37
View File
@@ -6,7 +6,7 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
import { MAIN_BRANCH, mainMergers, mainRuleBody, protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js";
import { MAIN_BRANCH, mainRuleBody, protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js";
import { Refused, checkSentPath, ruleCovers, pathSegment, pathSegments } from "./guard.js";
import { isCommit } from "./checks.js";
@@ -1074,45 +1074,39 @@ export class GiteaAdmin {
await this.addCollaborator(owner, repo, user, "write");
}
/** Make an account an administrator collaborator of a repository (writes.ts: a repository of a user that builds a
* registered module, where the tools' account must also put main's rule right). */
async addAdminCollaborator(owner: string, repo: string, user: string): Promise<void> {
await this.addCollaborator(owner, repo, user, "admin");
}
/** Put main's rule right in one repository: no push, the merge whitelist (the tools' account first), no status required,
* and no administrator override. A rule the operator already set that is stronger is kept, never made weaker (ADR 0266):
* a whitelist that holds the tools' account and only logins the wanted one names, more approvals, a push already refused.
* Answers the rule as the forge holds it after, read back. With the administrator's login. */
async ensureMainRule(owner: string, repo: string, mergers: string[] = mainMergers()): Promise<{ created: boolean; rule: BranchProtection }> {
/** The rule that applies to main in a repository, as its administrator reads it: a rule named main, else the first glob
* rule covering main; null when none does. With the administrator's login. */
async mainRule(owner: string, repo: string): Promise<BranchProtection | null> {
const rules = await this.branchProtections(owner, repo);
const own = rules.find((r) => (r.rule_name ?? r.branch_name) === MAIN_BRANCH);
const kept = own?.enable_merge_whitelist === true ? (own.merge_whitelist_usernames ?? []) : [];
const whitelist = kept.length > 0 && kept.includes(mergers[0]) && kept.every((u) => mergers.includes(u)) ? kept : mergers;
const body = { ...mainRuleBody(mergers), merge_whitelist_usernames: whitelist, block_admin_merge_override: true };
const path = `${repoPath(owner, repo)}/branch_protections`;
if (own) {
const res = await this.request(`${path}/${MAIN_BRANCH}`, { method: "PATCH", body: JSON.stringify(body) });
if (res.status !== 200) GiteaAdmin.fail(`${path}/${MAIN_BRANCH}`, res);
} else {
const covering = rules.filter((r) => ruleCovers(String(r.rule_name ?? r.branch_name ?? ""), MAIN_BRANCH));
const approvals = Math.max(0, ...covering.map((r) => Number(r.required_approvals ?? 0)));
const res = await this.request(path, { method: "POST", body: JSON.stringify({ rule_name: MAIN_BRANCH, ...(approvals > 0 ? { required_approvals: approvals } : {}), ...body }) });
if (res.status !== 201) GiteaAdmin.fail(path, res);
}
const after = (await this.branchProtections(owner, repo)).find((r) => (r.rule_name ?? r.branch_name) === MAIN_BRANCH);
if (!after) throw new Error(`main's rule of ${owner}/${repo} was written, but the forge does not list it`);
// The forge answers a write it did not keep: it drops a whitelist login that cannot write the repository, silently.
const listed = after.merge_whitelist_usernames ?? [];
const wrong: string[] = [];
if (after.enable_push !== false) wrong.push(`push is ${after.enable_push === true ? "allowed" : "not refused"}`);
if (after.enable_merge_whitelist !== true || !listed.includes(mergers[0])) wrong.push(`the merge whitelist [${listed.join(", ")}] leaves out ${mergers[0]}`);
if (after.enable_status_check !== false) wrong.push("a status is still required");
if (wrong.length > 0) throw new Error(`main's rule of ${owner}/${repo} was written, but the forge holds otherwise: ${wrong.join("; ")}`);
return { created: !own, rule: after };
return rules.find((r) => (r.rule_name ?? r.branch_name) === MAIN_BRANCH)
?? rules.find((r) => ruleCovers(String(r.rule_name ?? r.branch_name ?? ""), MAIN_BRANCH)) ?? null;
}
private async addCollaborator(owner: string, repo: string, user: string, permission: "read" | "write" | "admin"): Promise<void> {
/** Put an account on main's merge whitelist in a repository of a user, and nothing else (the operator, 2026-10-26:
* write, the rule untouched). Only an existing rule whose merge whitelist is on is changed, and only its whitelist: the
* account is added to the logins already there. Push, status checks, approvals and the administrator override stay as
* the operator set them; with no rule, or the whitelist off, nothing is written (whoever writes may merge). Read back:
* the forge drops a whitelist login that cannot write the repository and still answers success, so a whitelist read back
* without the account fails loud. Answers the rule as the forge holds it after. With the administrator's login. */
async ensureMergerListed(owner: string, repo: string, account: string): Promise<{ changed: boolean; rule: BranchProtection | null }> {
const rule = await this.mainRule(owner, repo);
const listed = rule?.merge_whitelist_usernames ?? [];
if (!rule || rule.enable_merge_whitelist !== true || listed.includes(account)) return { changed: false, rule };
const name = String(rule.rule_name ?? rule.branch_name);
const path = `${repoPath(owner, repo)}/branch_protections/${encodeURIComponent(name)}`;
const res = await this.request(path, { method: "PATCH", body: JSON.stringify({ merge_whitelist_usernames: [...listed, account] }) });
if (res.status !== 200) GiteaAdmin.fail(path, res);
const after = (await this.branchProtections(owner, repo)).find((r) => (r.rule_name ?? r.branch_name) === name);
if (!after) throw new Error(`main's rule ${name} of ${owner}/${repo} was written, but the forge does not list it`);
const held = after.merge_whitelist_usernames ?? [];
if (after.enable_merge_whitelist === true && !held.includes(account)) {
throw new Error(`${account} was added to the merge whitelist of main's rule ${name} of ${owner}/${repo}, but the forge ` +
`holds [${held.join(", ")}]: it drops a login that cannot write the repository`);
}
return { changed: true, rule: after };
}
private async addCollaborator(owner: string, repo: string, user: string, permission: "read" | "write"): Promise<void> {
const path = `${repoPath(owner, repo)}/collaborators/${encodeURIComponent(user)}`;
const res = await this.request(path, { method: "PUT", body: JSON.stringify({ permission }) });
if (res.status === 204 || res.status === 200) return;