diff --git a/modules/gitlab/client.ts b/modules/gitlab/client.ts new file mode 100644 index 0000000..caa9fca --- /dev/null +++ b/modules/gitlab/client.ts @@ -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(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 {}; + } +} diff --git a/modules/gitlab/module.json b/modules/gitlab/module.json new file mode 100644 index 0000000..c580600 --- /dev/null +++ b/modules/gitlab/module.json @@ -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" + ] +} diff --git a/modules/gitlab/package.json b/modules/gitlab/package.json new file mode 100644 index 0000000..15f5e8a --- /dev/null +++ b/modules/gitlab/package.json @@ -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" + } +} diff --git a/modules/gitlab/tools/index.ts b/modules/gitlab/tools/index.ts new file mode 100644 index 0000000..9f6a516 --- /dev/null +++ b/modules/gitlab/tools/index.ts @@ -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>): { + 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 = { + 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 = { + 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 = { + 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 []; + } +}); diff --git a/modules/gitlab/tsconfig.json b/modules/gitlab/tsconfig.json new file mode 100644 index 0000000..1f1b70a --- /dev/null +++ b/modules/gitlab/tsconfig.json @@ -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" + ] +}