feat(client): add rich domain classes, helpers, and refactor GitLabClient internals

This commit is contained in:
2026-03-02 13:10:54 +00:00
parent 4d90bb01cf
commit 7ba0fc984e
13 changed files with 1587 additions and 392 deletions

View File

@@ -1,5 +1,4 @@
import * as plugins from './gitlab.plugins.js';
import { logger } from './gitlab.logging.js';
import type {
IGitLabUser,
IGitLabProject,
@@ -18,22 +17,26 @@ import type {
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) {
// Remove trailing slash if present
this.baseUrl = baseUrl.replace(/\/+$/, '');
this.token = token;
}
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
// ===========================================================================
// HTTP helpers (internal)
// ===========================================================================
private async request<T = any>(
/** @internal */
async request<T = any>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
data?: any,
@@ -84,7 +87,8 @@ export class GitLabClient {
}
}
private async requestText(
/** @internal */
async requestText(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
): Promise<string> {
@@ -119,9 +123,45 @@ export class GitLabClient {
return response.text();
}
// ---------------------------------------------------------------------------
// Connection
// ---------------------------------------------------------------------------
/** @internal — multipart form upload (for avatars) */
async requestMultipart<T = any>(
method: 'PUT' | 'POST',
path: string,
formData: FormData,
): Promise<T> {
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<Uint8Array> {
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<ITestConnectionResult> {
try {
@@ -132,24 +172,95 @@ export class GitLabClient {
}
}
// ---------------------------------------------------------------------------
// Groups — scoped queries
// ---------------------------------------------------------------------------
// ===========================================================================
// Public API — Groups (returns rich objects)
// ===========================================================================
/**
* Get a single group by its full path (e.g. "foss.global" or "foss.global/push.rocks")
* Get all groups (auto-paginated).
*/
public async getGroupByPath(fullPath: string): Promise<IGitLabGroup> {
return this.request<IGitLabGroup>(
'GET',
`/api/v4/groups/${encodeURIComponent(fullPath)}`,
);
public async getGroups(opts?: IListOptions): Promise<GitLabGroup[]> {
return autoPaginate(
(page, perPage) => this.requestGetGroups({ ...opts, page, perPage }),
opts,
).then(groups => groups.map(g => new GitLabGroup(this, g)));
}
/**
* List projects within a group (includes subgroups when include_subgroups=true)
* Get a single group by full path.
*/
public async getGroupProjects(groupId: number | string, opts?: IListOptions): Promise<IGitLabProject[]> {
public async getGroup(fullPath: string): Promise<GitLabGroup> {
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<GitLabGroup> {
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<GitLabProject[]> {
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<GitLabProject> {
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<GitLabProject> {
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<IGitLabGroup[]> {
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<IGitLabGroup[]>('GET', url);
}
/** @internal */
async requestGetGroupByPath(fullPath: string): Promise<IGitLabGroup> {
return this.request<IGitLabGroup>('GET', `/api/v4/groups/${encodeURIComponent(fullPath)}`);
}
/** @internal */
async requestGetGroupProjects(groupId: number | string, opts?: IListOptions): Promise<IGitLabProject[]> {
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}`;
@@ -159,10 +270,8 @@ export class GitLabClient {
return this.request<IGitLabProject[]>('GET', url);
}
/**
* List all descendant groups (recursive subgroups) within a group
*/
public async getDescendantGroups(groupId: number | string, opts?: IListOptions): Promise<IGitLabGroup[]> {
/** @internal */
async requestGetDescendantGroups(groupId: number | string, opts?: IListOptions): Promise<IGitLabGroup[]> {
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}`;
@@ -172,19 +281,58 @@ export class GitLabClient {
return this.request<IGitLabGroup[]>('GET', url);
}
/**
* Create a new group. Optionally nested under a parent group.
*/
public async createGroup(name: string, path: string, parentId?: number): Promise<IGitLabGroup> {
/** @internal */
async requestCreateGroup(name: string, path: string, parentId?: number): Promise<IGitLabGroup> {
const body: any = { name, path, visibility: 'private' };
if (parentId) body.parent_id = parentId;
return this.request<IGitLabGroup>('POST', '/api/v4/groups', body);
}
/**
* Create a new project (repository).
*/
public async createProject(name: string, opts?: {
/** @internal */
async requestUpdateGroup(groupId: number | string, data: Record<string, any>): Promise<void> {
await this.request('PUT', `/api/v4/groups/${encodeURIComponent(groupId)}`, data);
}
/** @internal */
async requestSetGroupAvatar(groupId: number | string, imageData: Uint8Array, filename: string): Promise<void> {
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<void> {
await this.request('POST', `/api/v4/groups/${encodeURIComponent(groupId)}/transfer`, {
group_id: parentGroupId,
});
}
/** @internal */
async requestDeleteGroup(groupId: number | string): Promise<void> {
await this.request('DELETE', `/api/v4/groups/${encodeURIComponent(groupId)}`);
}
// --- Projects ---
/** @internal */
async requestGetProjects(opts?: IListOptions): Promise<IGitLabProject[]> {
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<IGitLabProject[]>('GET', url);
}
/** @internal */
async requestGetProject(idOrPath: number | string): Promise<IGitLabProject> {
return this.request<IGitLabProject>('GET', `/api/v4/projects/${encodeURIComponent(idOrPath)}`);
}
/** @internal */
async requestCreateProject(name: string, opts?: {
path?: string;
namespaceId?: number;
visibility?: string;
@@ -199,46 +347,83 @@ export class GitLabClient {
});
}
// ---------------------------------------------------------------------------
// Projects
// ---------------------------------------------------------------------------
public async getProjects(opts?: IListOptions): Promise<IGitLabProject[]> {
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<IGitLabProject[]>('GET', url);
/** @internal */
async requestUpdateProject(projectId: number | string, data: Record<string, any>): Promise<void> {
await this.request('PUT', `/api/v4/projects/${encodeURIComponent(projectId)}`, data);
}
// ---------------------------------------------------------------------------
// Groups
// ---------------------------------------------------------------------------
public async getGroups(opts?: IListOptions): Promise<IGitLabGroup[]> {
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<IGitLabGroup[]>('GET', url);
/** @internal */
async requestSetProjectAvatar(projectId: number | string, imageData: Uint8Array, filename: string): Promise<void> {
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);
}
// ---------------------------------------------------------------------------
// Project Variables (CI/CD)
// ---------------------------------------------------------------------------
/** @internal */
async requestTransferProject(projectId: number | string, namespaceId: number): Promise<void> {
await this.request('PUT', `/api/v4/projects/${encodeURIComponent(projectId)}/transfer`, {
namespace: namespaceId,
});
}
public async getProjectVariables(projectId: number | string): Promise<IGitLabVariable[]> {
/** @internal */
async requestDeleteProject(projectId: number | string): Promise<void> {
await this.request('DELETE', `/api/v4/projects/${encodeURIComponent(projectId)}`);
}
// --- Repo Branches & Tags ---
/** @internal */
async requestGetRepoBranches(projectId: number | string, opts?: IListOptions): Promise<IGitLabBranch[]> {
const page = opts?.page || 1;
const perPage = opts?.perPage || 50;
return this.request<IGitLabBranch[]>(
'GET',
`/api/v4/projects/${encodeURIComponent(projectId)}/repository/branches?page=${page}&per_page=${perPage}`,
);
}
/** @internal */
async requestGetRepoTags(projectId: number | string, opts?: IListOptions): Promise<IGitLabTag[]> {
const page = opts?.page || 1;
const perPage = opts?.perPage || 50;
return this.request<IGitLabTag[]>(
'GET',
`/api/v4/projects/${encodeURIComponent(projectId)}/repository/tags?page=${page}&per_page=${perPage}`,
);
}
// --- Protected Branches ---
/** @internal */
async requestGetProtectedBranches(projectId: number | string): Promise<IGitLabProtectedBranch[]> {
return this.request<IGitLabProtectedBranch[]>(
'GET',
`/api/v4/projects/${encodeURIComponent(projectId)}/protected_branches`,
);
}
/** @internal */
async requestUnprotectBranch(projectId: number | string, branchName: string): Promise<void> {
await this.request(
'DELETE',
`/api/v4/projects/${encodeURIComponent(projectId)}/protected_branches/${encodeURIComponent(branchName)}`,
);
}
// --- Project Variables ---
/** @internal */
async requestGetProjectVariables(projectId: number | string): Promise<IGitLabVariable[]> {
return this.request<IGitLabVariable[]>(
'GET',
`/api/v4/projects/${encodeURIComponent(projectId)}/variables`,
);
}
public async createProjectVariable(
/** @internal */
async requestCreateProjectVariable(
projectId: number | string,
key: string,
value: string,
@@ -257,7 +442,8 @@ export class GitLabClient {
);
}
public async updateProjectVariable(
/** @internal */
async requestUpdateProjectVariable(
projectId: number | string,
key: string,
value: string,
@@ -274,25 +460,26 @@ export class GitLabClient {
);
}
public async deleteProjectVariable(projectId: number | string, key: string): Promise<void> {
/** @internal */
async requestDeleteProjectVariable(projectId: number | string, key: string): Promise<void> {
await this.request(
'DELETE',
`/api/v4/projects/${encodeURIComponent(projectId)}/variables/${encodeURIComponent(key)}`,
);
}
// ---------------------------------------------------------------------------
// Group Variables (CI/CD)
// ---------------------------------------------------------------------------
// --- Group Variables ---
public async getGroupVariables(groupId: number | string): Promise<IGitLabVariable[]> {
/** @internal */
async requestGetGroupVariables(groupId: number | string): Promise<IGitLabVariable[]> {
return this.request<IGitLabVariable[]>(
'GET',
`/api/v4/groups/${encodeURIComponent(groupId)}/variables`,
);
}
public async createGroupVariable(
/** @internal */
async requestCreateGroupVariable(
groupId: number | string,
key: string,
value: string,
@@ -311,7 +498,8 @@ export class GitLabClient {
);
}
public async updateGroupVariable(
/** @internal */
async requestUpdateGroupVariable(
groupId: number | string,
key: string,
value: string,
@@ -328,22 +516,18 @@ export class GitLabClient {
);
}
public async deleteGroupVariable(groupId: number | string, key: string): Promise<void> {
/** @internal */
async requestDeleteGroupVariable(groupId: number | string, key: string): Promise<void> {
await this.request(
'DELETE',
`/api/v4/groups/${encodeURIComponent(groupId)}/variables/${encodeURIComponent(key)}`,
);
}
// ---------------------------------------------------------------------------
// Pipelines
// ---------------------------------------------------------------------------
// --- 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<IGitLabPipeline[]> {
/** @internal */
async requestGetPipelines(projectId: number | string, opts?: IPipelineListOptions): Promise<IGitLabPipeline[]> {
const page = opts?.page || 1;
const perPage = opts?.perPage || 30;
const orderBy = opts?.orderBy || 'updated_at';
@@ -359,20 +543,16 @@ export class GitLabClient {
return this.request<IGitLabPipeline[]>('GET', url);
}
/**
* Get a single pipeline's full details.
*/
public async getPipeline(projectId: number | string, pipelineId: number): Promise<IGitLabPipeline> {
/** @internal */
async requestGetPipeline(projectId: number | string, pipelineId: number): Promise<IGitLabPipeline> {
return this.request<IGitLabPipeline>(
'GET',
`/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}`,
);
}
/**
* Trigger a new pipeline on the given ref, optionally with variables.
*/
public async triggerPipeline(
/** @internal */
async requestTriggerPipeline(
projectId: number | string,
ref: string,
variables?: { key: string; value: string; variable_type?: string }[],
@@ -388,58 +568,50 @@ export class GitLabClient {
);
}
/**
* Delete a pipeline and all its jobs.
*/
public async deletePipeline(projectId: number | string, pipelineId: number): Promise<void> {
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<IGitLabPipelineVariable[]> {
return this.request<IGitLabPipelineVariable[]>(
'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<IGitLabTestReport> {
return this.request<IGitLabTestReport>(
'GET',
`/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}/test_report`,
);
}
public async retryPipeline(projectId: number | string, pipelineId: number): Promise<IGitLabPipeline> {
/** @internal */
async requestRetryPipeline(projectId: number | string, pipelineId: number): Promise<IGitLabPipeline> {
return this.request<IGitLabPipeline>(
'POST',
`/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}/retry`,
);
}
public async cancelPipeline(projectId: number | string, pipelineId: number): Promise<IGitLabPipeline> {
/** @internal */
async requestCancelPipeline(projectId: number | string, pipelineId: number): Promise<IGitLabPipeline> {
return this.request<IGitLabPipeline>(
'POST',
`/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}/cancel`,
);
}
// ---------------------------------------------------------------------------
// Jobs
// ---------------------------------------------------------------------------
/** @internal */
async requestDeletePipeline(projectId: number | string, pipelineId: number): Promise<void> {
await this.request(
'DELETE',
`/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}`,
);
}
/**
* List jobs for a pipeline with optional scope filter and pagination.
*/
public async getPipelineJobs(projectId: number | string, pipelineId: number, opts?: IJobListOptions): Promise<IGitLabJob[]> {
/** @internal */
async requestGetPipelineVariables(projectId: number | string, pipelineId: number): Promise<IGitLabPipelineVariable[]> {
return this.request<IGitLabPipelineVariable[]>(
'GET',
`/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}/variables`,
);
}
/** @internal */
async requestGetPipelineTestReport(projectId: number | string, pipelineId: number): Promise<IGitLabTestReport> {
return this.request<IGitLabTestReport>(
'GET',
`/api/v4/projects/${encodeURIComponent(projectId)}/pipelines/${pipelineId}/test_report`,
);
}
// --- Jobs ---
/** @internal */
async requestGetPipelineJobs(projectId: number | string, pipelineId: number, opts?: IJobListOptions): Promise<IGitLabJob[]> {
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}`;
@@ -451,114 +623,43 @@ export class GitLabClient {
return this.request<IGitLabJob[]>('GET', url);
}
/**
* Get a single job's full details.
*/
public async getJob(projectId: number | string, jobId: number): Promise<IGitLabJob> {
return this.request<IGitLabJob>(
'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<string> {
/** @internal */
async requestGetJobLog(projectId: number | string, jobId: number): Promise<string> {
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<IGitLabJob> {
/** @internal */
async requestRetryJob(projectId: number | string, jobId: number): Promise<IGitLabJob> {
return this.request<IGitLabJob>(
'POST',
`/api/v4/projects/${encodeURIComponent(projectId)}/jobs/${jobId}/retry`,
);
}
/**
* Cancel a running job.
*/
public async cancelJob(projectId: number | string, jobId: number): Promise<IGitLabJob> {
/** @internal */
async requestCancelJob(projectId: number | string, jobId: number): Promise<IGitLabJob> {
return this.request<IGitLabJob>(
'POST',
`/api/v4/projects/${encodeURIComponent(projectId)}/jobs/${jobId}/cancel`,
);
}
/**
* Trigger a manual job (play action).
*/
public async playJob(projectId: number | string, jobId: number): Promise<IGitLabJob> {
/** @internal */
async requestPlayJob(projectId: number | string, jobId: number): Promise<IGitLabJob> {
return this.request<IGitLabJob>(
'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<void> {
/** @internal */
async requestEraseJob(projectId: number | string, jobId: number): Promise<void> {
await this.request(
'POST',
`/api/v4/projects/${encodeURIComponent(projectId)}/jobs/${jobId}/erase`,
);
}
// ---------------------------------------------------------------------------
// Repository Branches & Tags
// ---------------------------------------------------------------------------
public async getRepoBranches(projectId: number | string, opts?: IListOptions): Promise<IGitLabBranch[]> {
const page = opts?.page || 1;
const perPage = opts?.perPage || 50;
return this.request<IGitLabBranch[]>(
'GET',
`/api/v4/projects/${encodeURIComponent(projectId)}/repository/branches?page=${page}&per_page=${perPage}`,
);
}
public async getRepoTags(projectId: number | string, opts?: IListOptions): Promise<IGitLabTag[]> {
const page = opts?.page || 1;
const perPage = opts?.perPage || 50;
return this.request<IGitLabTag[]>(
'GET',
`/api/v4/projects/${encodeURIComponent(projectId)}/repository/tags?page=${page}&per_page=${perPage}`,
);
}
// ---------------------------------------------------------------------------
// Protected Branches
// ---------------------------------------------------------------------------
public async getProtectedBranches(projectId: number | string): Promise<IGitLabProtectedBranch[]> {
return this.request<IGitLabProtectedBranch[]>(
'GET',
`/api/v4/projects/${encodeURIComponent(projectId)}/protected_branches`,
);
}
public async unprotectBranch(projectId: number | string, branchName: string): Promise<void> {
await this.request(
'DELETE',
`/api/v4/projects/${encodeURIComponent(projectId)}/protected_branches/${encodeURIComponent(branchName)}`,
);
}
// ---------------------------------------------------------------------------
// Project Deletion
// ---------------------------------------------------------------------------
public async deleteProject(projectId: number | string): Promise<void> {
await this.request(
'DELETE',
`/api/v4/projects/${encodeURIComponent(projectId)}`,
);
}
}