Convert gitlab — the tools-only external-SaaS exemplar (23 tools, lab-proven) #10

Merged
jschoubben merged 1 commits from feat/convert-gitlab into main 2026-09-05 23:40:31 +00:00
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"
]
}