/**
* GitLab API Client
*
* Handles all HTTP communication with the GitLab API.
* Uses node-fetch for HTTP requests.
* Never logs credentials.
*/
import fetch, { type RequestInit } from 'node-fetch';
import { StandardResponse } from './schemas.js';
const DEFAULT_GITLAB_URL = 'https://gitlab.com/api/v4';
interface GitLabClientOptions {
accessToken: string;
baseUrl?: string;
}
export class GitLabClient {
private accessToken: string;
private baseUrl: string;
constructor(options: GitLabClientOptions) {
this.accessToken = options.accessToken;
this.baseUrl = options.baseUrl || DEFAULT_GITLAB_URL;
}
private async request<T>(
method: string,
endpoint: string,
params?: Record<string, unknown>,
body?: unknown
): Promise<StandardResponse<T>> {
try {
// Build URL with query params
const url = new URL(`${this.baseUrl}${endpoint}`);
if (params && method === 'GET') {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, String(value));
}
});
}
const headers: Record<string, string> = {
'PRIVATE-TOKEN': this.accessToken,
'Content-Type': 'application/json',
};
const fetchOptions: RequestInit = {
method,
headers,
};
if (body && method !== 'GET') {
fetchOptions.body = JSON.stringify(body);
}
const response = await fetch(url.toString(), fetchOptions);
// Handle response
if (!response.ok) {
const errorText = await response.text();
let errorMessage: string;
try {
const errorJson = JSON.parse(errorText);
errorMessage = errorJson.message || errorJson.error || errorText;
} catch {
errorMessage = errorText;
}
// Map common GitLab error codes
if (response.status === 401) {
return { success: false, error: 'Invalid or expired authentication token' };
}
if (response.status === 403) {
return { success: false, error: 'Access forbidden - check token permissions' };
}
if (response.status === 404) {
return { success: false, error: `Resource not found: ${endpoint}` };
}
if (response.status === 422) {
return { success: false, error: `Validation error: ${errorMessage}` };
}
if (response.status === 429) {
return { success: false, error: 'Rate limit exceeded - try again later' };
}
return { success: false, error: `GitLab API error (${response.status}): ${errorMessage}` };
}
// Handle empty responses
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
const text = await response.text();
return { success: true, data: text as unknown as T };
}
const data = await response.json() as T;
return { success: true, data };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error occurred';
return { success: false, error: message };
}
}
// ==========================================================================
// Project Operations
// ==========================================================================
async searchProjects(params: {
search: string;
page?: number;
per_page?: number;
}): Promise<StandardResponse> {
return this.request('GET', '/projects', {
search: params.search,
page: params.page,
per_page: params.per_page,
});
}
async getProject(projectId: string): Promise<StandardResponse> {
return this.request('GET', `/projects/${encodeURIComponent(projectId)}`);
}
async listGroupProjects(params: {
group_id: string;
include_subgroups?: boolean;
search?: string;
order_by?: string;
sort?: string;
page?: number;
per_page?: number;
}): Promise<StandardResponse> {
return this.request('GET', `/groups/${encodeURIComponent(params.group_id)}/projects`, {
include_subgroups: params.include_subgroups,
search: params.search,
order_by: params.order_by,
sort: params.sort,
page: params.page,
per_page: params.per_page,
});
}
// ==========================================================================
// File Operations
// ==========================================================================
async getFileContents(params: {
project_id: string;
file_path: string;
ref?: string;
}): Promise<StandardResponse> {
const encodedPath = encodeURIComponent(params.file_path);
const result = await this.request<{ content: string; encoding: string }>(
'GET',
`/projects/${encodeURIComponent(params.project_id)}/repository/files/${encodedPath}`,
{ ref: params.ref || 'HEAD' }
);
// Decode base64 content if successful
if (result.success && result.data && typeof result.data === 'object' && 'content' in result.data) {
const decoded = Buffer.from(result.data.content, 'base64').toString('utf-8');
return {
success: true,
data: {
...result.data,
content: decoded,
content_raw: result.data.content // Keep original base64
}
};
}
return result;
}
async getTree(params: {
project_id: string;
path?: string;
ref?: string;
recursive?: boolean;
page?: number;
per_page?: number;
}): Promise<StandardResponse> {
return this.request('GET', `/projects/${encodeURIComponent(params.project_id)}/repository/tree`, {
path: params.path,
ref: params.ref,
recursive: params.recursive,
page: params.page,
per_page: params.per_page,
});
}
async createOrUpdateFile(params: {
project_id: string;
file_path: string;
branch: string;
content: string;
commit_message: string;
start_branch?: string;
author_email?: string;
author_name?: string;
}): Promise<StandardResponse> {
const encodedPath = encodeURIComponent(params.file_path);
const encodedContent = Buffer.from(params.content).toString('base64');
return this.request(
'PUT',
`/projects/${encodeURIComponent(params.project_id)}/repository/files/${encodedPath}`,
undefined,
{
branch: params.branch,
content: encodedContent,
commit_message: params.commit_message,
start_branch: params.start_branch,
author_email: params.author_email,
author_name: params.author_name,
encoding: 'base64',
}
);
}
// ==========================================================================
// Branch Operations
// ==========================================================================
async listBranches(params: {
project_id: string;
search?: string;
page?: number;
per_page?: number;
}): Promise<StandardResponse> {
return this.request('GET', `/projects/${encodeURIComponent(params.project_id)}/repository/branches`, {
search: params.search,
page: params.page,
per_page: params.per_page,
});
}
async createBranch(params: {
project_id: string;
branch: string;
ref: string;
}): Promise<StandardResponse> {
return this.request(
'POST',
`/projects/${encodeURIComponent(params.project_id)}/repository/branches`,
undefined,
{ branch: params.branch, ref: params.ref }
);
}
// ==========================================================================
// Commit Operations
// ==========================================================================
async listCommits(params: {
project_id: string;
ref_name?: string;
since?: string;
until?: string;
path?: string;
author?: string;
with_stats?: boolean;
page?: number;
per_page?: number;
}): Promise<StandardResponse> {
return this.request('GET', `/projects/${encodeURIComponent(params.project_id)}/repository/commits`, {
ref_name: params.ref_name,
since: params.since,
until: params.until,
path: params.path,
author: params.author,
with_stats: params.with_stats,
page: params.page,
per_page: params.per_page,
});
}
async getCommit(params: {
project_id: string;
sha: string;
}): Promise<StandardResponse> {
return this.request('GET', `/projects/${encodeURIComponent(params.project_id)}/repository/commits/${params.sha}`);
}
async getCommitDiff(params: {
project_id: string;
sha: string;
page?: number;
per_page?: number;
}): Promise<StandardResponse> {
return this.request('GET', `/projects/${encodeURIComponent(params.project_id)}/repository/commits/${params.sha}/diff`, {
page: params.page,
per_page: params.per_page,
});
}
async compare(params: {
project_id: string;
from: string;
to: string;
straight?: boolean;
}): Promise<StandardResponse> {
return this.request('GET', `/projects/${encodeURIComponent(params.project_id)}/repository/compare`, {
from: params.from,
to: params.to,
straight: params.straight,
});
}
// ==========================================================================
// Events/Activity
// ==========================================================================
async getProjectEvents(params: {
project_id: string;
action?: string;
target_type?: string;
before?: string;
after?: string;
sort?: string;
page?: number;
per_page?: number;
}): Promise<StandardResponse> {
return this.request('GET', `/projects/${encodeURIComponent(params.project_id)}/events`, {
action: params.action,
target_type: params.target_type,
before: params.before,
after: params.after,
sort: params.sort,
page: params.page,
per_page: params.per_page,
});
}
// ==========================================================================
// Issue Operations
// ==========================================================================
async listIssues(params: {
project_id: string;
state?: string;
labels?: string;
milestone?: string;
search?: string;
created_after?: string;
created_before?: string;
updated_after?: string;
updated_before?: string;
order_by?: string;
sort?: string;
page?: number;
per_page?: number;
}): Promise<StandardResponse> {
return this.request('GET', `/projects/${encodeURIComponent(params.project_id)}/issues`, {
state: params.state,
labels: params.labels,
milestone: params.milestone,
search: params.search,
created_after: params.created_after,
created_before: params.created_before,
updated_after: params.updated_after,
updated_before: params.updated_before,
order_by: params.order_by,
sort: params.sort,
page: params.page,
per_page: params.per_page,
});
}
async getIssue(params: {
project_id: string;
issue_iid: number;
}): Promise<StandardResponse> {
return this.request('GET', `/projects/${encodeURIComponent(params.project_id)}/issues/${params.issue_iid}`);
}
async createIssue(params: {
project_id: string;
title: string;
description?: string;
labels?: string;
assignee_ids?: number[];
milestone_id?: number;
due_date?: string;
confidential?: boolean;
}): Promise<StandardResponse> {
return this.request(
'POST',
`/projects/${encodeURIComponent(params.project_id)}/issues`,
undefined,
{
title: params.title,
description: params.description,
labels: params.labels,
assignee_ids: params.assignee_ids,
milestone_id: params.milestone_id,
due_date: params.due_date,
confidential: params.confidential,
}
);
}
async listIssueNotes(params: {
project_id: string;
issue_iid: number;
sort?: string;
order_by?: string;
page?: number;
per_page?: number;
}): Promise<StandardResponse> {
return this.request('GET', `/projects/${encodeURIComponent(params.project_id)}/issues/${params.issue_iid}/notes`, {
sort: params.sort,
order_by: params.order_by,
page: params.page,
per_page: params.per_page,
});
}
// ==========================================================================
// Merge Request Operations
// ==========================================================================
async listMergeRequests(params: {
project_id: string;
state?: string;
source_branch?: string;
target_branch?: string;
labels?: string;
search?: string;
created_after?: string;
created_before?: string;
updated_after?: string;
updated_before?: string;
order_by?: string;
sort?: string;
page?: number;
per_page?: number;
}): Promise<StandardResponse> {
return this.request('GET', `/projects/${encodeURIComponent(params.project_id)}/merge_requests`, {
state: params.state,
source_branch: params.source_branch,
target_branch: params.target_branch,
labels: params.labels,
search: params.search,
created_after: params.created_after,
created_before: params.created_before,
updated_after: params.updated_after,
updated_before: params.updated_before,
order_by: params.order_by,
sort: params.sort,
page: params.page,
per_page: params.per_page,
});
}
async getMergeRequest(params: {
project_id: string;
merge_request_iid: number;
}): Promise<StandardResponse> {
return this.request('GET', `/projects/${encodeURIComponent(params.project_id)}/merge_requests/${params.merge_request_iid}`);
}
async getMergeRequestChanges(params: {
project_id: string;
merge_request_iid: number;
}): Promise<StandardResponse> {
return this.request('GET', `/projects/${encodeURIComponent(params.project_id)}/merge_requests/${params.merge_request_iid}/changes`);
}
async createMergeRequest(params: {
project_id: string;
source_branch: string;
target_branch: string;
title: string;
description?: string;
labels?: string;
assignee_ids?: number[];
reviewer_ids?: number[];
remove_source_branch?: boolean;
squash?: boolean;
draft?: boolean;
}): Promise<StandardResponse> {
// Handle draft title prefix
let title = params.title;
if (params.draft && !title.toLowerCase().startsWith('draft:') && !title.toLowerCase().startsWith('wip:')) {
title = `Draft: ${title}`;
}
return this.request(
'POST',
`/projects/${encodeURIComponent(params.project_id)}/merge_requests`,
undefined,
{
source_branch: params.source_branch,
target_branch: params.target_branch,
title,
description: params.description,
labels: params.labels,
assignee_ids: params.assignee_ids,
reviewer_ids: params.reviewer_ids,
remove_source_branch: params.remove_source_branch,
squash: params.squash,
}
);
}
// ==========================================================================
// Wiki Operations
// ==========================================================================
async listWikiPages(params: {
project_id: string;
with_content?: boolean;
page?: number;
per_page?: number;
}): Promise<StandardResponse> {
return this.request('GET', `/projects/${encodeURIComponent(params.project_id)}/wikis`, {
with_content: params.with_content,
page: params.page,
per_page: params.per_page,
});
}
async getWikiPage(params: {
project_id: string;
slug: string;
}): Promise<StandardResponse> {
return this.request('GET', `/projects/${encodeURIComponent(params.project_id)}/wikis/${encodeURIComponent(params.slug)}`);
}
async createWikiPage(params: {
project_id: string;
title: string;
content: string;
format?: string;
}): Promise<StandardResponse> {
return this.request(
'POST',
`/projects/${encodeURIComponent(params.project_id)}/wikis`,
undefined,
{
title: params.title,
content: params.content,
format: params.format || 'markdown',
}
);
}
async updateWikiPage(params: {
project_id: string;
slug: string;
title?: string;
content?: string;
format?: string;
}): Promise<StandardResponse> {
return this.request(
'PUT',
`/projects/${encodeURIComponent(params.project_id)}/wikis/${encodeURIComponent(params.slug)}`,
undefined,
{
title: params.title,
content: params.content,
format: params.format,
}
);
}
// ==========================================================================
// Member Operations
// ==========================================================================
async listProjectMembers(params: {
project_id: string;
query?: string;
page?: number;
per_page?: number;
}): Promise<StandardResponse> {
return this.request('GET', `/projects/${encodeURIComponent(params.project_id)}/members`, {
query: params.query,
page: params.page,
per_page: params.per_page,
});
}
async listGroupMembers(params: {
group_id: string;
query?: string;
page?: number;
per_page?: number;
}): Promise<StandardResponse> {
return this.request('GET', `/groups/${encodeURIComponent(params.group_id)}/members`, {
query: params.query,
page: params.page,
per_page: params.per_page,
});
}
// ==========================================================================
// Search Operations
// ==========================================================================
async search(params: {
scope?: string;
search: string;
project_id?: string;
group_id?: string;
page?: number;
per_page?: number;
}): Promise<StandardResponse> {
let endpoint = '/search';
if (params.project_id) {
endpoint = `/projects/${encodeURIComponent(params.project_id)}/search`;
} else if (params.group_id) {
endpoint = `/groups/${encodeURIComponent(params.group_id)}/search`;
}
return this.request('GET', endpoint, {
scope: params.scope,
search: params.search,
page: params.page,
per_page: params.per_page,
});
}
}