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 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<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,
|
|
};
|
|
}
|
|
}
|