Files
mesh-catalog/modules/gitea/client.ts
T

974 lines
47 KiB
TypeScript

// 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";
import { protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js";
import { Refused, checkSentPath, pathSegment, pathSegments } from "./guard.js";
import { isCommit } from "./checks.js";
/** A repository's path on the forge, its owner and name each one segment (guard.ts pathSegment): uniform for every
* call, whether the names came from a caller or from the forge's own answer. */
function repoPath(owner: unknown, repo: unknown): string {
return `/repos/${pathSegment("repository owner", owner)}/${pathSegment("repository name", repo)}`;
}
/** A commit, by its sha or the start of one, as it goes in a path: anything else is refused before it is sent. */
function commitSegment(sha: unknown): string {
const v = String(sha ?? "");
if (!isCommit(v)) throw new Refused(`${JSON.stringify(sha)} is not a commit. Nothing was sent`);
return v;
}
export type { BranchProtection, ProtectionWanted } from "./protection.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;
/** When anything last moved in it — a push, and so a merge. */
updated_at?: 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;
}
/**
* **The branch a pull request was opened from, also once it is deleted** (novox/hq issue 578). The forge says it as
* `head.ref` while the branch exists; once it is gone — the merge tool deletes it after the merge by default —
* `head.ref` reads `refs/pull/<n>/head`, and only `head.label` still names the branch. A delivery group is the pull
* requests sharing a head branch name (novox/hq ADR 0249), and the controller finds a merge's group, and joins the
* group's merges in one batch, by the head its `pull.merged` names (ADR 0276 decision 5, ADR 0306 decision 2). With
* the pull ref it found no group: on 2026-10-12 mesh-catalog #251's walk was cut at once and went before
* mesh-controller #271's, which the group's order put first.
*/
export function headBranchOf(head: { ref?: unknown; label?: unknown } | null | undefined): string | undefined {
const said = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : undefined);
const ref = said(head?.ref);
const label = said(head?.label);
if (ref === undefined || /^refs\/pull\/\d+\/head$/.test(ref)) return label ?? ref;
return ref;
}
/** 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;
/** The commit the pull request's head is at now: what is checked before it merges (novox/hq to-be 45 §9). */
head_sha?: string;
base?: string;
updated_at?: string;
html_url: string;
/** The description: where a pull request says it goes after another (`after: <repository>`, novox/hq ADR 0239). */
body?: string;
}
/** An open pull request with what its base requires and what its head has: what gitea_open_pulls answers. */
export interface OpenPull {
owner: string;
repo: string;
number: number;
base: string;
head_sha: string;
html_url?: string;
/** The contexts the base branch's protection requires; none when it requires none, or cannot be read. */
required: string[];
/** The contexts the head has a status of, in any state. */
statuses: string[];
/** The head's statuses by context, the newest of each, with its state (novox/hq issue 438): what tells a check
* that started and never answered — pending — from one that answered. */
checks: Record<string, HeadCheck>;
}
/** One status on a head, the newest of its context: its state, what it said, and when it was set. */
export interface HeadCheck {
state: string;
description?: string;
updated_at?: string;
}
/** A commit status, as the forge keeps it: what a pull request shows beside its head commit. */
export interface CommitStatus {
state: "pending" | "success" | "error" | "failure" | "warning";
context: string;
description: string;
target_url?: string;
}
export interface GiteaComment {
id: number;
user?: string;
body: string;
created_at?: 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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
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);
} else if (res.status === 403) {
// A kept token minted before a scope was added lacks it. The forge says so; the source
// re-mints with the whole list and the call is retried once. Any other 403 stays a 403.
const text = await res.text();
if (!MintedToken.lacksScope(res.status, text)) throw new Error(`Gitea API ${path}: 403 ${text}`);
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<Response> {
checkSentPath(path);
return fetch(`${this.baseUrl}/api/v1${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `token ${token}`,
...(options.headers as Record<string, string> | undefined),
},
});
}
/** Generic authenticated API call — the escape hatch for endpoints without a dedicated method.
* Path is relative to /api/v1. */
async api<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
return this.request<T>(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<GiteaRepo[]> {
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<GiteaRepo[]> {
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<GiteaRepo> {
return GiteaClient.mapRepo(await this.request<any>("/user/repos", { method: "POST", body: JSON.stringify(data) }));
}
async deleteRepo(owner: string, repo: string): Promise<void> {
await this.request(`${repoPath(owner, repo)}`, { method: "DELETE" });
}
// ---- Issues ----
async listIssues(owner: string, repo: string, params: Record<string, string> = {}): Promise<GiteaIssue[]> {
const qs = new URLSearchParams({ type: "issues", ...params }).toString();
const issues = await this.request<any[]>(`${repoPath(owner, repo)}/issues?${qs}`);
return (issues ?? []).map(GiteaClient.mapIssue);
}
async getIssue(owner: string, repo: string, index: number): Promise<GiteaIssue> {
indexOf(index);
return GiteaClient.mapIssue(await this.request<any>(`${repoPath(owner, repo)}/issues/${index}`));
}
async createIssue(
owner: string,
repo: string,
data: { title: string; body?: string; labels?: number[] },
): Promise<GiteaIssue> {
return GiteaClient.mapIssue(
await this.request<any>(`${repoPath(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<GiteaIssue> {
indexOf(index);
return GiteaClient.mapIssue(
await this.request<any>(`${repoPath(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 }> {
indexOf(index);
const c = await this.request<any>(`${repoPath(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<GiteaLabel[]> {
const labels = await this.request<any[]>(`${repoPath(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<GiteaLabel> {
const l = await this.request<any>(`${repoPath(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<number> {
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<string, string> = {}): Promise<GiteaPull[]> {
const qs = new URLSearchParams(params).toString();
const prs = await this.request<any[]>(`${repoPath(owner, repo)}/pulls?${qs}`);
return (prs ?? []).map(GiteaClient.mapPull);
}
/** The open pull requests of every repository the mesh's account sees, updated since a moment when one is given,
* page by page (novox/hq issue 347): each with its repository and its own update time. */
async searchOpenPulls(since?: string): Promise<{ owner: string; name: string; number: number; updated_at?: string }[]> {
const out: { owner: string; name: string; number: number; updated_at?: string }[] = [];
for (let page = 1; page <= 20; page++) {
const qs = new URLSearchParams({ type: "pulls", state: "open", limit: "50", page: String(page) });
if (since) qs.set("since", since);
const found = await this.request<any[]>(`/repos/issues/search?${qs}`);
for (const i of found ?? []) {
const r = i?.repository ?? {};
const [fo, fn] = String(r.full_name ?? "").split("/");
const owner = String(r.owner ?? fo ?? "");
const name = String(r.name ?? fn ?? "");
if (owner && name && i?.number) out.push({ owner, name, number: Number(i.number), updated_at: i.updated_at });
}
if (!found || found.length < 50) break;
}
return out;
}
/** **Every open pull request, from the forge itself** (novox/hq issue 347): with what its base's protection
* requires and the contexts its head has a status of, in any state. What mesh-delivery reads to say a pull
* request the mesh was never asked to check; asked of the forge, never of what the announcer remembers, so a
* fault in the announcer cannot hide itself. Reads only. */
async openPulls(): Promise<OpenPull[]> {
const out: OpenPull[] = [];
const rules = new Map<string, BranchProtection[] | undefined>();
for (const found of await this.searchOpenPulls()) {
const full = `${found.owner}/${found.name}`;
const pull = await this.getPullRequest(found.owner, found.name, found.number);
if (pull.state !== "open" || !pull.head_sha) continue;
if (!rules.has(full)) rules.set(full, await this.branchProtections(found.owner, found.name).catch(() => undefined));
const rule = rules.get(full)?.find((p) => (p.rule_name ?? p.branch_name) === (pull.base || "main"));
const checks = await this.headChecks(found.owner, found.name, pull.head_sha);
out.push({ owner: found.owner, repo: found.name, number: pull.number, base: pull.base ?? "", head_sha: pull.head_sha,
html_url: pull.html_url, required: rule?.enable_status_check ? (rule.status_check_contexts ?? []) : [],
statuses: Object.keys(checks), checks });
}
return out;
}
/** **Every open pull request of a repository**, page by page (novox/hq ADR 0323 §4): what a trunk's move puts out of
* date, however many are open, whatever their update time (a pass announces from listOpenPullsSince). Bounded at
* 20 pages of 50; a repository with more is said, and the rest are left to the merge tool's own comparison. */
async listOpenPulls(owner: string, repo: string): Promise<GiteaPull[]> {
const out: GiteaPull[] = [];
for (let page = 1; page <= 20; page++) {
const batch = await this.listPullRequests(owner, repo, { state: "open", limit: "50", page: String(page) });
out.push(...batch);
if (batch.length < 50) return out;
}
console.error(`[gitea] ${owner}/${repo} has more than 1000 open pull requests; only the first 1000 are read`);
return out;
}
/** **The open pull requests of a repository updated since a moment**, newest first, page by page (novox/hq issue
* 579): what a pass announces new heads from. The forge sorts `recentupdate` by update time, newest first, so the
* paging stops at the first page that reaches back past `since`; with no moment — a sweep or the first pass —
* every page is read. Bounded like listOpenPulls at 20 pages of 50. A pull request seen twice, pushed up a page
* while the pages were read, is answered once. */
async listOpenPullsSince(owner: string, repo: string, since: string): Promise<GiteaPull[]> {
const out: GiteaPull[] = [];
const seen = new Set<number>();
const floor = since ? Date.parse(since) : NaN;
for (let page = 1; page <= 20; page++) {
const batch = await this.listPullRequests(owner, repo, { state: "open", sort: "recentupdate", limit: "50", page: String(page) });
for (const p of batch) {
if (seen.has(p.number)) continue;
seen.add(p.number);
out.push(p);
}
if (batch.length < 50) return out;
const oldest = batch[batch.length - 1]?.updated_at;
if (!Number.isNaN(floor) && oldest && Date.parse(oldest) < floor) return out;
}
console.error(`[gitea] ${owner}/${repo} has more than 1000 open pull requests updated since ${since || "ever"}; only the newest 1000 are read`);
return out;
}
/** The commit a branch is at now: a trunk's head (novox/hq ADR 0323). */
async branchHead(owner: string, repo: string, branch: string): Promise<string> {
const b = await this.request<any>(`${repoPath(owner, repo)}/branches/${pathSegments("branch", branch)}`);
const sha = String(b?.commit?.id ?? "");
if (!/^[0-9a-f]{40,64}$/i.test(sha)) throw new Error(`the forge named no commit for ${owner}/${repo}'s ${branch}`);
return sha;
}
/** One repository, as the forge holds it: its clone URL above all, for an announcement. */
async getRepo(owner: string, repo: string): Promise<GiteaRepo> {
return GiteaClient.mapRepo(await this.request<any>(repoPath(owner, repo)));
}
async getPullRequest(owner: string, repo: string, index: number): Promise<GiteaPull> {
indexOf(index);
return GiteaClient.mapPull(await this.request<any>(`${repoPath(owner, repo)}/pulls/${index}`));
}
/** A commit's parents, by sha, in order: what a merge commit says it joined (novox/hq issue 381). An answer
* that names no parents is no answer, never an empty list read as a root commit. */
async commitParents(owner: string, repo: string, sha: string): Promise<string[]> {
const c = await this.request<any>(`${repoPath(owner, repo)}/git/commits/${encodeURIComponent(sha)}`);
if (!Array.isArray(c?.parents)) throw new Error(`the forge named no parents of ${sha}`);
return c.parents.map((p: any) => {
if (typeof p?.sha !== "string" || !p.sha) throw new Error(`the forge named a parent of ${sha} without its sha`);
return p.sha;
});
}
/** The files a merged pull request changed, as paths from the repository's root.
*
* `limit` is what is asked for, and a merge that changed more says so rather than being read
* page by page: what the mesh does with a partial list is treat the whole repository as changed,
* so more pages would buy nothing. */
/** Every file a pull request changed, page by page. **The forge caps a page below what is asked**
* (fifty, asked for a hundred), so one page read as the whole list dropped files silently, and a module
* whose own files moved was not rebuilt (novox/hq issue 252). Read until a page comes back short; past
* `most` files the list is cut and says so, and the mesh then rebuilds everything built from the
* repository, the safe direction. */
/** Every file a pull request changes, and which of them it deleted: a module whose manifest the merge
* deleted is gone from its source, and the mesh forgets it rather than asking its build (novox/hq ADR
* 0236). The forge says `deleted`; `removed` is read the same. */
async listPullFiles(owner: string, repo: string, index: number, most = 3000): Promise<{ paths: string[]; removed: string[]; truncated: boolean }> {
indexOf(index);
const paths: string[] = [];
const removed: string[] = [];
let pageSize = 0;
for (let page = 1; ; page++) {
const files = (await this.request<any[]>(`${repoPath(owner, repo)}/pulls/${index}/files?limit=50&page=${page}`)) ?? [];
if (page === 1) pageSize = files.length;
for (const f of files) {
const name = String(f?.filename ?? "");
if (name === "") continue;
paths.push(name);
const status = String(f?.status ?? "");
if (status === "deleted" || status === "removed") removed.push(name);
// **A renamed file is said under both names** (novox/hq ADR 0267, issue 363): its old path is gone, and
// a module whose build source held it changed even though the new path is elsewhere.
const previous = String(f?.previous_filename ?? "");
if (previous !== "" && previous !== name) {
paths.push(previous);
removed.push(previous);
}
}
if (files.length === 0 || files.length < pageSize) return { paths, removed, truncated: false };
if (paths.length >= most) return { paths, removed, truncated: true };
}
}
/** The directories holding these files that hold a `module.json` at a commit (novox/hq issue 278).
*
* **Whether a directory is a module is a fact of the repository, not of the merge.** The mesh read a
* changed file as shared code unless its directory was a module it held or the merge also changed
* that directory's manifest; a change to the catalogue's reference module, which no machine holds,
* rebuilt all 103 modules built from the repository. So every directory above a changed file — never
* the root — is looked up at the merge commit, and the ones holding a manifest are said.
*
* `null` when there are more than `most` directories to look at: not said, and the mesh keeps its old
* rule, which rebuilds too much rather than too little. A failed lookup throws, for the same reason. */
async moduleDirsAt(owner: string, repo: string, sha: string, paths: string[], most = 300): Promise<string[] | null> {
const dirs = directoriesAbove(paths);
if (dirs.length > most) return null;
const out: string[] = [];
for (const dir of dirs) {
if (await this.exists(`${repoPath(owner, repo)}/contents/${encodePath(dir + "/module.json")}?ref=${encodeURIComponent(sha)}`)) {
out.push(dir);
}
}
return out;
}
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
async holdsFile(owner: string, repo: string, sha: string, file: string): Promise<boolean> {
return this.exists(`${repoPath(owner, repo)}/contents/${encodePath(file)}?ref=${encodeURIComponent(sha)}`);
}
/** Whether the forge has something at a path: true for an answer, false for a 404, thrown otherwise. */
private async exists(path: string): Promise<boolean> {
let token = await this.tokens.current();
let res = await this.send(path, {}, token);
if (res.status === 401) {
token = await this.tokens.renew(token);
res = await this.send(path, {}, token);
}
if (res.status === 404) return false;
if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`);
await res.body?.cancel();
return true;
}
/** Set a commit's status — what a pull request whose head it is shows beside it (novox/hq to-be 45 §9).
* The forge keeps one per context, the newest, so setting it again replaces it. */
async setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void> {
await this.request(`${repoPath(owner, repo)}/statuses/${commitSegment(sha)}`, { method: "POST", body: JSON.stringify(status) });
}
/** A commit's statuses by context, the newest of each: what a merge's head was checked as (novox/hq ADR 0239). */
async commitStatuses(owner: string, repo: string, sha: string): Promise<Record<string, string>> {
const raw = await this.request<any>(`${repoPath(owner, repo)}/commits/${commitSegment(sha)}/status`);
const out: Record<string, string> = {};
for (const s of raw?.statuses ?? []) if (s?.context && !out[s.context]) out[s.context] = String(s.status ?? s.state ?? "");
return out;
}
/** A head's statuses by context, the newest of each, with state, description and time (novox/hq issue 438):
* commitStatuses' read, keeping what a watch needs to say a check pending and since when. */
async headChecks(owner: string, repo: string, sha: string): Promise<Record<string, HeadCheck>> {
const raw = await this.request<any>(`${repoPath(owner, repo)}/commits/${commitSegment(sha)}/status`);
const out: Record<string, HeadCheck> = {};
for (const s of raw?.statuses ?? []) {
if (!s?.context || out[s.context]) continue;
out[s.context] = { state: String(s.status ?? s.state ?? ""), description: String(s.description ?? ""),
updated_at: String(s.updated_at ?? s.created_at ?? "") };
}
return out;
}
/** Every status of one context on a commit, newest first, as the forge keeps each one it was given: what tells
* when the check now pending on a head was opened, after a restart (novox/hq issue 456). */
async contextHistory(owner: string, repo: string, sha: string, context: string): Promise<HeadCheck[]> {
const raw = await this.request<any>(`${repoPath(owner, repo)}/statuses/${commitSegment(sha)}?sort=highestindex&limit=50`);
const out: HeadCheck[] = [];
for (const s of Array.isArray(raw) ? raw : []) {
if (s?.context !== context) continue;
out.push({ state: String(s.status ?? s.state ?? ""), description: String(s.description ?? ""),
updated_at: String(s.created_at ?? s.updated_at ?? "") });
}
return out;
}
/** A commit's statuses whole, as the forge combines them for a ref (a sha, or the start of one): the commit
* it resolved to and every status with its description, link, times and setter (novox/hq ADR 0239). */
async combinedStatus(owner: string, repo: string, ref: string): Promise<{ sha: string; state: string; statuses: unknown[] }> {
const raw = await this.request<any>(`${repoPath(owner, repo)}/commits/${pathSegment("commit or branch", ref)}/status?limit=50`);
return { sha: String(raw?.sha ?? ""), state: String(raw?.state ?? ""), statuses: Array.isArray(raw?.statuses) ? raw.statuses : [] };
}
/** The forge's compare of two commits, `base...head`: the commits on head that base lacks, and how many
* (novox/hq ADR 0270). Reads only. */
async compare(owner: string, repo: string, base: string, head: string): Promise<unknown> {
return this.request<unknown>(`${repoPath(owner, repo)}/compare/${commitSegment(base)}...${commitSegment(head)}`);
}
/** Replace a comment's body: the delivery's view, kept current in place (novox/hq ADR 0239). */
async editComment(owner: string, repo: string, id: number, body: string): Promise<{ id: number; html_url: string }> {
const c = await this.request<any>(`${repoPath(owner, repo)}/issues/comments/${id}`, { method: "PATCH", body: JSON.stringify({ body }) });
return { id: Number(c?.id ?? id), html_url: String(c?.html_url ?? "") };
}
// ---- Branch protection ----
/** A repository's branch protection rules. */
async branchProtections(owner: string, repo: string): Promise<BranchProtection[]> {
return (await this.request<BranchProtection[]>(`${repoPath(owner, repo)}/branch_protections`)) ?? [];
}
/** The rule named for a branch, null when it has none. */
async branchProtection(owner: string, repo: string, rule: string): Promise<BranchProtection | null> {
return (await this.branchProtections(owner, repo)).find((p) => (p.rule_name ?? p.branch_name) === rule) ?? null;
}
/** Make a branch require these commit statuses to merge (novox/hq ADR 0237): the rule named for the
* branch is edited — its required statuses replaced by these, everything else it says kept unless
* asked — or created, refusing direct pushes unless asked otherwise. Answers the rule as it is now. */
async setBranchProtection(owner: string, repo: string, branch: string, want: ProtectionWanted): Promise<{ created: boolean; rule: BranchProtection }> {
const had = await this.branchProtection(owner, repo, branch);
const body = protectionBody(want, !had);
if (had) {
const rule = await this.request<BranchProtection>(`${repoPath(owner, repo)}/branch_protections/${pathSegments("branch", branch)}`,
{ method: "PATCH", body: JSON.stringify(body) });
return { created: false, rule };
}
const rule = await this.request<BranchProtection>(`${repoPath(owner, repo)}/branch_protections`,
{ method: "POST", body: JSON.stringify({ rule_name: branch, ...body }) });
return { created: true, rule };
}
async createPullRequest(
owner: string,
repo: string,
data: { title: string; body?: string; head: string; base: string },
): Promise<GiteaPull> {
return GiteaClient.mapPull(
await this.request<any>(`${repoPath(owner, repo)}/pulls`, { method: "POST", body: JSON.stringify(data) }),
);
}
/** Close or reopen a pull request without merging it. A pull request is an issue to the forge's
* state machine, and the pulls endpoint takes the same `state`. */
async setPullState(owner: string, repo: string, index: number, state: "open" | "closed"): Promise<GiteaPull> {
indexOf(index);
return GiteaClient.mapPull(
await this.request<any>(`${repoPath(owner, repo)}/pulls/${index}`, { method: "PATCH", body: JSON.stringify({ state }) }),
);
}
/** Change a pull request's title or body; a field left undefined is left alone. */
async updatePullRequest(owner: string, repo: string, index: number, data: { title?: string; body?: string }): Promise<GiteaPull> {
indexOf(index);
return GiteaClient.mapPull(
await this.request<any>(`${repoPath(owner, repo)}/pulls/${index}`, { method: "PATCH", body: JSON.stringify(data) }),
);
}
/** The unified diff of a pull request, as text. */
async pullDiff(owner: string, repo: string, index: number): Promise<string> {
indexOf(index);
return this.requestText(`${repoPath(owner, repo)}/pulls/${index}.diff`);
}
/** Every comment on an issue or pull request, oldest first. */
async listComments(owner: string, repo: string, index: number): Promise<GiteaComment[]> {
indexOf(index);
const raw = await this.request<any[]>(`${repoPath(owner, repo)}/issues/${index}/comments`);
return (raw ?? []).map((c) => ({
id: Number(c?.id ?? 0),
user: c?.user?.login,
body: String(c?.body ?? ""),
created_at: c?.created_at,
html_url: String(c?.html_url ?? ""),
}));
}
/** One file's contents at a ref (default the repository's default branch), decoded. */
async getFile(owner: string, repo: string, path: string, ref?: string): Promise<{ path: string; ref?: string; sha: string; size: number; content: string }> {
const qs = ref ? `?ref=${encodeURIComponent(ref)}` : "";
const f = await this.request<any>(`${repoPath(owner, repo)}/contents/${pathSegments("file path", path.replace(/^\/+|\/+$/g, ""))}${qs}`);
if (!f || f.type !== "file") throw new Error(`Gitea API: ${path} is not a file`);
const content = f.encoding === "base64" ? Buffer.from(String(f.content ?? ""), "base64").toString("utf8") : String(f.content ?? "");
return { path, ref, sha: String(f.sha ?? ""), size: Number(f.size ?? content.length), content };
}
async listBranches(owner: string, repo: string): Promise<{ name: string; commit: string; protected: boolean }[]> {
const raw = await this.request<any[]>(`${repoPath(owner, repo)}/branches?limit=100`);
return (raw ?? []).map((b) => ({ name: String(b?.name ?? ""), commit: String(b?.commit?.id ?? ""), protected: Boolean(b?.protected) }));
}
async deleteBranch(owner: string, repo: string, branch: string): Promise<void> {
await this.request(`${repoPath(owner, repo)}/branches/${pathSegments("branch", branch)}`, { method: "DELETE" });
}
/** A request whose answer is text, not JSON — a diff. Same token handling as request(). */
private async requestText(path: string): Promise<string> {
const token = await this.tokens.current();
const res = await this.send(path, { headers: { Accept: "text/plain" } }, token);
if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`);
return res.text();
}
/** Merge a pull request — never forced past its checks, and, given the head that was checked, only that head:
* the forge refuses the merge when the branch moved since (novox/hq ADR 0266). */
async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false,
headCommit?: string): Promise<void> {
indexOf(index);
await this.request(`${repoPath(owner, repo)}/pulls/${index}/merge`, {
method: "POST",
body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch, force_merge: false,
...(headCommit ? { head_commit_id: headCommit } : {}) }),
});
}
// ---- 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,
updated_at: r.updated_at ?? undefined,
};
}
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: headBranchOf(p.head),
head_sha: p.head?.sha ?? undefined,
base: p.base?.ref,
updated_at: p.updated_at ?? undefined,
html_url: p.html_url,
body: typeof p.body === "string" ? p.body : undefined,
};
}
}
/** 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<AdminResponse> {
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: this.authorization,
...(options.headers as Record<string, string> | 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<void> {
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<number> {
// 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<number | null> {
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<void> {
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<void> {
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<boolean> {
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);
}
/** Withdraw a user and keep everything they own: login prohibited, which ensureUser undoes. */
async prohibitLogin(username: string): Promise<void> {
const res = await this.request(`/admin/users/${encodeURIComponent(username)}`, {
method: "PATCH",
body: JSON.stringify({ login_name: username, prohibit_login: true }),
});
if (res.status === 200 || res.status === 404) return;
GiteaAdmin.fail(`/admin/users/${username}`, res);
}
/** 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<void> {
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);
}
}
/** A pull request's or issue's number, refused unless it is one: a tool asked with a misnamed argument hands over
* `NaN`, and the forge was asked about `pulls/NaN` (novox/hq issue 347). */
export function indexOf(index: number): number {
if (!Number.isInteger(index) || index <= 0) {
throw new Error(`a pull request or issue number is a whole number above 0, and ${String(index)} was given — ` +
"is the argument named \`number\`? Nothing was asked of the forge");
}
return index;
}
/** The repositories that moved at or after a moment: every one when there is no moment yet, and one whose
* update time is not known, so a forge that does not say is asked as before (novox/hq issue 250). */
export function movedSince(repos: GiteaRepo[], floor: string): GiteaRepo[] {
if (!floor) return repos;
const at = Date.parse(floor);
return repos.filter((r) => !r.updated_at || !(Date.parse(r.updated_at) < at));
}
/** Every directory above these files, from the repository's root, the root itself left out; sorted. */
export function directoriesAbove(paths: string[]): string[] {
const dirs = new Set<string>();
for (const raw of paths) {
const parts = raw.replace(/^\/+/, "").split("/");
for (let i = 1; i < parts.length; i++) {
const dir = parts.slice(0, i).join("/");
if (dir !== "") dirs.add(dir);
}
}
return [...dirs].sort();
}
/** A repository path for the forge's URL: each segment escaped, the slashes kept. */
function encodePath(path: string): string {
return pathSegments("file path", path);
}