// The Gitea API client — gitea's own code, living in the module (novox/hq ADR 0039). Moved out of // the shared hal sdk, where a change to Gitea's API rebuilt everything; here it rebuilds only // gitea. Both this module's tools and its events entrypoint import it, and nothing outside gitea // does. import { readFileSync } from "node:fs"; import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js"; /** A repository, trimmed to what the mesh cares about. */ export interface GiteaRepo { full_name: string; /** The URL a build clones — what a module records as its source. */ clone_url?: string; name: string; owner: string; private: boolean; description?: string; html_url: string; default_branch?: string; } /** An issue, with its labels flattened to names. */ export interface GiteaIssue { number: number; title: string; state: string; user?: string; labels: string[]; html_url: string; body?: string; } /** A pull request, trimmed to the fields a reviewer or an event body needs. */ export interface GiteaPull { number: number; title: string; state: string; merged: boolean; /** The commit the merge produced — what a build of the base branch is made from. */ merge_commit_sha?: string; merged_at?: string; user?: string; head?: string; base?: string; html_url: string; } export interface GiteaLabel { id: number; name: string; } /** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */ function meshConfig(file?: string): Record { if (!file) return {}; try { return JSON.parse(readFileSync(file, "utf8")) as Record; } catch { return {}; } } export class GiteaClient { readonly baseUrl: string; private readonly tokens: TokenSource; /** A token given as a string is one somebody configured; a source decides for itself (token.ts). */ constructor(url: string, token: string | TokenSource) { this.baseUrl = url.replace(/\/+$/, ""); this.tokens = typeof token === "string" ? new ConfiguredToken(token) : token; } /** * Build from the module's resolved environment. The URL comes from MESH_GITEA_URL (the mesh's own * name), falling back to the bare GITEA_URL and to the forge's loopback port. The token, in order: * one configured in settings or the environment (MESH_GITEA_TOKEN / GITEA_TOKEN), which wins; else * one the module mints for itself with the admin account the vault delivered and keeps in its own * state (token.ts; hq issue 100). Throws only when neither is possible, naming what is missing, * rather than hand back a client that fails on first use. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): GiteaClient { const cfg = meshConfig(env.MESH_GITEA_CONFIG_FILE); const url = cfg.url ?? env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`; const configured = cfg.token ?? env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN; if (configured) return new GiteaClient(url, new ConfiguredToken(configured)); return new GiteaClient(url, MintedToken.fromEnv(url, env)); } /** * One authenticated call. A 401 is the forge saying the token is not one it knows — the case * after the forge's data was restored, or after somebody revoked it — so the source is asked to * renew once and the call is repeated with the new token. A configured token has nothing to renew * with, and its source says so. */ private async request(path: string, options: RequestInit = {}): Promise { let token = await this.tokens.current(); let res = await this.send(path, options, token); if (res.status === 401) { token = await this.tokens.renew(token); res = await this.send(path, options, token); } if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`); if (res.status === 204) return null as T; const text = await res.text(); return (text ? JSON.parse(text) : null) as T; } private send(path: string, options: RequestInit, token: string): Promise { return fetch(`${this.baseUrl}/api/v1${path}`, { ...options, headers: { "Content-Type": "application/json", Authorization: `token ${token}`, ...(options.headers as Record | undefined), }, }); } /** Generic authenticated API call — the escape hatch for endpoints without a dedicated method. * Path is relative to /api/v1. */ async api(path: string, options: RequestInit = {}): Promise { return this.request(path, options); } // ---- Repositories ---- /** Every repository this token can see, one page. `/user/repos` is only what the token's own * user owns — for the mesh's administrator that is nothing, which is how the forge watched an * empty list and announced no merge (2026-09-28). The search endpoint is the forge's whole view. */ async listRepos(page = 1, limit = 20): Promise { const found = await this.request<{ data?: any[] }>(`/repos/search?page=${page}&limit=${limit}`); return (found?.data ?? []).map(GiteaClient.mapRepo); } /** Every repository, all pages. */ async listAllRepos(): Promise { const all: GiteaRepo[] = []; for (let page = 1; page < 100; page++) { const batch = await this.listRepos(page, 50); all.push(...batch); if (batch.length < 50) break; } return all; } async createRepo(data: { name: string; description?: string; private?: boolean; auto_init?: boolean; }): Promise { return GiteaClient.mapRepo(await this.request("/user/repos", { method: "POST", body: JSON.stringify(data) })); } async deleteRepo(owner: string, repo: string): Promise { await this.request(`/repos/${owner}/${repo}`, { method: "DELETE" }); } // ---- Issues ---- async listIssues(owner: string, repo: string, params: Record = {}): Promise { const qs = new URLSearchParams({ type: "issues", ...params }).toString(); const issues = await this.request(`/repos/${owner}/${repo}/issues?${qs}`); return (issues ?? []).map(GiteaClient.mapIssue); } async getIssue(owner: string, repo: string, index: number): Promise { return GiteaClient.mapIssue(await this.request(`/repos/${owner}/${repo}/issues/${index}`)); } async createIssue( owner: string, repo: string, data: { title: string; body?: string; labels?: number[] }, ): Promise { return GiteaClient.mapIssue( await this.request(`/repos/${owner}/${repo}/issues`, { method: "POST", body: JSON.stringify(data) }), ); } /** Patch an issue's state — the one edit the close tool needs. */ async setIssueState(owner: string, repo: string, index: number, state: "open" | "closed"): Promise { return GiteaClient.mapIssue( await this.request(`/repos/${owner}/${repo}/issues/${index}`, { method: "PATCH", body: JSON.stringify({ state }), }), ); } async addComment(owner: string, repo: string, index: number, body: string): Promise<{ id: number; html_url: string }> { const c = await this.request(`/repos/${owner}/${repo}/issues/${index}/comments`, { method: "POST", body: JSON.stringify({ body }), }); return { id: c.id, html_url: c.html_url }; } // ---- Labels ---- async listLabels(owner: string, repo: string): Promise { const labels = await this.request(`/repos/${owner}/${repo}/labels`); return (labels ?? []).map((l: any) => ({ id: l.id, name: l.name })); } async createLabel( owner: string, repo: string, data: { name: string; color: string; description?: string }, ): Promise { const l = await this.request(`/repos/${owner}/${repo}/labels`, { method: "POST", body: JSON.stringify(data) }); return { id: l.id, name: l.name }; } /** Resolve a label name to its id, creating it if it does not exist — so create-issue can take * human label names and not numeric ids. */ async getOrCreateLabel(owner: string, repo: string, name: string, color = "#0075ca"): Promise { const existing = (await this.listLabels(owner, repo)).find((l) => l.name === name); if (existing) return existing.id; return (await this.createLabel(owner, repo, { name, color })).id; } // ---- Pull requests ---- async listPullRequests(owner: string, repo: string, params: Record = {}): Promise { const qs = new URLSearchParams(params).toString(); const prs = await this.request(`/repos/${owner}/${repo}/pulls?${qs}`); return (prs ?? []).map(GiteaClient.mapPull); } async getPullRequest(owner: string, repo: string, index: number): Promise { return GiteaClient.mapPull(await this.request(`/repos/${owner}/${repo}/pulls/${index}`)); } async createPullRequest( owner: string, repo: string, data: { title: string; body?: string; head: string; base: string }, ): Promise { return GiteaClient.mapPull( await this.request(`/repos/${owner}/${repo}/pulls`, { method: "POST", body: JSON.stringify(data) }), ); } async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false): Promise { await this.request(`/repos/${owner}/${repo}/pulls/${index}/merge`, { method: "POST", body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch }), }); } // ---- Mappers: the wire shape is broad and unstable; the mesh sees only these fields. ---- private static mapRepo(r: any): GiteaRepo { return { full_name: r.full_name, clone_url: r.clone_url ?? undefined, name: r.name, owner: r.owner?.login ?? r.full_name?.split("/")[0] ?? "unknown", private: Boolean(r.private), description: r.description || undefined, html_url: r.html_url, default_branch: r.default_branch, }; } private static mapIssue(i: any): GiteaIssue { return { number: i.number, title: i.title, state: i.state, user: i.user?.login, labels: (i.labels ?? []).map((l: any) => l.name), html_url: i.html_url, body: i.body || undefined, }; } private static mapPull(p: any): GiteaPull { return { number: p.number, title: p.title, state: p.state, merged: Boolean(p.merged), merge_commit_sha: p.merge_commit_sha ?? undefined, merged_at: p.merged_at ?? undefined, user: p.user?.login, head: p.head?.ref, base: p.base?.ref, html_url: p.html_url, }; } } /** One raw response the admin client acts on: the status code decides idempotency (a 422/409 on * create means "already there", a 404 on delete means "already gone"), the body carries ids. */ interface AdminResponse { readonly status: number; readonly body: any; } /** * The forge's admin client, over **basic auth** — gitea's own code, living in the module, used only * by the provisioner (novox/hq ADR 0048/0076). * * The token-authenticated {@link GiteaClient} above serves the tools and the event consumer, which * read repos and open issues. Provisioning is different: it creates and deletes *users* and manages * org teams — admin-API operations authenticated as the mesh's gitea admin, whose password is a mesh * own-secret. Basic auth is what the admin API takes, and keeping this separate from GiteaClient * keeps the two credentials and their two audiences apart. * * Every method is idempotent: the reconcile harness calls create repeatedly, so "already exists" is * success, not an error. */ export class GiteaAdmin { readonly baseUrl: string; private readonly authorization: string; constructor(url: string, user: string, password: string) { this.baseUrl = url.replace(/\/+$/, ""); this.authorization = "Basic " + Buffer.from(`${user}:${password}`).toString("base64"); } /** * Build from the module's resolved environment. The URL comes from MESH_GITEA_URL (the forge's * loopback, since the provisioner shares the host's network), the admin login from * MESH_GITEA_ADMIN_USER, and the admin password from the file MESH_GITEA_ADMIN_PASSWORD_FILE names * — the mesh own-secret the host unsealed. Trailing newline trimmed, the way the harness trims a * sealed secret. Throws rather than hand back a client that fails on first call. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): GiteaAdmin { const url = env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`; const user = env.MESH_GITEA_ADMIN_USER; if (!user) throw new Error("no Gitea admin user — set MESH_GITEA_ADMIN_USER"); const file = env.MESH_GITEA_ADMIN_PASSWORD_FILE; if (!file) throw new Error("no Gitea admin password file — set MESH_GITEA_ADMIN_PASSWORD_FILE"); const password = readFileSync(file, "utf8").replace(/\n$/, ""); return new GiteaAdmin(url, user, password); } /** A single admin-API call. Unlike GiteaClient.request, this returns the status rather than * throwing on it — the caller decides which non-2xx codes are idempotent successes. Only an * unexpected status becomes an error, and only where the caller says so. */ private async request(path: string, options: RequestInit = {}): Promise { const res = await fetch(`${this.baseUrl}/api/v1${path}`, { ...options, headers: { "Content-Type": "application/json", Authorization: this.authorization, ...(options.headers as Record | undefined), }, }); const text = await res.text(); let body: any = null; if (text) { try { body = JSON.parse(text); } catch { body = text; } } return { status: res.status, body }; } /** Fail with the forge's own message when a status the caller did not expect comes back. */ private static fail(path: string, res: AdminResponse): never { const detail = typeof res.body === "string" ? res.body : JSON.stringify(res.body); throw new Error(`Gitea admin ${path}: ${res.status} ${detail}`); } /** Ensure the npm-owner org exists. 201 created, 2xx/404-then-created, and 422/409 (a concurrent * create won the race) are all success. */ async ensureOrg(name: string): Promise { const existing = await this.request(`/orgs/${encodeURIComponent(name)}`); if (existing.status === 200) return; const res = await this.request("/orgs", { method: "POST", body: JSON.stringify({ username: name, visibility: "private" }), }); if (res.status === 201 || res.status === 422 || res.status === 409) return; GiteaAdmin.fail("/orgs", res); } /** Ensure the org's package team exists with exactly these units, and return its id. Found or * created, the units are applied either way — a team is configuration the reconcile loop owns, * the same as a user's password, so a unit this code gains reaches a team that already exists * rather than only the next mesh raised from scratch. A lost create race is resolved by * re-listing. */ async ensureTeam(org: string, team: string, packageWrite: boolean): Promise { // The units a consumer needs, and no more. `units_map` is exhaustive — a unit not named is a // unit the team does not have — so code read must be said here: without it gitea answers a // member's clone of a private repository with "not found", which is how the builder's first // credentialed clone failed against a team that named only packages. const units = { permission: "read", units_map: { "repo.code": "read", "repo.packages": packageWrite ? "write" : "read" }, includes_all_repositories: true, can_create_org_repo: false, }; const found = await this.findTeam(org, team); if (found !== null) { const patch = await this.request(`/teams/${found}`, { method: "PATCH", body: JSON.stringify({ name: team, ...units }), }); if (patch.status === 200) return found; GiteaAdmin.fail(`/teams/${found}`, patch); } const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`, { method: "POST", body: JSON.stringify({ name: team, ...units }), }); if (res.status === 201) return Number(res.body?.id); if (res.status === 422 || res.status === 409) { const after = await this.findTeam(org, team); if (after !== null) return after; } return GiteaAdmin.fail(`/orgs/${org}/teams`, res); } private async findTeam(org: string, team: string): Promise { const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`); if (res.status !== 200) return null; const match = (res.body as any[] | null)?.find((t) => t?.name === team); return match ? Number(match.id) : null; } /** Ensure a user exists with exactly this password. Created if absent; if already there, its * password is patched — so the mesh minting a new secret takes on the next reconcile. * * The edit path is taken only when the user actually exists. A 422 from the create is also what * a plain validation failure returns, and reading it as "already there" made the follow-up edit * 404 — burying the create's own message, which is the one that says what is actually wrong. */ async ensureUser(username: string, password: string, email: string): Promise { const res = await this.request("/admin/users", { method: "POST", body: JSON.stringify({ username, email, password, must_change_password: false }), }); if (res.status === 201) return; if (res.status === 422 || res.status === 409) { const seen = await this.request(`/users/${encodeURIComponent(username)}`); if (seen.status === 200) { const patch = await this.request(`/admin/users/${encodeURIComponent(username)}`, { method: "PATCH", // login_name is required by the admin edit endpoint; for a local user it is the username. // active and prohibit_login: a deactivated or login-prohibited user is refused like a wrong // password, so the provisioner's check reports it lost; applying again must undo both. body: JSON.stringify({ login_name: username, password, must_change_password: false, active: true, prohibit_login: false }), }); if (patch.status === 200) return; GiteaAdmin.fail(`/admin/users/${username}`, patch); } } GiteaAdmin.fail("/admin/users", res); } /** Add a user to a team, which also makes them an org member. Idempotent: adding an existing * member returns 204 again. */ async addUserToTeam(teamId: number, username: string): Promise { const res = await this.request(`/teams/${teamId}/members/${encodeURIComponent(username)}`, { method: "PUT", }); if (res.status === 204 || res.status === 200) return; GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res); } /** * Whether a consumer's user logs in with exactly this password and is still a member of the * package team. Read-only: the password is checked as the consumer presents it, basic auth on the * API, and membership through the admin API. `false` for a refused login or a missing member; any * other answer rejects (novox/hq issue 120). */ async holdsTeamMember(org: string, team: string, username: string, password: string): Promise { const me = await fetch(`${this.baseUrl}/api/v1/user`, { headers: { Authorization: "Basic " + Buffer.from(`${username}:${password}`).toString("base64") }, }); if (me.status === 401 || me.status === 403) return false; if (me.status !== 200) throw new Error(`Gitea GET /user as ${username}: ${me.status}`); const teams = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`); if (teams.status === 404) return false; if (teams.status !== 200) GiteaAdmin.fail(`/orgs/${org}/teams`, teams); const found = (teams.body as { id: number; name: string }[]).find((t) => t.name === team); if (!found) return false; const member = await this.request(`/teams/${found.id}/members/${encodeURIComponent(username)}`); if (member.status === 200 || member.status === 204) return true; if (member.status === 404) return false; GiteaAdmin.fail(`/teams/${found.id}/members/${username}`, member); } /** Delete a user, purging what they own. A 404 means the mesh already withdrew them — success, not * an error, so a re-run of remove is safe. */ async deleteUser(username: string): Promise { const res = await this.request(`/admin/users/${encodeURIComponent(username)}?purge=true`, { method: "DELETE", }); if (res.status === 204 || res.status === 200 || res.status === 404) return; GiteaAdmin.fail(`/admin/users/${username}`, res); } }