import * as plugins from './gitlab.plugins.js'; import type { IGitLabUser, IGitLabProject, IGitLabGroup, IGitLabVariable, IVariableOptions, IGitLabProtectedBranch, IGitLabBranch, IGitLabTag, IGitLabPipeline, IGitLabPipelineVariable, IGitLabTestReport, IGitLabJob, ITestConnectionResult, IListOptions, IPipelineListOptions, IJobListOptions, } from './gitlab.interfaces.js'; import { GitLabGroup } from './gitlab.classes.group.js'; import { GitLabProject } from './gitlab.classes.project.js'; import { GitLabPipeline } from './gitlab.classes.pipeline.js'; import { autoPaginate } from './gitlab.helpers.js'; export class GitLabClient { private baseUrl: string; private token: string; constructor(baseUrl: string, token: string) { this.baseUrl = baseUrl.replace(/\/+$/, ''); this.token = token; } // =========================================================================== // HTTP helpers (internal) // =========================================================================== /** @internal */ 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; } } /** @internal */ 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(); } /** @internal — multipart form upload (for avatars) */ async requestMultipart( method: 'PUT' | 'POST', path: string, formData: FormData, ): Promise { const url = `${this.baseUrl}${path}`; const response = await fetch(url, { method, headers: { 'PRIVATE-TOKEN': this.token }, body: formData, }); 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; } } /** @internal — fetch binary data (e.g. avatar images) */ async requestBinary(url: string): Promise { const fullUrl = url.startsWith('http') ? url : `${this.baseUrl}${url}`; const response = await fetch(fullUrl, { headers: { 'PRIVATE-TOKEN': this.token }, }); if (!response.ok) { throw new Error(`GET ${url}: ${response.status} ${response.statusText}`); } const buf = await response.arrayBuffer(); return new Uint8Array(buf); } // =========================================================================== // Public API — 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) }; } } // =========================================================================== // Public API — Groups (returns rich objects) // =========================================================================== /** * Get all groups (auto-paginated). */ public async getGroups(opts?: IListOptions): Promise { return autoPaginate( (page, perPage) => this.requestGetGroups({ ...opts, page, perPage }), opts, ).then(groups => groups.map(g => new GitLabGroup(this, g))); } /** * Get a single group by full path. */ public async getGroup(fullPath: string): Promise { const raw = await this.requestGetGroupByPath(fullPath); return new GitLabGroup(this, raw); } /** * Create a new group. */ public async createGroup(name: string, path: string, parentId?: number): Promise { const raw = await this.requestCreateGroup(name, path, parentId); return new GitLabGroup(this, raw); } // =========================================================================== // Public API — Projects (returns rich objects) // =========================================================================== /** * Get all projects (auto-paginated, membership=true). */ public async getProjects(opts?: IListOptions): Promise { return autoPaginate( (page, perPage) => this.requestGetProjects({ ...opts, page, perPage }), opts, ).then(projects => projects.map(p => new GitLabProject(this, p))); } /** * Get a single project by ID or path. */ public async getProject(idOrPath: number | string): Promise { const raw = await this.requestGetProject(idOrPath); return new GitLabProject(this, raw); } /** * Create a new project. */ public async createProject(name: string, opts?: { path?: string; namespaceId?: number; visibility?: string; description?: string; }): Promise { const raw = await this.requestCreateProject(name, opts); return new GitLabProject(this, raw); } // =========================================================================== // Internal request methods — called by domain classes // =========================================================================== // --- Groups --- /** @internal */ async requestGetGroups(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); } /** @internal */ async requestGetGroupByPath(fullPath: string): Promise { return this.request('GET', `/api/v4/groups/${encodeURIComponent(fullPath)}`); } /** @internal */ async requestGetGroupProjects(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); } /** @internal */ async requestGetDescendantGroups(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); } /** @internal */ async requestCreateGroup(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); } /** @internal */ async requestUpdateGroup(groupId: number | string, data: Record): Promise { await this.request('PUT', `/api/v4/groups/${encodeURIComponent(groupId)}`, data); } /** @internal */ async requestSetGroupAvatar(groupId: number | string, imageData: Uint8Array, filename: string): Promise { const blob = new Blob([imageData.buffer as ArrayBuffer]); const formData = new FormData(); formData.append('avatar', blob, filename); await this.requestMultipart('PUT', `/api/v4/groups/${encodeURIComponent(groupId)}`, formData); } /** @internal */ async requestTransferGroup(groupId: number | string, parentGroupId: number): Promise { await this.request('POST', `/api/v4/groups/${encodeURIComponent(groupId)}/transfer`, { group_id: parentGroupId, }); } /** @internal */ async requestDeleteGroup(groupId: number | string): Promise { await this.request('DELETE', `/api/v4/groups/${encodeURIComponent(groupId)}`); } // --- Projects --- /** @internal */ async requestGetProjects(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); } /** @internal */ async requestGetProject(idOrPath: number | string): Promise { return this.request('GET', `/api/v4/projects/${encodeURIComponent(idOrPath)}`); } /** @internal */ async requestCreateProject(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 || '', }); } /** @internal */ async requestUpdateProject(projectId: number | string, data: Record): Promise { await this.request('PUT', `/api/v4/projects/${encodeURIComponent(projectId)}`, data); } /** @internal */ async requestSetProjectAvatar(projectId: number | string, imageData: Uint8Array, filename: string): Promise { const blob = new Blob([imageData.buffer as ArrayBuffer]); const formData = new FormData(); formData.append('avatar', blob, filename); await this.requestMultipart('PUT', `/api/v4/projects/${encodeURIComponent(projectId)}`, formData); } /** @internal */ async requestTransferProject(projectId: number | string, namespaceId: number): Promise { await this.request('PUT', `/api/v4/projects/${encodeURIComponent(projectId)}/transfer`, { namespace: namespaceId, }); } /** @internal */ async requestDeleteProject(projectId: number | string): Promise { await this.request('DELETE', `/api/v4/projects/${encodeURIComponent(projectId)}`); } // --- Repo Branches & Tags --- /** @internal */ async requestGetRepoBranches(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}`, ); } /** @internal */ async requestGetRepoTags(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 --- /** @internal */ async requestGetProtectedBranches(projectId: number | string): Promise { return this.request( 'GET', `/api/v4/projects/${encodeURIComponent(projectId)}/protected_branches`, ); } /** @internal */ async requestUnprotectBranch(projectId: number | string, branchName: string): Promise { await this.request( 'DELETE', `/api/v4/projects/${encodeURIComponent(projectId)}/protected_branches/${encodeURIComponent(branchName)}`, ); } // --- Project Variables --- /** @internal */ async requestGetProjectVariables(projectId: number | string): Promise { return this.request( 'GET', `/api/v4/projects/${encodeURIComponent(projectId)}/variables`, ); } /** @internal */ async requestCreateProjectVariable( 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 ?? '*', }, ); } /** @internal */ async requestUpdateProjectVariable( 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, ); } /** @internal */ async requestDeleteProjectVariable(projectId: number | string, key: string): Promise { await this.request( 'DELETE', `/api/v4/projects/${encodeURIComponent(projectId)}/variables/${encodeURIComponent(key)}`, ); } // --- Group Variables --- /** @internal */ async requestGetGroupVariables(groupId: number | string): Promise { return this.request( 'GET', `/api/v4/groups/${encodeURIComponent(groupId)}/variables`, ); } /** @internal */ async requestCreateGroupVariable( 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 ?? '*', }, ); } /** @internal */ async requestUpdateGroupVariable( 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, ); } /** @internal */ async requestDeleteGroupVariable(groupId: number | string, key: string): Promise { await this.request( 'DELETE', `/api/v4/groups/${encodeURIComponent(groupId)}/variables/${encodeURIComponent(key)}`, ); } // --- Pipelines --- /** @internal */ async requestGetPipelines(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); } /** @internal */ async requestGetPipeline(projectId: number | string, pipelineId: number): Promise { return this.request( 'GET', `/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}`, ); } /** @internal */ async requestTriggerPipeline( 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, ); } /** @internal */ async requestRetryPipeline(projectId: number | string, pipelineId: number): Promise { return this.request( 'POST', `/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}/retry`, ); } /** @internal */ async requestCancelPipeline(projectId: number | string, pipelineId: number): Promise { return this.request( 'POST', `/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}/cancel`, ); } /** @internal */ async requestDeletePipeline(projectId: number | string, pipelineId: number): Promise { await this.request( 'DELETE', `/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}`, ); } /** @internal */ async requestGetPipelineVariables(projectId: number | string, pipelineId: number): Promise { return this.request( 'GET', `/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}/variables`, ); } /** @internal */ async requestGetPipelineTestReport(projectId: number | string, pipelineId: number): Promise { return this.request( 'GET', `/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}/test_report`, ); } // --- Jobs --- /** @internal */ async requestGetPipelineJobs(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); } /** @internal */ async requestGetJobLog(projectId: number | string, jobId: number): Promise { return this.requestText( 'GET', `/api/v4/projects/${encodeURIComponent(projectId)}/jobs/${jobId}/trace`, ); } /** @internal */ async requestRetryJob(projectId: number | string, jobId: number): Promise { return this.request( 'POST', `/api/v4/projects/${encodeURIComponent(projectId)}/jobs/${jobId}/retry`, ); } /** @internal */ async requestCancelJob(projectId: number | string, jobId: number): Promise { return this.request( 'POST', `/api/v4/projects/${encodeURIComponent(projectId)}/jobs/${jobId}/cancel`, ); } /** @internal */ async requestPlayJob(projectId: number | string, jobId: number): Promise { return this.request( 'POST', `/api/v4/projects/${encodeURIComponent(projectId)}/jobs/${jobId}/play`, ); } /** @internal */ async requestEraseJob(projectId: number | string, jobId: number): Promise { await this.request( 'POST', `/api/v4/projects/${encodeURIComponent(projectId)}/jobs/${jobId}/erase`, ); } }