Merge pull request 'Convert gitlab — the tools-only external-SaaS exemplar (23 tools, lab-proven)' (#10) from feat/convert-gitlab into main
This commit was merged in pull request #10.
This commit is contained in:
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user