Files
mesh-catalog/modules/gitlab/client.ts
jschoubben 7eb156a82a Convert gitlab into a tools-only mesh catalog module
Port the HAL gitlab module (whose tools lived in @hal/sdk) into a
self-contained mesh-catalog module modelled on cloudflare-dns: the GitLab
API client and all its tools live in the module (ADR 0039), served through
mesh-sdk's registerModuleTools harness.

Tools-only, outbound-only external-SaaS shape: a runtime-only container on
network:host, no service, no listener, no provisioner. The token is an
own-secret; GITLAB_URL is a public setting in a merge:json config file.

The client is built lazily and never throws at registration, so the runtime
comes up and serves all 23 tools even with no valid token (the Servarr
lesson) — it only fails when a tool is actually invoked unconfigured.

Ported 23 tools: projects (list, get), merge requests (list, get, create,
approve, add note), pipelines (list, get, retry, cancel, list jobs, job log),
and project + group CI/CD variables (list, get, create, update, delete each).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-06 01:33:43 +02:00

242 lines
9.5 KiB
TypeScript

// gitlab's own GitLab API client — its own code, living in the module (novox/hq ADR 0039). Ported
// from the hal sdk's shared GitLabClient, where a change to the GitLab API rebuilt everything; here
// it rebuilds only gitlab. This module's tools import it, and nothing outside gitlab does.
//
// gitlab is a tools-only, outbound-only integration with an external SaaS: it holds no service of
// its own, listens for nothing, and only ever calls out to a GitLab instance over its REST v4 API,
// authenticated with a personal/project access token (the PRIVATE-TOKEN header).
//
// The client is built lazily and NEVER throws at construction (the Servarr lesson): the runtime must
// come up and register every tool even with no valid token — the lab has no real GitLab. A missing
// URL or token surfaces only when a tool is actually invoked, as a clear error from that one call,
// not as a runtime that refuses to serve.
import { readFileSync } from "node:fs";
export class GitLabClient {
constructor(
/** The GitLab base URL, e.g. "https://gitlab.example.com". A public setting (config file). */
private readonly url: string | undefined,
/** The access token — gitlab's one secret (own-secret). */
private readonly token: string | undefined,
) {}
static fromEnv(env: NodeJS.ProcessEnv = process.env): GitLabClient {
// The URL is a mesh's own fact, not this module's — a setting, merged into a config file the mesh
// manages (novox/hq ADR 0046) under the key GITLAB_URL, read here. The token is the one secret and
// stays an own-secret, read from its file. Env is honoured as a fallback for a hand-run instance.
// Neither absence throws: the client still constructs, so every tool still registers and serves.
const config = readConfig(env.MESH_GITLAB_CONFIG_FILE);
const url = config.GITLAB_URL ?? env.MESH_GITLAB_URL ?? env.GITLAB_URL;
const token = env.MESH_GITLAB_TOKEN ?? readSecret(env.MESH_GITLAB_TOKEN_FILE);
return new GitLabClient(url, token);
}
/** Whether the module is configured enough to make a call. */
configured(): boolean {
return Boolean(this.url && this.token);
}
private baseUrl(): string {
if (!this.url || !this.token) {
throw new Error(
"gitlab is not configured — set its URL in settings (GITLAB_URL) and its token as its " +
"own-secret; until then it answers no calls",
);
}
return this.url.replace(/\/+$/, "");
}
private encodeProject(id: number | string): string {
return typeof id === "number" ? String(id) : encodeURIComponent(id);
}
private async request<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${this.baseUrl()}/api/v4${path}`, {
...options,
headers: {
"Content-Type": "application/json",
"PRIVATE-TOKEN": this.token!,
...(options.headers as Record<string, string>),
},
});
if (!res.ok) {
throw new Error(`GitLab API error ${res.status}: ${await res.text()}`);
}
if (res.status === 204) return null as T;
return res.json() as Promise<T>;
}
private async requestText(path: string): Promise<string> {
const res = await fetch(`${this.baseUrl()}/api/v4${path}`, {
headers: { "PRIVATE-TOKEN": this.token! },
});
if (!res.ok) {
throw new Error(`GitLab API error ${res.status}: ${await res.text()}`);
}
return res.text();
}
// --- Projects ---
async listProjects(params: Record<string, string> = {}): Promise<unknown[]> {
return this.request(`/projects?${new URLSearchParams(params).toString()}`);
}
async getProject(id: number | string): Promise<unknown> {
return this.request(`/projects/${this.encodeProject(id)}`);
}
// --- Merge Requests ---
async listMergeRequests(projectId: number | string, params: Record<string, string> = {}): Promise<unknown[]> {
return this.request(`/projects/${this.encodeProject(projectId)}/merge_requests?${new URLSearchParams(params).toString()}`);
}
async getMergeRequest(projectId: number | string, mrIid: number): Promise<unknown> {
return this.request(`/projects/${this.encodeProject(projectId)}/merge_requests/${mrIid}`);
}
async createMergeRequest(
projectId: number | string,
data: { source_branch: string; target_branch: string; title: string; description?: string },
): Promise<unknown> {
return this.request(`/projects/${this.encodeProject(projectId)}/merge_requests`, {
method: "POST",
body: JSON.stringify(data),
});
}
async approveMergeRequest(projectId: number | string, mrIid: number): Promise<unknown> {
return this.request(`/projects/${this.encodeProject(projectId)}/merge_requests/${mrIid}/approve`, { method: "POST" });
}
async addMergeRequestNote(projectId: number | string, mrIid: number, body: string): Promise<unknown> {
return this.request(`/projects/${this.encodeProject(projectId)}/merge_requests/${mrIid}/notes`, {
method: "POST",
body: JSON.stringify({ body }),
});
}
// --- Pipelines ---
async listPipelines(projectId: number | string, params: Record<string, string> = {}): Promise<unknown[]> {
return this.request(`/projects/${this.encodeProject(projectId)}/pipelines?${new URLSearchParams(params).toString()}`);
}
async getPipeline(projectId: number | string, pipelineId: number): Promise<unknown> {
return this.request(`/projects/${this.encodeProject(projectId)}/pipelines/${pipelineId}`);
}
async retryPipeline(projectId: number | string, pipelineId: number): Promise<unknown> {
return this.request(`/projects/${this.encodeProject(projectId)}/pipelines/${pipelineId}/retry`, { method: "POST" });
}
async cancelPipeline(projectId: number | string, pipelineId: number): Promise<unknown> {
return this.request(`/projects/${this.encodeProject(projectId)}/pipelines/${pipelineId}/cancel`, { method: "POST" });
}
async listPipelineJobs(projectId: number | string, pipelineId: number): Promise<unknown[]> {
return this.request(`/projects/${this.encodeProject(projectId)}/pipelines/${pipelineId}/jobs`);
}
async getJobLog(projectId: number | string, jobId: number): Promise<string> {
return this.requestText(`/projects/${this.encodeProject(projectId)}/jobs/${jobId}/trace`);
}
// --- Project variables ---
async listProjectVariables(projectId: number | string): Promise<unknown[]> {
return this.request(`/projects/${this.encodeProject(projectId)}/variables`);
}
async getProjectVariable(projectId: number | string, key: string): Promise<unknown> {
return this.request(`/projects/${this.encodeProject(projectId)}/variables/${encodeURIComponent(key)}`);
}
async createProjectVariable(
projectId: number | string,
data: { key: string; value: string; protected?: boolean; masked?: boolean; environment_scope?: string },
): Promise<unknown> {
return this.request(`/projects/${this.encodeProject(projectId)}/variables`, {
method: "POST",
body: JSON.stringify(data),
});
}
async updateProjectVariable(
projectId: number | string,
key: string,
data: { value: string; protected?: boolean; masked?: boolean; environment_scope?: string },
): Promise<unknown> {
return this.request(`/projects/${this.encodeProject(projectId)}/variables/${encodeURIComponent(key)}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async deleteProjectVariable(projectId: number | string, key: string): Promise<void> {
await this.request(`/projects/${this.encodeProject(projectId)}/variables/${encodeURIComponent(key)}`, { method: "DELETE" });
}
// --- Group variables ---
async listGroupVariables(groupId: number | string): Promise<unknown[]> {
return this.request(`/groups/${this.encodeProject(groupId)}/variables`);
}
async getGroupVariable(groupId: number | string, key: string): Promise<unknown> {
return this.request(`/groups/${this.encodeProject(groupId)}/variables/${encodeURIComponent(key)}`);
}
async createGroupVariable(
groupId: number | string,
data: { key: string; value: string; protected?: boolean; masked?: boolean; environment_scope?: string },
): Promise<unknown> {
return this.request(`/groups/${this.encodeProject(groupId)}/variables`, {
method: "POST",
body: JSON.stringify(data),
});
}
async updateGroupVariable(
groupId: number | string,
key: string,
data: { value: string; protected?: boolean; masked?: boolean; environment_scope?: string },
): Promise<unknown> {
return this.request(`/groups/${this.encodeProject(groupId)}/variables/${encodeURIComponent(key)}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async deleteGroupVariable(groupId: number | string, key: string): Promise<void> {
await this.request(`/groups/${this.encodeProject(groupId)}/variables/${encodeURIComponent(key)}`, { method: "DELETE" });
}
}
function readSecret(path: string | undefined): string | undefined {
if (!path) return undefined;
try {
return readFileSync(path, "utf8").trim();
} catch {
return undefined;
}
}
interface Config {
GITLAB_URL?: string;
}
/** The settings-managed config file (a JSON document the mesh merges settings into), holding the
* public GITLAB_URL setting. Absent or unparseable yields an empty config — the module then answers
* no calls until its URL and token are set, but still registers and serves every tool. */
function readConfig(path: string | undefined): Config {
if (!path) return {};
try {
return JSON.parse(readFileSync(path, "utf8")) as Config;
} catch {
return {};
}
}