gitea: the merging account writes every repository that builds a registered module, read from the indexer's modules (issue 1945)

car-hunter #2 was approved and its commit status refused 403: mesh-tools only read the
repositories of a user. It is now a write collaborator on each repository the indexer's
modules state names (watched over the bus: at the start and on each registration), read
back and said as gitea.repo.writable; the operator's other repositories stay read-only.
gitea_grant_write and gitea_unwritable_repos do and show it by hand; gitea_commit_statuses
answers merge.writes.
This commit is contained in:
2026-10-26 16:36:50 +01:00
parent 6b6d5cba71
commit 52de4c320e
7 changed files with 382 additions and 8 deletions
+68 -1
View File
@@ -206,7 +206,10 @@ function fakeForge() {
if (m) {
const repo = forgeRepos.find((r) => r.owner === decodeURIComponent(m![1]) && r.name === decodeURIComponent(m![2]));
if (!repo || permissionOf(me, repo) === "none") return json(res, 404, { message: "repository does not exist" });
if (req.method === "GET" && !m[3]) return json(res, 200, repoJSON(repo));
if (req.method === "GET" && !m[3]) {
const mine = permissionOf(me, repo);
return json(res, 200, { ...repoJSON(repo), permissions: { pull: true, push: ["write", "admin", "owner"].includes(mine), admin: ["admin", "owner"].includes(mine) } });
}
const user = decodeURIComponent(m[3] ?? "");
if (req.method === "GET" && m[4]) {
if (repoFaults.permission.has(repo.full)) return json(res, 500, { message: "the forge is not well" });
@@ -576,3 +579,67 @@ test("on the forge's events: a repository made is granted read at once, a known
repoFaults.refuse.clear();
repoFaults.permission.clear();
});
// The merging account writes every repository that builds a registered module, and no other (writes.ts; the operator,
// 2026-10-26; issue 1945): car-hunter #2 was approved and its commit status refused 403, mesh-tools reading only there.
test("the merging account is given write on each repository the indexer's modules name, read back and said; the operator's other repositories stay read-only; the tools say both", async () => {
const env = await environment();
const client = fresh(env);
await client.listRepos(); // the account and its team exist, as after any mint
forgeRepos.splice(0);
addRepo("novox/hq", true);
for (const full of ["jschoubben/car-hunter", "jschoubben/snake", "jschoubben/dotfiles"]) addRepo(full, true).collaborators.set(TOOLS_ACCOUNT, "read");
const { GiteaAdmin } = await import("../dist/client.js");
const { DeliveredWrites, writeTools, WRITABLE_EVENT } = await import("../dist/writes.js");
const lines: string[] = [];
const said: { type: string; body: any }[] = [];
const writes = new DeliveredWrites(GiteaAdmin.fromEnv(env), TOOLS_ACCOUNT, "novox", (l: string) => lines.push(l),
async (type: string, body: unknown) => { said.push({ type, body }); });
const [unwritableTool, grantTool] = writeTools(() => writes);
await assert.rejects(grantTool.run({}), /not known yet/, "nothing is granted before the indexer's modules were read");
assert.equal((await client.getRepo("jschoubben", "car-hunter")).writes, false, "the statuses' answer says the account cannot write");
// 1945/AC1: the watch hands the indexer's modules; a user's repository a module builds from is made writable, once.
await writes.take({ key: "car-hunter", op: "put", value: { v: 1, repository: "jschoubben/car-hunter", commit: "c" }, current: true });
await writes.take({ key: "gitea", op: "put", value: { v: 1, repository: "novox/hq", commit: "c" }, current: true });
await writes.take({ key: "broken", op: "put", value: { v: 1, repository: "no-slash" }, current: true });
const from = forge.calls.length;
await writes.take({ key: "car-hunter-2", op: "put", value: { v: 1, repository: "jschoubben/car-hunter" } });
assert.equal(forge.calls.length, from, "a repository already written asks nothing more");
const repo = (full: string) => forgeRepos.find((x) => x.full === full)!;
assert.equal(repo("jschoubben/car-hunter").collaborators.get(TOOLS_ACCOUNT), "write");
assert.equal(repo("novox/hq").collaborators.size, 0, "the organisation's repository is written through its team, no collaborator");
assert.equal((await client.getRepo("jschoubben", "car-hunter")).writes, true);
assert.deepEqual(said, [{ type: WRITABLE_EVENT, body: { owner: "jschoubben", repo: "car-hunter", account: TOOLS_ACCOUNT } }]);
assert.match(lines.join("\n"), /mesh-tools was given write on jschoubben\/car-hunter, which builds the registered module car-hunter/);
assert.match(lines.join("\n"), /broken names no repository/);
// 1945/AC2: a delivered repository the forge refuses is said, in its words, by the listing and the grant; the operator's
// repository no module builds from is left read-only, named with why, and a grant naming it asks the forge nothing.
repoFaults.refuse.add("jschoubben/snake");
await writes.take({ key: "snake", op: "put", value: { v: 1, repository: "jschoubben/snake" } });
assert.match(lines.join("\n"), /could not be given write on jschoubben\/snake, which builds snake — .*422/);
const listed = await unwritableTool.run({}) as any;
assert.deepEqual(listed.unwritable.map((u: any) => [u.repository, u.permission]), [["jschoubben/snake", "read"]]);
assert.match(listed.unwritable[0].why, /builds snake.*gitea_grant_write/);
const all = await grantTool.run({}) as any;
assert.deepEqual(all.delivered, ["jschoubben/car-hunter", "jschoubben/snake", "novox/hq"]);
assert.deepEqual(all.already, ["jschoubben/car-hunter", "novox/hq"]);
assert.deepEqual(all.granted, []);
assert.match(all.refused.find((x: any) => x.repository === "jschoubben/snake").why, /422.*would not have it/);
assert.deepEqual(all.left_read_only.map((x: any) => x.repository), ["jschoubben/dotfiles"]);
assert.match(all.left_read_only[0].why, /builds no registered module/);
const puts = forge.calls.length;
const named = await grantTool.run({ repositories: ["jschoubben/dotfiles"] }) as any;
assert.deepEqual([named.granted, named.left_read_only.map((x: any) => x.repository)], [[], ["jschoubben/dotfiles"]]);
assert.ok(!forge.calls.slice(puts).some((c) => c.startsWith("PUT ")), "no write is asked on a repository no module builds from");
assert.equal(repo("jschoubben/dotfiles").collaborators.get(TOOLS_ACCOUNT), "read");
repoFaults.refuse.clear();
const after = await grantTool.run({ repositories: ["jschoubben/snake"] }) as any;
assert.deepEqual(after.granted, ["jschoubben/snake"]);
assert.deepEqual(((await unwritableTool.run({})) as any).unwritable, []);
// A module gone from the indexer leaves its repository out of the delivered list.
await writes.take({ key: "snake", op: "delete" });
assert.deepEqual(writes.delivered(), ["jschoubben/car-hunter", "novox/hq"]);
});