// 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(path: string, options: RequestInit = {}): Promise { const res = await fetch(`${this.baseUrl()}/api/v4${path}`, { ...options, headers: { "Content-Type": "application/json", "PRIVATE-TOKEN": this.token!, ...(options.headers as Record), }, }); 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; } private async requestText(path: string): Promise { 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 = {}): Promise { return this.request(`/projects?${new URLSearchParams(params).toString()}`); } async getProject(id: number | string): Promise { return this.request(`/projects/${this.encodeProject(id)}`); } // --- Merge Requests --- async listMergeRequests(projectId: number | string, params: Record = {}): Promise { return this.request(`/projects/${this.encodeProject(projectId)}/merge_requests?${new URLSearchParams(params).toString()}`); } async getMergeRequest(projectId: number | string, mrIid: number): Promise { 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 { return this.request(`/projects/${this.encodeProject(projectId)}/merge_requests`, { method: "POST", body: JSON.stringify(data), }); } async approveMergeRequest(projectId: number | string, mrIid: number): Promise { return this.request(`/projects/${this.encodeProject(projectId)}/merge_requests/${mrIid}/approve`, { method: "POST" }); } async addMergeRequestNote(projectId: number | string, mrIid: number, body: string): Promise { 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 = {}): Promise { return this.request(`/projects/${this.encodeProject(projectId)}/pipelines?${new URLSearchParams(params).toString()}`); } async getPipeline(projectId: number | string, pipelineId: number): Promise { return this.request(`/projects/${this.encodeProject(projectId)}/pipelines/${pipelineId}`); } async retryPipeline(projectId: number | string, pipelineId: number): Promise { return this.request(`/projects/${this.encodeProject(projectId)}/pipelines/${pipelineId}/retry`, { method: "POST" }); } async cancelPipeline(projectId: number | string, pipelineId: number): Promise { return this.request(`/projects/${this.encodeProject(projectId)}/pipelines/${pipelineId}/cancel`, { method: "POST" }); } async listPipelineJobs(projectId: number | string, pipelineId: number): Promise { return this.request(`/projects/${this.encodeProject(projectId)}/pipelines/${pipelineId}/jobs`); } async getJobLog(projectId: number | string, jobId: number): Promise { return this.requestText(`/projects/${this.encodeProject(projectId)}/jobs/${jobId}/trace`); } // --- Project variables --- async listProjectVariables(projectId: number | string): Promise { return this.request(`/projects/${this.encodeProject(projectId)}/variables`); } async getProjectVariable(projectId: number | string, key: string): Promise { 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 { 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 { return this.request(`/projects/${this.encodeProject(projectId)}/variables/${encodeURIComponent(key)}`, { method: "PUT", body: JSON.stringify(data), }); } async deleteProjectVariable(projectId: number | string, key: string): Promise { await this.request(`/projects/${this.encodeProject(projectId)}/variables/${encodeURIComponent(key)}`, { method: "DELETE" }); } // --- Group variables --- async listGroupVariables(groupId: number | string): Promise { return this.request(`/groups/${this.encodeProject(groupId)}/variables`); } async getGroupVariable(groupId: number | string, key: string): Promise { 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 { 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 { return this.request(`/groups/${this.encodeProject(groupId)}/variables/${encodeURIComponent(key)}`, { method: "PUT", body: JSON.stringify(data), }); } async deleteGroupVariable(groupId: number | string, key: string): Promise { 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 {}; } }