Files
jschoubben 32655b95f6 GIT1, GIT2, GIT3, GIT10: the forge module serves the seat git's verbs and says its events, retires its acting tools, follows the catalog half
The four share files (index.ts, module.json, guard.test.ts), so they land as one commit.
GIT1: seat.ts serves pull, open-pulls, tags, trees, file, contains, statuses, status, comment, view, note, merge-pull
as tools on the seat, each answering the contract's shape and an error of kind refused, not-found or failed; a comment,
view or note whose act id is held is answered held (note matched by id). merge-pull merges the head the caller names
(its from-G judgements are GIT6, GIT7, GIT9).
GIT2: seatevents.ts says pull-updated, pull-merged, pull-closed, forge-pull, forge-status, forge-delete and repo-writable on
mesh.seat.git.event.<event>; the module's emits drop them.
GIT3: merge.ts, check.ts, the acting tools, the owed watch, the gate's source read and check-gates-merge are gone.
GIT10: catalog.ts follows the seat mesh-catalog (revision, catalog-revision); no write is granted before the first view.

Builds: design 47 G §310, §311, §312, §313, §314, §163, §317, §278, §321, §196

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 11:31:24 +01:00

335 lines
20 KiB
TypeScript

// The account that sets commit statuses and merges writes every repository that builds a registered module, and no other
// (the operator, 2026-10-26: "yes the mesh should get write access", on the repositories the mesh delivers; novox/hq issue
// 1945). car-hunter #2 (a repository of a user, not of the organisation novox) was approved, and mesh-delivery's first
// act, its commit status, was refused 403: the tools' account was only a collaborator that reads there (readall.ts).
//
// **Which account.** The tools' account, `mesh-tools`: the seat git's acts (seat.ts) set every commit status and merge
// with its token. The build modules' `nox-builder` and the machines'
// `nox-agent` only fetch, and keep read (readall.ts).
//
// **Which repositories: the catalog half's word, read over the bus.** The seat mesh-catalog's trunk revision holds every
// registered module's entry, each naming the repository it builds from. This module asks it of the seat's verb `revision`
// (its manifest's `invokes`) and then hears the seat's event `catalog-revision` (`consumes`; catalog.ts, design 47): the
// first view hands every entry when the module starts, and each newer trunk revision what changed, so a repository is
// granted at the module's start and when a module of it is first registered. A candidate's revision moves nothing, and
// before the first view nothing is granted. Nothing polls and no holder's state is read. A repository of the organisation needs nothing: the organisation's team writes it (tools-account.ts). Every other
// repository of a user (the operator's dotfiles, say) is left as it is: read only.
//
// **Every grant is read back** from the forge (its permission for the account), and said on the bus as
// `repo.writable` {owner, repo, account}: mesh-delivery-gate, which does not ask the operator's Approve of a pull request
// whose merge the forge would refuse, reads that repository again on it.
//
// **A repository of a user is written, and main's rule is left as the operator set it** (the operator, 2026-10-26, asked
// "Write, rule untouched"): the account is a write collaborator there, and only added to main's merge whitelist when that
// rule keeps one (client.ts ensureMergerListed). What else in main's rule stops the mesh's merge (a status the mesh does not
// set, approvals the gate does not give) is changed by nobody but said: both tools list such a repository as writable, but
// blocked, with why (merge_blocked).
//
// Not taken away: a repository whose last module leaves the catalog keeps its collaborator until a hand removes it;
// gitea_unwritable_repos and gitea_grant_write say the delivered list as it is now.
import type { ForgeRepository } from "./client.js";
import type { BranchProtection } from "./protection.js";
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT } from "./pulls.js";
/** What the view is called in what this module says: the catalog half, through the seat mesh-catalog. */
const CATALOG_HALF = "the catalog half (the seat mesh-catalog's revision)";
/** The event said once an account writes a repository it could not write before. */
export const WRITABLE_EVENT = "repo.writable";
/** What the module asks of the forge for this, with the site administrator's login (GiteaAdmin, client.ts). */
export interface WriteForge {
everyRepository(): Promise<ForgeRepository[]>;
repository(owner: string, repo: string): Promise<ForgeRepository | null>;
repositoryPermission(owner: string, repo: string, user: string): Promise<string>;
addWriteCollaborator(owner: string, repo: string, user: string): Promise<void>;
mainRule(owner: string, repo: string): Promise<BranchProtection | null>;
ensureMergerListed(owner: string, repo: string, account: string): Promise<{ changed: boolean; rule: BranchProtection | null }>;
}
/** The statuses every pull request the mesh delivers carries (mesh-delivery's checks); a rule requiring another blocks. */
const MESH_CONTEXTS = new Set([CHECK_STATUS, CHECK_CONTEXT, REPO_CHECK_CONTEXT]);
/** What in main's rule stops the mesh's merge, in words; empty when nothing does. Changed by nobody here: the operator's. */
export function mergeBlocks(rule: BranchProtection | null, account: string): string[] {
if (!rule) return [];
const blocks: string[] = [];
if (rule.enable_merge_whitelist === true && !(rule.merge_whitelist_usernames ?? []).includes(account)) {
blocks.push(`its merge whitelist [${(rule.merge_whitelist_usernames ?? []).join(", ")}] leaves out ${account} (gitea_grant_write adds it)`);
}
if (rule.enable_status_check === true) {
for (const c of rule.status_check_contexts ?? []) {
if (!MESH_CONTEXTS.has(c)) blocks.push(`it requires the status ${c}, which the mesh does not set`);
}
}
const approvals = Number(rule.required_approvals ?? 0);
if (approvals > 0) blocks.push(`it requires ${approvals} approving review${approvals === 1 ? "" : "s"} on the forge, which the gate does not give`);
if (rule.require_signed_commits === true) blocks.push("it requires signed commits, which the mesh does not make");
if (rule.block_on_outdated_branch === true) blocks.push("it refuses a branch behind main, which the mesh does not bring up to date");
if (rule.block_on_rejected_reviews === true) blocks.push("it refuses a merge after a review asked for changes, which the gate does not clear");
return blocks;
}
const blockedWhy = (blocks: string[]): string => `writable, but main's rule blocks the mesh's merge: ${blocks.join("; ")}`;
/** One change of the catalog half's entries: a module, put with its entry or deleted. */
export interface ModulesChange {
key: string;
op: "put" | "delete";
value?: unknown;
}
export interface Written {
account: string;
/** The repositories that build a registered module, as the catalog half says now. */
delivered: string[];
/** Made writable now, each read back from the forge. */
granted: string[];
/** Written already (a collaborator of a user's repository), or written through the organisation's team. */
already: string[];
/** Each repository of a user: whether the account was added to main's merge whitelist now, and the whitelist read back
* (null: main's rule keeps no whitelist, or there is no rule, and whoever writes may merge). */
merge_whitelist: { repository: string; changed: boolean; listed: string[] | null }[];
/** Writable, but main's rule (the operator's, left as set) stops the mesh's merge, and why. */
merge_blocked: { repository: string; why: string }[];
/** The forge refused, in its own words; or the repository is the organisation's, whose team should write it. */
refused: { repository: string; why: string }[];
/** Repositories the account cannot write that are left so, and why: they build no registered module. */
left_read_only: { repository: string; permission: string; why: string }[];
}
const WRITES = new Set(["write", "admin", "owner"]);
const said = (err: unknown): string => (err instanceof Error ? err.message : String(err));
function split(full: string): [string, string] {
const [owner, name, ...rest] = String(full ?? "").split("/");
if (!owner || !name || rest.length) throw new Error(`"${full}" is not a repository: say owner/name`);
return [owner, name];
}
export class DeliveredWrites {
/** Module to the repository it builds from, as the catalog half says. */
readonly modules = new Map<string, string>();
/** Whether the first view of the catalog half is held: until then, which repositories are delivered is not known. */
heard = false;
/** Repositories seen written by the account in this process: a module registered again asks nothing more. */
readonly writable = new Set<string>();
constructor(private readonly forge: WriteForge, readonly account: string, readonly org: string,
private readonly log: (line: string) => void = (l) => console.log(`[gitea] ${l}`),
private readonly emit: (type: string, body: unknown) => Promise<void> = async () => {}) {}
/** The repositories that build a registered module, sorted. */
delivered(): string[] {
return [...new Set(this.modules.values())].sort();
}
/** The first view is held, its entries taken: from now on which repositories are delivered is known, even when the
* catalog lists none, and every one the account cannot write yet is granted now, in one pass. Never throws. */
async viewed(): Promise<void> {
if (this.heard) return;
this.heard = true;
try {
const r = await this.grant();
for (const g of r.granted) this.log(`${this.account} was given write on ${g}, which builds a registered module`);
for (const x of r.refused) this.log(`${this.account} could not be given write on ${x.repository} — ${x.why}`);
} catch (err) {
this.log(`which repositories ${this.account} writes could not be settled at the first view — ${said(err)}`);
}
}
/** One change of the catalog half: a repository newly delivered is made writable now. Never throws. Before the first
* view is held (viewed) nothing is granted: the change is kept, and the first view's changes follow it. */
async take(change: ModulesChange): Promise<void> {
if (change.op === "delete") {
this.modules.delete(change.key);
return;
}
const repository = String((change.value as { repository?: unknown } | undefined)?.repository ?? "").trim();
try {
split(repository);
} catch {
this.log(`${CATALOG_HALF}: ${change.key} names no repository (${JSON.stringify(repository)}): no write is granted for it`);
return;
}
this.modules.set(change.key, repository);
if (!this.heard || this.writable.has(repository)) return;
try {
const r = await this.grant([repository]);
for (const g of r.granted) this.log(`${this.account} was given write on ${g}, which builds the registered module ${change.key}`);
for (const x of r.refused) this.log(`${this.account} could not be given write on ${x.repository}, which builds ${change.key} — ${x.why}`);
} catch (err) {
this.log(`whether ${this.account} writes ${repository} (module ${change.key}) could not be settled — ${said(err)}`);
}
}
/** Grant write on every delivered repository the account cannot write (or on those named, each of which must be
* delivered); with none named, also say which repositories it cannot write are left so, and why. */
async grant(only?: string[]): Promise<Written> {
if (!this.heard) {
throw new Error(`which repositories build a registered module is not known yet: ${CATALOG_HALF} has no first view ` +
"held (not answered, or waited for); nothing is granted");
}
const delivered = this.delivered();
const result: Written = { account: this.account, delivered, granted: [], already: [], merge_whitelist: [], merge_blocked: [], refused: [], left_read_only: [] };
const named = only && only.length ? only.map((x) => String(x).trim()) : null;
const targets: string[] = [];
for (const full of named ?? delivered) {
split(full);
if (delivered.includes(full)) targets.push(full);
else result.left_read_only.push({ repository: full, permission: "unchanged",
why: "builds no registered module (the catalog half names none from it), so the account is given no write there" });
}
for (const full of targets) {
const [owner, name] = split(full);
let permission: string;
try {
if (!(await this.forge.repository(owner, name))) {
result.refused.push({ repository: full, why: "the forge holds no repository by that name" });
continue;
}
permission = await this.forge.repositoryPermission(owner, name, this.account);
} catch (err) {
result.refused.push({ repository: full, why: `its permission could not be read — ${said(err)}` });
continue;
}
if (owner === this.org) {
if (WRITES.has(permission)) {
this.writable.add(full);
result.already.push(full);
continue;
}
// The organisation's repositories are written through its team (tools-account.ts), never a collaborator.
result.refused.push({ repository: full, why: `a repository of ${this.org}, which ${this.account} writes through the ` +
`team ${this.org}/${this.account}; the forge gives it "${permission}", so the team is not as it should be (it is ` +
"set again at the next mint of the tools' token)" });
continue;
}
// A repository of a user: the account writes it, and is put on main's merge whitelist when the rule keeps one.
let changed = false;
if (!WRITES.has(permission)) {
try {
await this.forge.addWriteCollaborator(owner, name, this.account);
} catch (err) {
result.refused.push({ repository: full, why: said(err) });
continue;
}
let after: string;
try {
after = await this.forge.repositoryPermission(owner, name, this.account);
} catch (err) {
result.refused.push({ repository: full, why: `granted, but its permission could not be read back — ${said(err)}` });
continue;
}
if (!WRITES.has(after)) {
result.refused.push({ repository: full, why: `the grant was answered, but the forge still gives ${this.account} "${after}"` });
continue;
}
changed = true;
}
try {
const { changed: listedNow, rule } = await this.forge.ensureMergerListed(owner, name, this.account);
result.merge_whitelist.push({ repository: full, changed: listedNow,
listed: rule?.enable_merge_whitelist === true ? (rule.merge_whitelist_usernames ?? []) : null });
const blocks = mergeBlocks(rule, this.account);
if (blocks.length > 0) result.merge_blocked.push({ repository: full, why: blockedWhy(blocks) });
} catch (err) {
result.refused.push({ repository: full, why: `${this.account} writes it, but could not be put on main's merge whitelist — ${said(err)}` });
continue;
}
this.writable.add(full);
if (!changed) {
result.already.push(full);
continue;
}
result.granted.push(full);
await this.emit(WRITABLE_EVENT, { owner, repo: name, account: this.account }).catch((err) =>
this.log(`${this.account} writes ${full} now, and that could not be said on the bus — ${said(err)}`));
}
if (!named) {
for (const repo of await this.forge.everyRepository()) {
if (repo.owner === this.org || delivered.includes(repo.full_name)) continue;
let permission: string;
try {
permission = await this.forge.repositoryPermission(repo.owner, repo.name, this.account);
} catch (err) {
permission = `unknown — ${said(err)}`;
}
if (WRITES.has(permission)) continue;
result.left_read_only.push({ repository: repo.full_name, permission,
why: "builds no registered module (the catalog half names none from it), so the account is given no write there" });
}
}
return result;
}
/** Every delivered repository the account cannot write, as the forge says now, and why. */
async unwritable(): Promise<{ account: string; delivered: number; unwritable: { repository: string; permission: string; why: string }[];
merge_blocked: { repository: string; why: string }[] }> {
if (!this.heard) throw new Error(`which repositories build a registered module is not known yet: ${CATALOG_HALF} has no first view held`);
const delivered = this.delivered();
const out: { repository: string; permission: string; why: string }[] = [];
const blocked: { repository: string; why: string }[] = [];
for (const full of delivered) {
const [owner, name] = split(full);
let permission: string;
try {
permission = await this.forge.repositoryPermission(owner, name, this.account);
} catch (err) {
out.push({ repository: full, permission: "unknown", why: `the forge did not answer ${this.account}'s permission on it — ${said(err)}` });
continue;
}
if (WRITES.has(permission)) {
if (owner === this.org) continue; // the organisation's main rules are the mesh's own (mainrules.ts)
try {
const blocks = mergeBlocks(await this.forge.mainRule(owner, name), this.account);
if (blocks.length > 0) blocked.push({ repository: full, why: blockedWhy(blocks) });
} catch (err) {
blocked.push({ repository: full, why: `writable, but main's rule could not be read — ${said(err)}` });
}
continue;
}
const modules = [...this.modules].filter(([, r]) => r === full).map(([m]) => m).sort();
out.push({ repository: full, permission, why: `builds ${modules.join(", ")}, and the forge gives ${this.account} ` +
`"${permission}" on it: its commit status and its merge are refused until gitea_grant_write grants it` });
}
return { account: this.account, delivered: delivered.length, unwritable: out, merge_blocked: blocked };
}
}
type Tool = { name: string; description: string; input: Record<string, unknown>; run: (args: Readonly<Record<string, unknown>>) => Promise<unknown> };
/** The two tools: which delivered repositories the merging account cannot write, and the grant now. */
export function writeTools(writes: () => DeliveredWrites): Tool[] {
return [
{
name: "gitea_unwritable_repos",
description: "Every repository that builds a registered module (the catalog half's trunk revision, read over the bus) on which " +
"the mesh's merging forge account (mesh-tools, which sets commit statuses and merges) cannot write, with the " +
"permission the forge gives it there and why: such a pull request can be checked but never merged, and the " +
"delivery gate does not ask its Approve. Empty when it writes every one. Its merge_blocked lists each repository of " +
"a user it writes whose main's rule (the operator's, left as set) still stops the mesh's merge: a merge whitelist " +
"without it, a required status the mesh does not set, required approving reviews. Read with the site administrator's login.",
input: {},
run: async () => writes().unwritable(),
},
{
name: "gitea_grant_write",
description: "Give the mesh's merging forge account (mesh-tools) write, as a collaborator, on every repository of a " +
"user that builds a registered module, or on the repositories named (each must build one). Main's rule there is left " +
"as the operator set it; only when it keeps a merge whitelist is mesh-tools added to it, read back (merge_whitelist). " +
"What else in that rule stops the mesh's merge (a required status the mesh does not set, required approving reviews) " +
"is changed by nobody and said in merge_blocked. " +
"The module does this itself at its start and when a module of a new repository is registered; this is the same " +
"act by hand, such as when a merge or a commit status was refused 403. A repository of the organisation novox " +
"is written through its team and needs nothing. Answers what was granted (each read back from the forge), what " +
"was written already, what the forge refused in its own words, and which other repositories it left read-only " +
"and why (they build no registered module). Needs the site administrator's login, used for this grant alone.",
input: {
repositories: { type: "array", items: { type: "string" }, description: "owner/name of each repository; empty or absent, every delivered one" },
},
run: async (args) => writes().grant(Array.isArray(args.repositories) ? args.repositories.map(String) : undefined),
},
];
}