import * as plugins from './gitlab.plugins.js'; import { logger } from './gitlab.logging.js'; import type { IGitLabUser, IGitLabProject, IGitLabGroup, IGitLabVariable, IVariableOptions, IGitLabProtectedBranch, IGitLabBranch, IGitLabTag, IGitLabPipeline, IGitLabPipelineVariable, IGitLabTestReport, IGitLabJob, ITestConnectionResult, IListOptions, IPipelineListOptions, IJobListOptions, } from './gitlab.interfaces.js'; export class GitLabClient { private baseUrl: string; private token: string; constructor(baseUrl: string, token: string) { // Remove trailing slash if present this.baseUrl = baseUrl.replace(/\/+$/, ''); this.token = token; } // --------------------------------------------------------------------------- // HTTP helpers // --------------------------------------------------------------------------- private async request( method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, data?: any, customHeaders?: Record, ): Promise { const url = `${this.baseUrl}${path}`; let builder = plugins.smartrequest.SmartRequest.create() .url(url) .header('PRIVATE-TOKEN', this.token) .header('Content-Type', 'application/json'); if (customHeaders) { for (const [k, v] of Object.entries(customHeaders)) { builder = builder.header(k, v); } } if (data) { builder = builder.json(data); } let response: Awaited>; switch (method) { case 'GET': response = await builder.get(); break; case 'POST': response = await builder.post(); break; case 'PUT': response = await builder.put(); break; case 'DELETE': response = await builder.delete(); break; } if (!response.ok) { const errorText = await response.text(); throw new Error(`${method} ${path}: ${response.status} ${response.statusText} - ${errorText}`); } try { return await response.json() as T; } catch { return undefined as unknown as T; } } private async requestText( method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, ): Promise { const url = `${this.baseUrl}${path}`; let builder = plugins.smartrequest.SmartRequest.create() .url(url) .header('PRIVATE-TOKEN', this.token) .header('Accept', 'text/plain'); let response: Awaited>; switch (method) { case 'GET': response = await builder.get(); break; case 'POST': response = await builder.post(); break; case 'PUT': response = await builder.put(); break; case 'DELETE': response = await builder.delete(); break; } if (!response.ok) { const errorText = await response.text(); throw new Error(`${method} ${path}: ${response.status} ${response.statusText} - ${errorText}`); } return response.text(); } // --------------------------------------------------------------------------- // Connection // --------------------------------------------------------------------------- public async testConnection(): Promise { try { await this.request('GET', '/api/v4/user'); return { ok: true }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } } // --------------------------------------------------------------------------- // Groups — scoped queries // --------------------------------------------------------------------------- /** * Get a single group by its full path (e.g. "foss.global" or "foss.global/push.rocks") */ public async getGroupByPath(fullPath: string): Promise { return this.request( 'GET', `/api/v4/groups/${encodeURIComponent(fullPath)}`, ); } /** * List projects within a group (includes subgroups when include_subgroups=true) */ public async getGroupProjects(groupId: number | string, opts?: IListOptions): Promise { const page = opts?.page || 1; const perPage = opts?.perPage || 50; let url = `/api/v4/groups/${encodeURIComponent(groupId)}/projects?include_subgroups=true&order_by=updated_at&sort=desc&page=${page}&per_page=${perPage}`; if (opts?.search) { url += `&search=${encodeURIComponent(opts.search)}`; } return this.request('GET', url); } /** * List all descendant groups (recursive subgroups) within a group */ public async getDescendantGroups(groupId: number | string, opts?: IListOptions): Promise { const page = opts?.page || 1; const perPage = opts?.perPage || 50; let url = `/api/v4/groups/${encodeURIComponent(groupId)}/descendant_groups?order_by=name&sort=asc&page=${page}&per_page=${perPage}`; if (opts?.search) { url += `&search=${encodeURIComponent(opts.search)}`; } return this.request('GET', url); } /** * Create a new group. Optionally nested under a parent group. */ public async createGroup(name: string, path: string, parentId?: number): Promise { const body: any = { name, path, visibility: 'private' }; if (parentId) body.parent_id = parentId; return this.request('POST', '/api/v4/groups', body); } /** * Create a new project (repository). */ public async createProject(name: string, opts?: { path?: string; namespaceId?: number; visibility?: string; description?: string; }): Promise { return this.request('POST', '/api/v4/projects', { name, path: opts?.path || name, namespace_id: opts?.namespaceId, visibility: opts?.visibility || 'private', description: opts?.description || '', }); } // --------------------------------------------------------------------------- // Projects // --------------------------------------------------------------------------- public async getProjects(opts?: IListOptions): Promise { const page = opts?.page || 1; const perPage = opts?.perPage || 50; let url = `/api/v4/projects?membership=true&order_by=updated_at&sort=desc&page=${page}&per_page=${perPage}`; if (opts?.search) { url += `&search=${encodeURIComponent(opts.search)}`; } return this.request('GET', url); } // --------------------------------------------------------------------------- // Groups // --------------------------------------------------------------------------- public async getGroups(opts?: IListOptions): Promise { const page = opts?.page || 1; const perPage = opts?.perPage || 50; let url = `/api/v4/groups?order_by=name&sort=asc&page=${page}&per_page=${perPage}`; if (opts?.search) { url += `&search=${encodeURIComponent(opts.search)}`; } return this.request('GET', url); } // --------------------------------------------------------------------------- // Project Variables (CI/CD) // --------------------------------------------------------------------------- public async getProjectVariables(projectId: number | string): Promise { return this.request( 'GET', `/api/v4/projects/${encodeURIComponent(projectId)}/variables`, ); } public async createProjectVariable( projectId: number | string, key: string, value: string, opts?: IVariableOptions, ): Promise { return this.request( 'POST', `/api/v4/projects/${encodeURIComponent(projectId)}/variables`, { key, value, protected: opts?.protected ?? false, masked: opts?.masked ?? false, environment_scope: opts?.environment_scope ?? '*', }, ); } public async updateProjectVariable( projectId: number | string, key: string, value: string, opts?: IVariableOptions, ): Promise { const body: any = { value }; if (opts?.protected !== undefined) body.protected = opts.protected; if (opts?.masked !== undefined) body.masked = opts.masked; if (opts?.environment_scope !== undefined) body.environment_scope = opts.environment_scope; return this.request( 'PUT', `/api/v4/projects/${encodeURIComponent(projectId)}/variables/${encodeURIComponent(key)}`, body, ); } public async deleteProjectVariable(projectId: number | string, key: string): Promise { await this.request( 'DELETE', `/api/v4/projects/${encodeURIComponent(projectId)}/variables/${encodeURIComponent(key)}`, ); } // --------------------------------------------------------------------------- // Group Variables (CI/CD) // --------------------------------------------------------------------------- public async getGroupVariables(groupId: number | string): Promise { return this.request( 'GET', `/api/v4/groups/${encodeURIComponent(groupId)}/variables`, ); } public async createGroupVariable( groupId: number | string, key: string, value: string, opts?: IVariableOptions, ): Promise { return this.request( 'POST', `/api/v4/groups/${encodeURIComponent(groupId)}/variables`, { key, value, protected: opts?.protected ?? false, masked: opts?.masked ?? false, environment_scope: opts?.environment_scope ?? '*', }, ); } public async updateGroupVariable( groupId: number | string, key: string, value: string, opts?: IVariableOptions, ): Promise { const body: any = { value }; if (opts?.protected !== undefined) body.protected = opts.protected; if (opts?.masked !== undefined) body.masked = opts.masked; if (opts?.environment_scope !== undefined) body.environment_scope = opts.environment_scope; return this.request( 'PUT', `/api/v4/groups/${encodeURIComponent(groupId)}/variables/${encodeURIComponent(key)}`, body, ); } public async deleteGroupVariable(groupId: number | string, key: string): Promise { await this.request( 'DELETE', `/api/v4/groups/${encodeURIComponent(groupId)}/variables/${encodeURIComponent(key)}`, ); } // --------------------------------------------------------------------------- // Pipelines // --------------------------------------------------------------------------- /** * List pipelines for a project with optional filters. * Supports status, ref, source, scope, username, date range, ordering. */ public async getPipelines(projectId: number | string, opts?: IPipelineListOptions): Promise { const page = opts?.page || 1; const perPage = opts?.perPage || 30; const orderBy = opts?.orderBy || 'updated_at'; const sort = opts?.sort || 'desc'; let url = `/api/v4/projects/${encodeURIComponent(projectId)}/pipelines?page=${page}&per_page=${perPage}&order_by=${orderBy}&sort=${sort}`; if (opts?.status) url += `&status=${encodeURIComponent(opts.status)}`; if (opts?.ref) url += `&ref=${encodeURIComponent(opts.ref)}`; if (opts?.source) url += `&source=${encodeURIComponent(opts.source)}`; if (opts?.scope) url += `&scope=${encodeURIComponent(opts.scope)}`; if (opts?.username) url += `&username=${encodeURIComponent(opts.username)}`; if (opts?.updatedAfter) url += `&updated_after=${encodeURIComponent(opts.updatedAfter)}`; if (opts?.updatedBefore) url += `&updated_before=${encodeURIComponent(opts.updatedBefore)}`; return this.request('GET', url); } /** * Get a single pipeline's full details. */ public async getPipeline(projectId: number | string, pipelineId: number): Promise { return this.request( 'GET', `/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}`, ); } /** * Trigger a new pipeline on the given ref, optionally with variables. */ public async triggerPipeline( projectId: number | string, ref: string, variables?: { key: string; value: string; variable_type?: string }[], ): Promise { const body: any = { ref }; if (variables && variables.length > 0) { body.variables = variables; } return this.request( 'POST', `/api/v4/projects/${encodeURIComponent(projectId)}/pipeline`, body, ); } /** * Delete a pipeline and all its jobs. */ public async deletePipeline(projectId: number | string, pipelineId: number): Promise { await this.request( 'DELETE', `/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}`, ); } /** * Get variables used in a specific pipeline run. */ public async getPipelineVariables(projectId: number | string, pipelineId: number): Promise { return this.request( 'GET', `/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}/variables`, ); } /** * Get the test report for a pipeline. */ public async getPipelineTestReport(projectId: number | string, pipelineId: number): Promise { return this.request( 'GET', `/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}/test_report`, ); } public async retryPipeline(projectId: number | string, pipelineId: number): Promise { return this.request( 'POST', `/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}/retry`, ); } public async cancelPipeline(projectId: number | string, pipelineId: number): Promise { return this.request( 'POST', `/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}/cancel`, ); } // --------------------------------------------------------------------------- // Jobs // --------------------------------------------------------------------------- /** * List jobs for a pipeline with optional scope filter and pagination. */ public async getPipelineJobs(projectId: number | string, pipelineId: number, opts?: IJobListOptions): Promise { const page = opts?.page || 1; const perPage = opts?.perPage || 100; let url = `/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}/jobs?page=${page}&per_page=${perPage}`; if (opts?.scope && opts.scope.length > 0) { for (const s of opts.scope) { url += `&scope[]=${encodeURIComponent(s)}`; } } return this.request('GET', url); } /** * Get a single job's full details. */ public async getJob(projectId: number | string, jobId: number): Promise { return this.request( 'GET', `/api/v4/projects/${encodeURIComponent(projectId)}/jobs/${jobId}`, ); } /** * Get a job's raw log (trace) output. */ public async getJobLog(projectId: number | string, jobId: number): Promise { return this.requestText( 'GET', `/api/v4/projects/${encodeURIComponent(projectId)}/jobs/${jobId}/trace`, ); } /** * Retry a single job. */ public async retryJob(projectId: number | string, jobId: number): Promise { return this.request( 'POST', `/api/v4/projects/${encodeURIComponent(projectId)}/jobs/${jobId}/retry`, ); } /** * Cancel a running job. */ public async cancelJob(projectId: number | string, jobId: number): Promise { return this.request( 'POST', `/api/v4/projects/${encodeURIComponent(projectId)}/jobs/${jobId}/cancel`, ); } /** * Trigger a manual job (play action). */ public async playJob(projectId: number | string, jobId: number): Promise { return this.request( 'POST', `/api/v4/projects/${encodeURIComponent(projectId)}/jobs/${jobId}/play`, ); } /** * Erase a job's trace and artifacts. */ public async eraseJob(projectId: number | string, jobId: number): Promise { await this.request( 'POST', `/api/v4/projects/${encodeURIComponent(projectId)}/jobs/${jobId}/erase`, ); } // --------------------------------------------------------------------------- // Repository Branches & Tags // --------------------------------------------------------------------------- public async getRepoBranches(projectId: number | string, opts?: IListOptions): Promise { const page = opts?.page || 1; const perPage = opts?.perPage || 50; return this.request( 'GET', `/api/v4/projects/${encodeURIComponent(projectId)}/repository/branches?page=${page}&per_page=${perPage}`, ); } public async getRepoTags(projectId: number | string, opts?: IListOptions): Promise { const page = opts?.page || 1; const perPage = opts?.perPage || 50; return this.request( 'GET', `/api/v4/projects/${encodeURIComponent(projectId)}/repository/tags?page=${page}&per_page=${perPage}`, ); } // --------------------------------------------------------------------------- // Protected Branches // --------------------------------------------------------------------------- public async getProtectedBranches(projectId: number | string): Promise { return this.request( 'GET', `/api/v4/projects/${encodeURIComponent(projectId)}/protected_branches`, ); } public async unprotectBranch(projectId: number | string, branchName: string): Promise { await this.request( 'DELETE', `/api/v4/projects/${encodeURIComponent(projectId)}/protected_branches/${encodeURIComponent(branchName)}`, ); } // --------------------------------------------------------------------------- // Project Deletion // --------------------------------------------------------------------------- public async deleteProject(projectId: number | string): Promise { await this.request( 'DELETE', `/api/v4/projects/${encodeURIComponent(projectId)}`, ); } }