// 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"; /** 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 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 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(path: string, options: RequestInit = {}): Promise { const res = await fetch(`${this.baseUrl}/api/v1${path}`, { ...options, headers: { "Content-Type": "application/json", Authorization: `token ${this.token}`, ...(options.headers as Record | 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(path: string, options: RequestInit = {}): Promise { return this.request(path, options); } // ---- Repositories ---- async listRepos(page = 1, limit = 20): Promise { const repos = await this.request(`/user/repos?page=${page}&limit=${limit}`); return (repos ?? []).map(GiteaClient.mapRepo); } 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, 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, }; } }