/user/repos lists what the token's user owns, which for the mesh's administrator is nothing — so the forge module watched an empty list and never announced a merge. It reads the forge's whole view through the search endpoint, every page.
493 lines
21 KiB
TypeScript
493 lines
21 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";
|
|
|
|
/** 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<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);
|
|
}
|
|
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> {
|
|
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(`/repos/${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[]>(`/repos/${owner}/${repo}/issues?${qs}`);
|
|
return (issues ?? []).map(GiteaClient.mapIssue);
|
|
}
|
|
|
|
async getIssue(owner: string, repo: string, index: number): Promise<GiteaIssue> {
|
|
return GiteaClient.mapIssue(await this.request<any>(`/repos/${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>(`/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<GiteaIssue> {
|
|
return GiteaClient.mapIssue(
|
|
await this.request<any>(`/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<any>(`/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<GiteaLabel[]> {
|
|
const labels = await this.request<any[]>(`/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<GiteaLabel> {
|
|
const l = await this.request<any>(`/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<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[]>(`/repos/${owner}/${repo}/pulls?${qs}`);
|
|
return (prs ?? []).map(GiteaClient.mapPull);
|
|
}
|
|
|
|
async getPullRequest(owner: string, repo: string, index: number): Promise<GiteaPull> {
|
|
return GiteaClient.mapPull(await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`));
|
|
}
|
|
|
|
async createPullRequest(
|
|
owner: string,
|
|
repo: string,
|
|
data: { title: string; body?: string; head: string; base: string },
|
|
): Promise<GiteaPull> {
|
|
return GiteaClient.mapPull(
|
|
await this.request<any>(`/repos/${owner}/${repo}/pulls`, { method: "POST", body: JSON.stringify(data) }),
|
|
);
|
|
}
|
|
|
|
async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false): Promise<void> {
|
|
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<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);
|
|
}
|
|
|
|
/** 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);
|
|
}
|
|
}
|