Files
mesh-catalog/modules/gitea/readall.ts
T
jschoubben b4a7bb9dea
mesh/delivery merged: source.main moved to novox/mesh-catalog@a4f5df797b68
mesh/check being carried to b4a7bb9dea50 on a4f5df797b68
gitea: say that a repository made private later is granted only at the next start or by hand, and let one unreadable permission refuse its repository, not the whole pass
2026-10-26 13:43:46 +01:00

211 lines
11 KiB
TypeScript

// The tools' account reads every repository on the forge (the operator, 2026-10-26: "the mesh's forge account should
// get read access to all repositories").
//
// Since issue 469 the tools act as the plain account `mesh-tools`, which writes to the organisation novox's
// repositories through its team (tools-account.ts). Every other repository it saw only when public: a private
// repository of a user (jschoubben/car-hunter, jschoubben/snake) answered its tools 404 and was missing from every
// list, while that user's public ones were there. So the module makes its account a collaborator that reads only on
// each repository it cannot read, the least the forge offers: one repository, read, no organisation membership.
//
// - **at the start**, one pass over every repository of every user and organisation;
// - **on the forge's own event** (the system webhook, webhook.ts): `forge.repository.created` and `forge.fork` at
// once, and the first event of any repository this process has not yet seen readable, since the forge may not
// send a repository's creation for every owner. Nothing polls for it.
//
// **Not caught: a repository made private later.** The forge sends no event for a change of visibility, and a public
// repository is already seen readable by the start's pass, so its events ask nothing. Such a repository is granted at
// the module's next start, or now by `gitea_grant_read`; `gitea_unreadable_repos` shows it meanwhile.
//
// **The grant needs the site administrator's login.** Adding a collaborator asks the repository's administration,
// which on another user's repository only the site administrator holds; the module holds that login for setting the
// forge up (MESH_GITEA_ADMIN_PASSWORD_FILE). Here it is used through `ReadAllForge` alone: list the repositories, read
// the account's permission on one, add the account as a collaborator that reads. Nothing else, and never for the tools'
// own calls, which keep their token.
//
// What the forge refuses (a repository that went meanwhile, an archived one if the forge will not take a collaborator
// on it) is said per repository and not retried; the tool `gitea_unreadable_repos` shows what is left, and
// `gitea_grant_read` grants again now.
import type { ForgeRepository } from "./client.js";
/** What the module asks of the forge for this, with the site administrator's login (GiteaAdmin, client.ts). */
export interface ReadAllForge {
everyRepository(): Promise<ForgeRepository[]>;
repository(owner: string, repo: string): Promise<ForgeRepository | null>;
repositoryPermission(owner: string, repo: string, user: string): Promise<string>;
addReadCollaborator(owner: string, repo: string, user: string): Promise<void>;
}
/** A repository the account cannot read, and why. */
export interface Unreadable {
repository: string;
permission: string;
private: boolean;
archived: boolean;
why: string;
}
export interface Granted {
/** Repositories looked at. */
checked: number;
/** Made readable now, by a read collaborator. */
granted: string[];
/** The forge refused the grant: each repository and the forge's own words. */
refused: { repository: string; why: string }[];
}
const READS = new Set(["read", "write", "admin", "owner"]);
/** Why the account cannot read a repository, from what the forge says of it. */
export function whyUnreadable(repo: ForgeRepository, account: string): string {
const parts: string[] = [];
if (repo.private) parts.push(`a private repository of ${repo.owner}, on which ${account} is no collaborator and in no team that covers it`);
else if (repo.owner_visibility !== "public") {
parts.push(`a public repository of ${repo.owner}, whose own visibility is ${repo.owner_visibility}: the forge shows it to members only`);
} else parts.push(`the forge answers ${account} no read on it`);
if (repo.archived) parts.push("archived");
return parts.join("; ");
}
export class ReadAll {
/** Repositories this process has seen the account read, by full name: an event of one of them asks nothing more. */
readonly readable = new Set<string>();
constructor(private readonly forge: ReadAllForge, readonly account: string,
private readonly log: (line: string) => void = (l) => console.log(`[gitea] ${l}`)) {}
/** Every repository the account cannot read, and why, as the forge says it now. */
async unreadable(): Promise<{ checked: number; unreadable: Unreadable[] }> {
const repos = await this.forge.everyRepository();
const out: Unreadable[] = [];
for (const repo of repos) {
try {
const u = await this.judge(repo);
if (u) out.push(u);
} catch (err) {
// A permission the forge would not answer is said for that repository; the rest are still looked at.
out.push({ repository: repo.full_name, permission: "unknown", private: repo.private, archived: repo.archived,
why: `the forge did not answer ${this.account}'s permission on it — ${err instanceof Error ? err.message : String(err)}` });
}
}
return { checked: repos.length, unreadable: out };
}
/** Grant read on every repository the account cannot read (or on those named, `owner/name`). */
async grant(only?: string[]): Promise<Granted> {
let repos: ForgeRepository[];
if (only && only.length) {
repos = [];
for (const full of only) {
const [owner, name, ...rest] = full.split("/");
if (!owner || !name || rest.length) throw new Error(`"${full}" is not a repository: say owner/name`);
const repo = await this.forge.repository(owner, name);
if (!repo) throw new Error(`the forge holds no repository ${full}`);
repos.push(repo);
}
} else repos = await this.forge.everyRepository();
const result: Granted = { checked: repos.length, granted: [], refused: [] };
for (const repo of repos) {
let u: Unreadable | null;
try {
u = await this.judge(repo);
} catch (err) {
// One repository whose permission cannot be read is a refusal of its own, never the end of the pass.
result.refused.push({ repository: repo.full_name, why: `its permission could not be read — ${err instanceof Error ? err.message : String(err)}` });
continue;
}
if (!u) continue;
try {
await this.forge.addReadCollaborator(repo.owner, repo.name, this.account);
} catch (err) {
result.refused.push({ repository: repo.full_name, why: err instanceof Error ? err.message : String(err) });
continue;
}
// Read back: the forge's own word that the account reads it now, never the grant's answer alone.
let after: string;
try {
after = await this.forge.repositoryPermission(repo.owner, repo.name, this.account);
} catch (err) {
result.refused.push({ repository: repo.full_name, why: `granted, but its permission could not be read back — ${err instanceof Error ? err.message : String(err)}` });
continue;
}
if (READS.has(after)) {
this.readable.add(repo.full_name);
result.granted.push(repo.full_name);
} else {
result.refused.push({ repository: repo.full_name, why: `the grant was answered, but the forge still gives ${this.account} "${after}"` });
}
}
return result;
}
/** The start's one pass: grant what is missing and say it. Answers the result; a failure is the caller's to say. */
async atStart(): Promise<Granted> {
const r = await this.grant();
this.log(`${this.account} reads every repository: ${r.checked} looked at, ${r.granted.length} granted read now` +
`${r.granted.length ? ` (${r.granted.join(", ")})` : ""}${r.refused.length ? `, ${r.refused.length} refused: ` +
r.refused.map((x) => `${x.repository} — ${x.why}`).join("; ") : ""}`);
return r;
}
/** A forge event: a repository made (or forked), or the first event of one not seen readable, is granted read now. */
async onEvent(event: { name: string; body: unknown }): Promise<void> {
const b = (event.body ?? {}) as { owner?: string; repo?: string };
if (!b.owner || !b.repo) return;
const full = `${b.owner}/${b.repo}`;
if (event.name === "forge.repository.deleted") {
this.readable.delete(full);
return;
}
const made = event.name === "forge.repository.created" || event.name === "forge.fork";
// Seen readable once, its events ask nothing: so a repository made private after that is not caught here (the
// forge says no change of visibility), only at the next start or by gitea_grant_read.
if (!made && this.readable.has(full)) return;
const r = await this.grant([full]);
if (r.granted.length) this.log(`${this.account} was given read on ${full}, on the forge's ${event.name}`);
for (const x of r.refused) this.log(`${this.account} could not be given read on ${x.repository} — ${x.why}`);
}
private async judge(repo: ForgeRepository): Promise<Unreadable | null> {
const permission = await this.forge.repositoryPermission(repo.owner, repo.name, this.account);
if (READS.has(permission)) {
this.readable.add(repo.full_name);
return null;
}
this.readable.delete(repo.full_name);
return { repository: repo.full_name, permission, private: repo.private, archived: repo.archived, why: whyUnreadable(repo, this.account) };
}
}
type Tool = { name: string; description: string; input: Record<string, unknown>; run: (args: Readonly<Record<string, unknown>>) => Promise<unknown> };
/** The two tools: what the account cannot read and why, and the grant now. */
export function readAllTools(readAll: () => ReadAll): Tool[] {
return [
{
name: "gitea_unreadable_repos",
description: "Every repository on the forge the tools' own account (mesh-tools) cannot read, and why: the " +
"permission the forge gives it there, whether the repository is private or archived, and in words the reason " +
"(a private repository of a user with the account no collaborator, an owner not public). Empty when it reads " +
"every repository. Read with the site administrator's login, which alone sees every repository.",
input: {},
run: async () => readAll().unreadable(),
},
{
name: "gitea_grant_read",
description: "Give the tools' own account (mesh-tools) read on every repository it cannot read now, or on the " +
"repositories named: each becomes a collaborator that reads only, the least the forge offers. The module does " +
"this itself at its start and on the forge's event of a repository made; this is the same act by hand. Answers " +
"how many were looked at, which were granted (each read back from the forge) and which the forge refused, in its " +
"own words. 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 repository" },
},
run: async (args) => {
const named = Array.isArray(args.repositories) ? args.repositories.map(String) : undefined;
return readAll().grant(named);
},
},
];
}