Files
mesh-catalog/modules/gitlab/tools/index.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

323 lines
14 KiB
TypeScript

// 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 [];
}
});