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
This commit is contained in:
2026-09-06 01:33:43 +02:00
parent 09b140fe86
commit 7eb156a82a
5 changed files with 642 additions and 0 deletions
+241
View File
@@ -0,0 +1,241 @@
// 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 {};
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"module": "gitlab",
"version": "1",
"own-secrets": {
"token": "/var/lib/gitlab/token",
"broker": "/var/lib/mesh/gitlab/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/gitlab",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"path": "/var/lib/gitlab",
"mode": "0700"
},
{
"id": "config",
"type": "file",
"path": "/var/lib/gitlab/config.json",
"merge": "json",
"content": "{}",
"mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-runtime-gitlab",
"image": "mesh-runtime-gitlab@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/gitlab/config.json:/run/config/config.json:ro",
"/var/lib/gitlab/token:/run/secrets/token:ro",
"/var/lib/mesh/gitlab/broker:/run/secrets/broker:ro"
],
"env": {
"MESH_GITLAB_TOKEN_FILE": "/run/secrets/token",
"MESH_GITLAB_CONFIG_FILE": "/run/config/config.json",
"MESH_BROKER_FILE": "/run/secrets/broker"
}
}
],
"capabilities": [
"container-runtime"
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-gitlab",
"version": "0.1.0",
"description": "gitlab — a tools-only, outbound-only GitLab SaaS integration (novox/hq ADR 0039): its API client and tools live here.",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+322
View File
@@ -0,0 +1,322 @@
// gitlab's tools — gitlab's own code (novox/hq ADR 0039), importing gitlab's own GitLab API client.
// They return structured data; the mesh serves them through the sdk's tool harness. gitlab is
// tools-only and outbound-only: no service, no events, no listener — it reaches out to a GitLab
// instance and exposes its projects, merge requests, pipelines, jobs and CI/CD variables.
//
// Every tool is registered unconditionally, even with no token configured (the Servarr lesson): the
// client is built lazily and never throws, so the runtime always comes up and serves the full tool
// surface — a call made before the URL/token are set fails with a clear error, but the runtime does
// not refuse to serve. The install proof is the runtime logging `[mesh-tools] serving N tool(s)`.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { GitLabClient } from "../client.js";
/** Coerce a project/group identifier: a numeric id stays a number, a path stays a string. */
function id(value: unknown): number | string {
const s = String(value ?? "");
return /^\d+$/.test(s) ? Number(s) : s;
}
function num(value: unknown): number {
return Number(value);
}
function str(value: unknown): string {
return String(value ?? "");
}
/** Optional CI/CD variable fields, forwarded only when the caller supplied them. */
function variableOptions(args: Readonly<Record<string, unknown>>): {
protected?: boolean;
masked?: boolean;
environment_scope?: string;
} {
const out: { protected?: boolean; masked?: boolean; environment_scope?: string } = {};
if (args.protected !== undefined) out.protected = Boolean(args.protected);
if (args.masked !== undefined) out.masked = Boolean(args.masked);
if (args.environment_scope !== undefined) out.environment_scope = str(args.environment_scope);
return out;
}
const project = { type: "string", description: "Project ID or URL-encoded path (e.g. 'group/project')" };
const group = { type: "string", description: "Group ID or URL-encoded path" };
export function getGitLabTools(gitlab: GitLabClient): ToolDefinition[] {
return [
// --- Projects ---
{
name: "gitlab_list_projects",
description: "List GitLab projects, with optional search.",
input: {
search: { type: "string", description: "search query for project name" },
page: { type: "number", description: "page number (default 1)" },
per_page: { type: "number", description: "results per page (default 20)" },
},
run: async (args) => {
const params: Record<string, string> = {
page: String(args.page ?? 1),
per_page: String(args.per_page ?? 20),
};
if (args.search) params.search = str(args.search);
return { projects: await gitlab.listProjects(params) };
},
},
{
name: "gitlab_get_project",
description: "Get a single GitLab project by ID or path (e.g. 'group/project').",
input: { project_id: project },
run: async (args) => ({ project: await gitlab.getProject(id(args.project_id)) }),
},
// --- Merge Requests ---
{
name: "gitlab_list_merge_requests",
description: "List merge requests for a GitLab project.",
input: {
project_id: project,
state: { type: "string", description: "opened, closed, merged or all (default opened)" },
author_username: { type: "string", description: "filter by author username" },
page: { type: "number", description: "page number (default 1)" },
per_page: { type: "number", description: "results per page (default 20)" },
},
run: async (args) => {
const params: Record<string, string> = {
state: str(args.state || "opened"),
page: String(args.page ?? 1),
per_page: String(args.per_page ?? 20),
};
if (args.author_username) params.author_username = str(args.author_username);
return { merge_requests: await gitlab.listMergeRequests(id(args.project_id), params) };
},
},
{
name: "gitlab_get_merge_request",
description: "Get a single merge request by IID.",
input: { project_id: project, mr_iid: { type: "number", description: "merge request IID" } },
run: async (args) => ({ merge_request: await gitlab.getMergeRequest(id(args.project_id), num(args.mr_iid)) }),
},
{
name: "gitlab_create_merge_request",
description: "Create a new merge request.",
input: {
project_id: project,
source_branch: { type: "string", description: "source branch" },
target_branch: { type: "string", description: "target branch" },
title: { type: "string", description: "MR title" },
description: { type: "string", description: "MR description (optional)" },
},
run: async (args) => ({
merge_request: await gitlab.createMergeRequest(id(args.project_id), {
source_branch: str(args.source_branch),
target_branch: str(args.target_branch),
title: str(args.title),
description: args.description !== undefined ? str(args.description) : undefined,
}),
}),
},
{
name: "gitlab_approve_merge_request",
description: "Approve a merge request.",
input: { project_id: project, mr_iid: { type: "number", description: "merge request IID" } },
run: async (args) => ({ approved: await gitlab.approveMergeRequest(id(args.project_id), num(args.mr_iid)) }),
},
{
name: "gitlab_add_mr_note",
description: "Add a comment/note to a merge request.",
input: {
project_id: project,
mr_iid: { type: "number", description: "merge request IID" },
body: { type: "string", description: "note content" },
},
run: async (args) => ({ note: await gitlab.addMergeRequestNote(id(args.project_id), num(args.mr_iid), str(args.body)) }),
},
// --- Pipelines ---
{
name: "gitlab_list_pipelines",
description: "List pipelines for a GitLab project.",
input: {
project_id: project,
ref: { type: "string", description: "filter by branch/tag name" },
status: { type: "string", description: "filter by status (running, pending, success, failed, ...)" },
page: { type: "number", description: "page number (default 1)" },
per_page: { type: "number", description: "results per page (default 20)" },
},
run: async (args) => {
const params: Record<string, string> = {
page: String(args.page ?? 1),
per_page: String(args.per_page ?? 20),
};
if (args.ref) params.ref = str(args.ref);
if (args.status) params.status = str(args.status);
return { pipelines: await gitlab.listPipelines(id(args.project_id), params) };
},
},
{
name: "gitlab_get_pipeline",
description: "Get details of a specific pipeline.",
input: { project_id: project, pipeline_id: { type: "number", description: "pipeline ID" } },
run: async (args) => ({ pipeline: await gitlab.getPipeline(id(args.project_id), num(args.pipeline_id)) }),
},
{
name: "gitlab_retry_pipeline",
description: "Retry a failed pipeline.",
input: { project_id: project, pipeline_id: { type: "number", description: "pipeline ID" } },
run: async (args) => ({ pipeline: await gitlab.retryPipeline(id(args.project_id), num(args.pipeline_id)) }),
},
{
name: "gitlab_cancel_pipeline",
description: "Cancel a running pipeline.",
input: { project_id: project, pipeline_id: { type: "number", description: "pipeline ID" } },
run: async (args) => ({ pipeline: await gitlab.cancelPipeline(id(args.project_id), num(args.pipeline_id)) }),
},
{
name: "gitlab_list_pipeline_jobs",
description: "List jobs for a specific pipeline.",
input: { project_id: project, pipeline_id: { type: "number", description: "pipeline ID" } },
run: async (args) => ({ jobs: await gitlab.listPipelineJobs(id(args.project_id), num(args.pipeline_id)) }),
},
{
name: "gitlab_get_job_log",
description: "Get the log/trace output of a specific job (truncated to the last 2000 lines).",
input: { project_id: project, job_id: { type: "number", description: "job ID" } },
run: async (args) => {
const log = await gitlab.getJobLog(id(args.project_id), num(args.job_id));
const lines = log.split("\n");
const truncated = lines.length > 2000 ? lines.slice(-2000).join("\n") : log;
return { log: truncated, truncated: lines.length > 2000 };
},
},
// --- Project variables ---
{
name: "gitlab_list_project_variables",
description: "List CI/CD variables for a project.",
input: { project_id: project },
run: async (args) => ({ variables: await gitlab.listProjectVariables(id(args.project_id)) }),
},
{
name: "gitlab_get_project_variable",
description: "Get a single project CI/CD variable.",
input: { project_id: project, key: { type: "string", description: "variable key" } },
run: async (args) => ({ variable: await gitlab.getProjectVariable(id(args.project_id), str(args.key)) }),
},
{
name: "gitlab_create_project_variable",
description: "Create a new project CI/CD variable.",
input: {
project_id: project,
key: { type: "string", description: "variable key" },
value: { type: "string", description: "variable value" },
protected: { type: "boolean", description: "protected (default false)" },
masked: { type: "boolean", description: "masked (default false)" },
environment_scope: { type: "string", description: "environment scope (default *)" },
},
run: async (args) => ({
variable: await gitlab.createProjectVariable(id(args.project_id), {
key: str(args.key),
value: str(args.value),
...variableOptions(args),
}),
}),
},
{
name: "gitlab_update_project_variable",
description: "Update an existing project CI/CD variable.",
input: {
project_id: project,
key: { type: "string", description: "variable key" },
value: { type: "string", description: "new value" },
protected: { type: "boolean", description: "protected (optional)" },
masked: { type: "boolean", description: "masked (optional)" },
environment_scope: { type: "string", description: "environment scope (optional)" },
},
run: async (args) => ({
variable: await gitlab.updateProjectVariable(id(args.project_id), str(args.key), {
value: str(args.value),
...variableOptions(args),
}),
}),
},
{
name: "gitlab_delete_project_variable",
description: "Delete a project CI/CD variable.",
input: { project_id: project, key: { type: "string", description: "variable key" } },
run: async (args) => {
await gitlab.deleteProjectVariable(id(args.project_id), str(args.key));
return { deleted: true, key: str(args.key) };
},
},
// --- Group variables ---
{
name: "gitlab_list_group_variables",
description: "List CI/CD variables for a group.",
input: { group_id: group },
run: async (args) => ({ variables: await gitlab.listGroupVariables(id(args.group_id)) }),
},
{
name: "gitlab_get_group_variable",
description: "Get a single group CI/CD variable.",
input: { group_id: group, key: { type: "string", description: "variable key" } },
run: async (args) => ({ variable: await gitlab.getGroupVariable(id(args.group_id), str(args.key)) }),
},
{
name: "gitlab_create_group_variable",
description: "Create a new group CI/CD variable.",
input: {
group_id: group,
key: { type: "string", description: "variable key" },
value: { type: "string", description: "variable value" },
protected: { type: "boolean", description: "protected (default false)" },
masked: { type: "boolean", description: "masked (default false)" },
environment_scope: { type: "string", description: "environment scope (default *)" },
},
run: async (args) => ({
variable: await gitlab.createGroupVariable(id(args.group_id), {
key: str(args.key),
value: str(args.value),
...variableOptions(args),
}),
}),
},
{
name: "gitlab_update_group_variable",
description: "Update an existing group CI/CD variable.",
input: {
group_id: group,
key: { type: "string", description: "variable key" },
value: { type: "string", description: "new value" },
protected: { type: "boolean", description: "protected (optional)" },
masked: { type: "boolean", description: "masked (optional)" },
environment_scope: { type: "string", description: "environment scope (optional)" },
},
run: async (args) => ({
variable: await gitlab.updateGroupVariable(id(args.group_id), str(args.key), {
value: str(args.value),
...variableOptions(args),
}),
}),
},
{
name: "gitlab_delete_group_variable",
description: "Delete a group CI/CD variable.",
input: { group_id: group, key: { type: "string", description: "variable key" } },
run: async (args) => {
await gitlab.deleteGroupVariable(id(args.group_id), str(args.key));
return { deleted: true, key: str(args.key) };
},
},
];
}
// gitlab always registers its full tool surface: the client is built lazily and never throws, so the
// runtime comes up and serves every tool even before a URL/token is configured (the lab has no real
// GitLab). A tool called before the module is configured fails with a clear error from that call.
registerModuleTools("gitlab", (env) => {
try {
return getGitLabTools(GitLabClient.fromEnv(env));
} catch {
return [];
}
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": [
"client.ts",
"tools/index.ts"
]
}