Skip to main content
Glama

mcp-gitlab-jira

gitlab.service.ts25.5 kB
import fetch from 'node-fetch'; import { GitLabConfig, GitLabMRDetails, GitLabProject, GitLabMergeRequest, GitLabPosition, GitLabUser, ParsedHunk, } from './gitlab.js'; export class GitLabService { private readonly config: GitLabConfig; private projectCache: { data: GitLabProject[]; timestamp: number } | null = null; private readonly CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours constructor(config: GitLabConfig) { this.config = config; } private async callGitLabApi<T>( endpoint: string, method: string = 'GET', body?: object, ): Promise<T> { const url = `${this.config.url}/api/v4/${endpoint}`; const headers = { 'Private-Token': this.config.accessToken, 'Content-Type': 'application/json', }; const options: any = { method, headers, body: body ? JSON.stringify(body) : undefined, }; try { const response = await fetch(url, options); if (!response.ok) { const errorText = await response.text(); console.error(`GitLab API Error: ${response.status} - ${errorText}`); throw new Error(`GitLab API Error: ${response.status} - ${errorText}`); } return response.json() as Promise<T>; } catch (error) { console.error(`Failed to call GitLab API: ${error}`); throw error; } } private parseDiff(diff: string): ParsedHunk[] { const hunks: ParsedHunk[] = []; const lines = diff.split('\n'); let currentHunk: ParsedHunk | null = null; for (const line of lines) { if (line.startsWith('@@')) { // Hunk header const headerMatch = line.match( /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)/, ); if (headerMatch) { if (currentHunk) { hunks.push(currentHunk); } currentHunk = { header: line, oldStartLine: parseInt(headerMatch[1], 10), oldLineCount: parseInt(headerMatch[2] || '1', 10), newStartLine: parseInt(headerMatch[3], 10), newLineCount: parseInt(headerMatch[4] || '1', 10), lines: [], isCollapsed: false, }; } } else if (currentHunk) { // Hunk content let lineType: 'add' | 'remove' | 'context'; let oldLine: number | undefined; let newLine: number | undefined; if (line.startsWith('+')) { lineType = 'add'; newLine = currentHunk.newStartLine + currentHunk.lines.filter((l) => l.type !== 'remove').length; } else if (line.startsWith('-')) { lineType = 'remove'; oldLine = currentHunk.oldStartLine + currentHunk.lines.filter((l) => l.type !== 'add').length; } else { lineType = 'context'; oldLine = currentHunk.oldStartLine + currentHunk.lines.filter((l) => l.type !== 'add').length; newLine = currentHunk.newStartLine + currentHunk.lines.filter((l) => l.type !== 'remove').length; } currentHunk.lines.push({ type: lineType, oldLine, newLine, content: line, }); } } if (currentHunk) { hunks.push(currentHunk); } return hunks; } // Utility method to parse GitLab MR URLs private parseMrUrl( mrUrl: string, gitlabBaseUrl: string, ): { projectPath: string; mrIid: number } { try { const url = new URL(mrUrl); const baseUrl = new URL(gitlabBaseUrl); // Ensure the URL is from the same GitLab instance if (url.origin !== baseUrl.origin) { throw new Error( `MR URL is not from the configured GitLab instance: ${gitlabBaseUrl}`, ); } // Parse the path: /{namespace}/{project}/-/merge_requests/{iid} const pathMatch = url.pathname.match(/^\/(.+)\/-\/merge_requests\/(\d+)/); if (!pathMatch) { throw new Error(`Invalid GitLab MR URL format: ${mrUrl}`); } const projectPath = pathMatch[1]; const mrIid = parseInt(pathMatch[2], 10); return { projectPath, mrIid }; } catch (error) { throw new Error( `Failed to parse GitLab MR URL: ${error instanceof Error ? error.message : String(error)}`, ); } } // Phase 2: Basic Features Implementation will go here // 1. Fetch Merge Request Details async getMergeRequestDetails( projectPath: string, mrIid: number, ): Promise<GitLabMRDetails> { const encodedProjectPath = encodeURIComponent(projectPath); const baseUrl = `projects/${encodedProjectPath}/merge_requests/${mrIid}`; const mrDetails = await this.callGitLabApi<any>(baseUrl); const mrChanges = await this.callGitLabApi<any>( `projects/${encodedProjectPath}/merge_requests/${mrIid}/changes`, ); // Map file diffs const fileDiffs = mrChanges.changes.map((change: any) => ({ old_path: change.old_path, new_path: change.new_path, new_file: change.new_file, deleted_file: change.deleted_file, renamed_file: change.renamed_file, diff: change.diff, })); const parsedDiffs = fileDiffs.map((diff: any) => ({ filePath: diff.new_path, oldPath: diff.old_path, isNew: diff.new_file, isDeleted: diff.deleted_file, isRenamed: diff.renamed_file, hunks: this.parseDiff(diff.diff), })); return { projectPath: mrDetails.path_with_namespace, mrIid: mrDetails.iid.toString(), projectId: mrDetails.project_id, title: mrDetails.title, authorName: mrDetails.author.name, webUrl: mrDetails.web_url, sourceBranch: mrDetails.source_branch, targetBranch: mrDetails.target_branch, base_sha: mrDetails.diff_refs.base_sha, start_sha: mrDetails.diff_refs.start_sha, head_sha: mrDetails.diff_refs.head_sha, fileDiffs: fileDiffs, diffForPrompt: fileDiffs.map((diff: any) => diff.diff).join('\n'), parsedDiffs: parsedDiffs, fileContents: new Map(), // fileContents will be populated by a separate tool discussions: [], // Discussions will be fetched by a separate tool existingFeedback: [], // Existing feedback will be derived from discussions }; } // Convenience method to get MR details from URL async getMergeRequestDetailsFromUrl(mrUrl: string): Promise<GitLabMRDetails> { const { projectPath, mrIid } = this.parseMrUrl(mrUrl, this.config.url); return this.getMergeRequestDetails(projectPath, mrIid); } // New tool: Get Merge Request Discussions async getMergeRequestDiscussions( projectPath: string, mrIid: number, ): Promise<any[]> { const encodedProjectPath = encodeURIComponent(projectPath); const mrDiscussions = await this.callGitLabApi<any>( `projects/${encodedProjectPath}/merge_requests/${mrIid}/discussions`, ); // Map discussions const discussions = mrDiscussions.map((discussion: any) => ({ id: discussion.id, notes: discussion.notes.map((note: any) => ({ id: note.id, body: note.body, author: { name: note.author.name, username: note.author.username, }, system: note.system, position: note.position ? { base_sha: note.position.base_sha, start_sha: note.position.start_sha, head_sha: note.position.head_sha, position_type: note.position.position_type, old_path: note.position.old_path, new_path: note.position.new_path, new_line: note.position.new_line, old_line: note.position.old_line, } : undefined, })), postedAsInline: discussion.individual_note, // Assuming individual_note means inline })); return discussions; } // Convenience method to get MR discussions from URL async getMergeRequestDiscussionsFromUrl(mrUrl: string): Promise<any[]> { const { projectPath, mrIid } = this.parseMrUrl(mrUrl, this.config.url); return this.getMergeRequestDiscussions(projectPath, mrIid); } // New tool: Get File Content async getFileContent( projectPath: string, filePath: string, sha: string, ): Promise<string> { const encodedProjectPath = encodeURIComponent(projectPath); const encodedFilePath = encodeURIComponent(filePath); const content = await this.callGitLabApi<any>( `projects/${encodedProjectPath}/repository/files/${encodedFilePath}/raw?ref=${sha}`, ); return content; } // Convenience method to get file content from MR URL and file path/SHA async getFileContentFromMrUrl( mrUrl: string, filePath: string, sha: string, ): Promise<string> { const { projectPath } = this.parseMrUrl(mrUrl, this.config.url); return this.getFileContent(projectPath, filePath, sha); } // 2. Add Comment to Merge Request async addCommentToMergeRequest( projectPath: string, mrIid: number, discussionId: string | undefined, commentBody: string, position: GitLabPosition | undefined, ): Promise<any> { const encodedProjectPath = encodeURIComponent(projectPath); if (discussionId) { // Reply to an existing discussion return this.callGitLabApi( `projects/${encodedProjectPath}/merge_requests/${mrIid}/discussions/${discussionId}/notes`, 'POST', { body: commentBody }, ); } else if (position) { // Add a new comment with a position (inline comment) return this.callGitLabApi( `projects/${encodedProjectPath}/merge_requests/${mrIid}/notes`, 'POST', { body: commentBody, noteable_type: 'MergeRequest', noteable_id: mrIid, position: { base_sha: position.base_sha, start_sha: position.start_sha, head_sha: position.head_sha, position_type: position.position_type, old_path: position.old_path, new_path: position.new_path, new_line: position.new_line, old_line: position.old_line, }, }, ); } else { // Add a general comment return this.callGitLabApi( `projects/${encodedProjectPath}/merge_requests/${mrIid}/notes`, 'POST', { body: commentBody }, ); } } // Convenience method to add comment from MR URL async addCommentToMergeRequestFromUrl( mrUrl: string, commentBody: string, discussionId?: string, position?: GitLabPosition, ): Promise<any> { const { projectPath, mrIid } = this.parseMrUrl(mrUrl, this.config.url); return this.addCommentToMergeRequest( projectPath, mrIid, discussionId, commentBody, position, ); } // 3. List Projects async listProjects(): Promise<GitLabProject[]> { if ( this.projectCache && Date.now() - this.projectCache.timestamp < this.CACHE_DURATION_MS ) { return this.projectCache.data; } const url = `projects?membership=true&min_access_level=30&order_by=last_activity_at&sort=desc&per_page=100`; const projects = await this.callGitLabApi<any[]>(url); const simplifiedProjects: GitLabProject[] = projects.map((project) => ({ id: project.id, name: project.name, name_with_namespace: project.name_with_namespace, path_with_namespace: project.path_with_namespace, last_activity_at: project.last_activity_at, ssh_url_to_repo: project.ssh_url_to_repo, http_url_to_repo: project.http_url_to_repo, web_url: project.web_url, readme_url: project.readme_url, issue_branch_template: project.issue_branch_template, statistics: project.statistics, _links: project._links, })); this.projectCache = { data: simplifiedProjects, timestamp: Date.now() }; return simplifiedProjects; } // 4. List Merge Requests for a Project async listMergeRequests(projectPath: string): Promise<GitLabMergeRequest[]> { const encodedProjectPath = encodeURIComponent(projectPath); return this.callGitLabApi<GitLabMergeRequest[]>( `projects/${encodedProjectPath}/merge_requests`, ); } // New tool: Assign Reviewers to Merge Request async assignReviewersToMergeRequest( projectPath: string, mrIid: number, reviewerIds: number[], ): Promise<any> { const encodedProjectPath = encodeURIComponent(projectPath); return this.callGitLabApi( `projects/${encodedProjectPath}/merge_requests/${mrIid}`, 'PUT', { reviewer_ids: reviewerIds }, ); } // Convenience method to assign reviewers from MR URL async assignReviewersToMergeRequestFromUrl( mrUrl: string, reviewerIds: number[], ): Promise<any> { const { projectPath, mrIid } = this.parseMrUrl(mrUrl, this.config.url); return this.assignReviewersToMergeRequest(projectPath, mrIid, reviewerIds); } // New tool: List Project Members (Contributors) async listProjectMembers(projectPath: string): Promise<any[]> { const encodedProjectPath = encodeURIComponent(projectPath); return this.callGitLabApi<any[]>( `projects/${encodedProjectPath}/members/all`, ); } // Convenience method to list project members from MR URL async listProjectMembersFromMrUrl(mrUrl: string): Promise<any[]> { const { projectPath } = this.parseMrUrl(mrUrl, this.config.url); return this.listProjectMembers(projectPath); } // New tool: List Project Members by Project Name async listProjectMembersByProjectName(projectName: string): Promise<any[]> { const projects = await this.listProjects(); const project = projects.find((p) => p.name === projectName); if (!project) { throw new Error(`Project with name ${projectName} not found.`); } return this.listProjectMembers(project.path_with_namespace); } // New tool: Filter Projects by Name (fuzzy, case-insensitive) async filterProjectsByName(projectName: string): Promise<GitLabProject[]> { const allProjects = await this.listProjects(); const lowerCaseProjectName = projectName.toLowerCase(); return allProjects.filter( (project) => project.name.toLowerCase().includes(lowerCaseProjectName) || project.name_with_namespace .toLowerCase() .includes(lowerCaseProjectName), ); } // New tool: Get Releases for a Project async getReleases(projectPath: string): Promise<any[]> { const encodedProjectPath = encodeURIComponent(projectPath); return this.callGitLabApi<any[]>(`projects/${encodedProjectPath}/releases`); } // New tool: Filter Releases Since a Specific Version async filterReleasesSinceVersion( projectPath: string, sinceVersion: string, ): Promise<any[]> { const allReleases = await this.getReleases(projectPath); const semver = await import('semver'); return allReleases.filter((release) => { try { return semver.gte(release.tag_name, sinceVersion); } catch (error) { console.error( `Could not parse version ${release.tag_name} or ${sinceVersion}: ${error}`, ); return false; } }); } // New tool: Get User ID by Username async getUserIdByUsername(username: string): Promise<number> { // First try exact username match const exactUsers = await this.callGitLabApi<GitLabUser[]>( `users?username=${username}`, ); if (exactUsers.length > 0) { return exactUsers[0].id; } // Fallback: search all users and filter by partial username (case-insensitive) const allUsers = await this.callGitLabApi<GitLabUser[]>(`users?per_page=100`); const lowerCaseUsername = username.toLowerCase(); const matchingUsers = allUsers.filter( (user) => user.username.toLowerCase().includes(lowerCaseUsername) || user.name.toLowerCase().includes(lowerCaseUsername), ); if (matchingUsers.length === 0) { throw new Error(`User with username containing '${username}' not found.`); } if (matchingUsers.length > 1) { const userList = matchingUsers .map((user) => `${user.username} (${user.name})`) .join(', '); throw new Error( `Multiple users found matching '${username}': ${userList}. Please be more specific.`, ); } return matchingUsers[0].id; } // New tool: Get User Activities async getUserActivities(userId: number, sinceDate?: Date): Promise<any[]> { let endpoint = `users/${userId}/events`; if (sinceDate) { // GitLab API expects ISO 8601 format for `after` parameter endpoint += `?after=${sinceDate.toISOString().split('T')[0]}`; } return this.callGitLabApi<any[]>(endpoint); } // New tool: Get Pipeline Status for a Project async getProjectPipelines(projectPath: string, ref?: string): Promise<any[]> { const encodedProjectPath = encodeURIComponent(projectPath); let endpoint = `projects/${encodedProjectPath}/pipelines`; if (ref) { endpoint += `?ref=${encodeURIComponent(ref)}`; } return this.callGitLabApi<any[]>(endpoint); } // New tool: Get Pipeline Status for MR async getMergeRequestPipelines(mrUrl: string): Promise<any[]> { const { projectPath, mrIid } = this.parseMrUrl(mrUrl, this.config.url); const encodedProjectPath = encodeURIComponent(projectPath); return this.callGitLabApi<any[]>( `projects/${encodedProjectPath}/merge_requests/${mrIid}/pipelines`, ); } // New tool: Get Pipeline Details async getPipelineDetails(projectPath: string, pipelineId: number): Promise<any> { const encodedProjectPath = encodeURIComponent(projectPath); return this.callGitLabApi<any>( `projects/${encodedProjectPath}/pipelines/${pipelineId}`, ); } // New tool: Get Pipeline Jobs async getPipelineJobs(projectPath: string, pipelineId: number): Promise<any[]> { const encodedProjectPath = encodeURIComponent(projectPath); return this.callGitLabApi<any[]>( `projects/${encodedProjectPath}/pipelines/${pipelineId}/jobs`, ); } // New tool: Get Job Logs async getJobLogs(projectPath: string, jobId: number): Promise<string> { const encodedProjectPath = encodeURIComponent(projectPath); const url = `${this.config.url}/api/v4/projects/${encodedProjectPath}/jobs/${jobId}/trace`; const headers = { 'Private-Token': this.config.accessToken, }; try { const response = await fetch(url, { headers }); if (!response.ok) { throw new Error(`Failed to get job logs: ${response.status} - ${response.statusText}`); } return await response.text(); } catch (error) { console.error(`Failed to get job logs: ${error}`); throw error; } } // New tool: Trigger Pipeline async triggerPipeline(projectPath: string, ref: string, variables?: Record<string, string>): Promise<any> { const encodedProjectPath = encodeURIComponent(projectPath); const body: any = { ref }; if (variables) { body.variables = Object.entries(variables).map(([key, value]) => ({ key, value })); } return this.callGitLabApi<any>( `projects/${encodedProjectPath}/pipeline`, 'POST', body, ); } // New tool: Retry Pipeline async retryPipeline(projectPath: string, pipelineId: number): Promise<any> { const encodedProjectPath = encodeURIComponent(projectPath); return this.callGitLabApi<any>( `projects/${encodedProjectPath}/pipelines/${pipelineId}/retry`, 'POST', ); } // New tool: Cancel Pipeline async cancelPipeline(projectPath: string, pipelineId: number): Promise<any> { const encodedProjectPath = encodeURIComponent(projectPath); return this.callGitLabApi<any>( `projects/${encodedProjectPath}/pipelines/${pipelineId}/cancel`, 'POST', ); } // New tool: List Branches async listBranches(projectPath: string): Promise<any[]> { const encodedProjectPath = encodeURIComponent(projectPath); return this.callGitLabApi<any[]>(`projects/${encodedProjectPath}/repository/branches`); } // New tool: Create Branch async createBranch(projectPath: string, branchName: string, ref: string): Promise<any> { const encodedProjectPath = encodeURIComponent(projectPath); return this.callGitLabApi<any>( `projects/${encodedProjectPath}/repository/branches`, 'POST', { branch: branchName, ref: ref, }, ); } // New tool: Delete Branch async deleteBranch(projectPath: string, branchName: string): Promise<void> { const encodedProjectPath = encodeURIComponent(projectPath); const encodedBranchName = encodeURIComponent(branchName); await this.callGitLabApi<any>( `projects/${encodedProjectPath}/repository/branches/${encodedBranchName}`, 'DELETE', ); } // New tool: Get Branch Details async getBranchDetails(projectPath: string, branchName: string): Promise<any> { const encodedProjectPath = encodeURIComponent(projectPath); const encodedBranchName = encodeURIComponent(branchName); return this.callGitLabApi<any>( `projects/${encodedProjectPath}/repository/branches/${encodedBranchName}`, ); } // New tool: List Project Issues async listProjectIssues(projectPath: string, state?: 'opened' | 'closed' | 'all'): Promise<any[]> { const encodedProjectPath = encodeURIComponent(projectPath); let endpoint = `projects/${encodedProjectPath}/issues`; if (state) { endpoint += `?state=${state}`; } return this.callGitLabApi<any[]>(endpoint); } // New tool: Get Issue Details async getIssueDetails(projectPath: string, issueIid: number): Promise<any> { const encodedProjectPath = encodeURIComponent(projectPath); return this.callGitLabApi<any>( `projects/${encodedProjectPath}/issues/${issueIid}`, ); } // New tool: Create Issue async createIssue(projectPath: string, title: string, description?: string, labels?: string[], assigneeIds?: number[]): Promise<any> { const encodedProjectPath = encodeURIComponent(projectPath); const body: any = { title }; if (description) body.description = description; if (labels && labels.length > 0) body.labels = labels.join(','); if (assigneeIds && assigneeIds.length > 0) body.assignee_ids = assigneeIds; return this.callGitLabApi<any>( `projects/${encodedProjectPath}/issues`, 'POST', body, ); } // New tool: Update Issue async updateIssue(projectPath: string, issueIid: number, updates: { title?: string; description?: string; labels?: string[]; assigneeIds?: number[]; state?: 'close' | 'reopen'; }): Promise<any> { const encodedProjectPath = encodeURIComponent(projectPath); const body: any = {}; if (updates.title) body.title = updates.title; if (updates.description) body.description = updates.description; if (updates.labels) body.labels = updates.labels.join(','); if (updates.assigneeIds) body.assignee_ids = updates.assigneeIds; if (updates.state) body.state_event = updates.state; return this.callGitLabApi<any>( `projects/${encodedProjectPath}/issues/${issueIid}`, 'PUT', body, ); } // New tool: Close Issue async closeIssue(projectPath: string, issueIid: number): Promise<any> { return this.updateIssue(projectPath, issueIid, { state: 'close' }); } // New tool: Add Comment to Issue async addCommentToIssue(projectPath: string, issueIid: number, body: string): Promise<any> { const encodedProjectPath = encodeURIComponent(projectPath); return this.callGitLabApi<any>( `projects/${encodedProjectPath}/issues/${issueIid}/notes`, 'POST', { body }, ); } // New tool: Get Issue Comments async getIssueComments(projectPath: string, issueIid: number): Promise<any[]> { const encodedProjectPath = encodeURIComponent(projectPath); return this.callGitLabApi<any[]>( `projects/${encodedProjectPath}/issues/${issueIid}/notes`, ); } // New tool: Get Project Tags async getProjectTags(projectPath: string): Promise<any[]> { const encodedProjectPath = encodeURIComponent(projectPath); return this.callGitLabApi<any[]>(`projects/${encodedProjectPath}/repository/tags`); } // New tool: Create Tag async createTag(projectPath: string, tagName: string, ref: string, message?: string): Promise<any> { const encodedProjectPath = encodeURIComponent(projectPath); const body: any = { tag_name: tagName, ref: ref, }; if (message) body.message = message; return this.callGitLabApi<any>( `projects/${encodedProjectPath}/repository/tags`, 'POST', body, ); } // New tool: Get Deployment Status async getDeployments(projectPath: string, environment?: string): Promise<any[]> { const encodedProjectPath = encodeURIComponent(projectPath); let endpoint = `projects/${encodedProjectPath}/deployments`; if (environment) { endpoint += `?environment=${encodeURIComponent(environment)}`; } return this.callGitLabApi<any[]>(endpoint); } // New tool: Get Project Statistics async getProjectStatistics(projectPath: string): Promise<any> { const encodedProjectPath = encodeURIComponent(projectPath); return this.callGitLabApi<any>(`projects/${encodedProjectPath}?statistics=true`); } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/HainanZhao/mcp-gitlab-jira'

If you have feedback or need assistance with the MCP directory API, please join our Discord server