Nineteen modules gain a broker-bound runtime container that serves the module's tools under its own scoped account: bazarr, gitea, grafana, home-assistant, icecast, influxdb, jackett, keycloak, mailu, nextcloud, nodered, nzbget, ombi, photos, portainer, qbittorrent, searxng, tautulli, verdaccio. Config is the assignment's, not the manifest's (ADR 0051): each client's fromEnv overlays a settings-merged config file (MESH_<M>_CONFIG_FILE) over its env fallbacks, so URL and credentials come from `settings set`, with the URL defaulting to the server on the node. nextcloud and mailu also mount the docker socket for their exec-based tools. Proven in the mesh-lab: assigned-grafana green — settings deliver the URL and token, the runtime reads the merged config and serves grafana's tools under the scoped account, with nothing in the manifest. Two gaps this surfaced are filed as hq issues 008 (a provider runtime's seal key) and 009 (a settings change does not restart a container runtime). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
252 lines
8.5 KiB
TypeScript
252 lines
8.5 KiB
TypeScript
// The Gitea API client — gitea's own code, living in the module (novox/hq ADR 0044). 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";
|
|
|
|
/** A repository, trimmed to what the mesh cares about. */
|
|
export interface GiteaRepo {
|
|
full_name: 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;
|
|
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 0051): { 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 cachedUsername: string | null = null;
|
|
|
|
constructor(
|
|
url: string,
|
|
private readonly token: string,
|
|
) {
|
|
this.baseUrl = url.replace(/\/+$/, "");
|
|
}
|
|
|
|
/**
|
|
* Build from the module's resolved environment. URL and token come from MESH_GITEA_URL /
|
|
* MESH_GITEA_TOKEN (the mesh's own names), falling back to the bare GITEA_* names and, for the
|
|
* URL, to the forge's loopback port. A token is required — without one there is no authenticated
|
|
* call to make, so this throws 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 token = cfg.token ?? env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN;
|
|
if (!token) throw new Error("no Gitea token — set MESH_GITEA_TOKEN");
|
|
return new GiteaClient(url, token);
|
|
}
|
|
|
|
private async request<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
|
|
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
|
|
...options,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `token ${this.token}`,
|
|
...(options.headers as Record<string, string> | undefined),
|
|
},
|
|
});
|
|
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;
|
|
}
|
|
|
|
/** 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 ----
|
|
|
|
async listRepos(page = 1, limit = 20): Promise<GiteaRepo[]> {
|
|
const repos = await this.request<any[]>(`/user/repos?page=${page}&limit=${limit}`);
|
|
return (repos ?? []).map(GiteaClient.mapRepo);
|
|
}
|
|
|
|
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,
|
|
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),
|
|
user: p.user?.login,
|
|
head: p.head?.ref,
|
|
base: p.base?.ref,
|
|
html_url: p.html_url,
|
|
};
|
|
}
|
|
}
|