UNPKG

@hicho-lge/gerrit-mcp

Version:

Gerrit Code Review system을 위한 Model Context Protocol (MCP) 서버 - 23개 도구로 변경사항, 프로젝트, 계정 관리 기능 제공

660 lines (649 loc) 29.8 kB
import axios from 'axios'; export class GerritClient { constructor(config) { this.config = config; const baseURL = config.baseUrl.endsWith('/') ? config.baseUrl.slice(0, -1) : config.baseUrl; this.client = axios.create({ baseURL: `${baseURL}/a`, // Use authenticated endpoint timeout: 30000, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); // Setup authentication if (config.username && config.password) { this.client.defaults.auth = { username: config.username, password: config.password }; } else if (config.accessToken) { this.client.defaults.params = { access_token: config.accessToken }; } // Add response interceptor to handle Gerrit's security prefix this.client.interceptors.response.use((response) => { if (typeof response.data === 'string' && response.data.startsWith(")]}'\n")) { try { response.data = JSON.parse(response.data.slice(5)); } catch (e) { console.warn('Failed to parse Gerrit JSON response:', e); } } return response; }, (error) => { if (error.response?.data && typeof error.response.data === 'string' && error.response.data.startsWith(")]}'\n")) { try { error.response.data = JSON.parse(error.response.data.slice(5)); } catch (e) { // Ignore parse errors for error responses } } return Promise.reject(error); }); } // Changes API async queryChanges(options) { const params = { q: options.q }; if (options.o) params.o = options.o; if (options.n) params.n = options.n; if (options.S) params.S = options.S; const response = await this.client.get('/changes/', { params }); return response.data; } async getChange(changeId, options) { let url = `/changes/${encodeURIComponent(changeId)}`; if (options && options.length > 0) { const queryParams = options.map(opt => `o=${encodeURIComponent(opt)}`).join('&'); url += `?${queryParams}`; } const response = await this.client.get(url); return response.data; } async getChangeDetail(changeId) { try { return await this.getChange(changeId, [ 'LABELS', 'DETAILED_LABELS', 'CURRENT_REVISION', 'ALL_REVISIONS', 'CURRENT_COMMIT', 'ALL_COMMITS', 'MESSAGES', 'DETAILED_ACCOUNTS' ]); } catch (error) { if (error.response?.status === 400) { // Try with basic options if full detail fails try { return await this.getChange(changeId, ['CURRENT_REVISION', 'LABELS']); } catch (basicError) { throw new Error(`Invalid change ID: ${changeId}. Please check the change ID format.`); } } throw error; } } async reviewChange(changeId, revisionId, review) { const response = await this.client.post(`/changes/${encodeURIComponent(changeId)}/revisions/${revisionId}/review`, review); return response.data; } async abandonChange(changeId, message) { try { // First check if change exists and get current status const change = await this.getChange(changeId); if (change.status === 'ABANDONED') { throw new Error(`Change ${changeId} is already abandoned.`); } if (change.status === 'MERGED') { throw new Error(`Cannot abandon merged change ${changeId}.`); } const body = message ? { message } : {}; const response = await this.client.post(`/changes/${encodeURIComponent(changeId)}/abandon`, body); return response.data; } catch (error) { if (error.response?.status === 403) { throw new Error(`Permission denied: You don't have permission to abandon this change. Only the change owner or project administrators can abandon changes.`); } else if (error.response?.status === 409) { throw new Error(`Conflict: Change ${changeId} cannot be abandoned in its current state.`); } throw error; } } // Projects API async listProjects(options) { const params = {}; if (options?.limit) params.n = options.limit; if (options?.skip) params.S = options.skip; if (options?.prefix) params.p = options.prefix; if (options?.regex) params.r = options.regex; if (options?.description) params.d = true; if (options?.all) params.all = true; const response = await this.client.get('/projects/', { params }); return response.data; } async getProject(projectName) { const response = await this.client.get(`/projects/${encodeURIComponent(projectName)}`); return response.data; } async createProject(projectName, input) { const response = await this.client.put(`/projects/${encodeURIComponent(projectName)}`, input); return response.data; } // Accounts API async getAccount(accountId) { const response = await this.client.get(`/accounts/${encodeURIComponent(accountId)}`); return response.data; } async getSelf() { const response = await this.client.get('/accounts/self'); return response.data; } async queryAccounts(query, options) { const params = { q: query }; if (options?.limit) params.n = options.limit; if (options?.skip) params.S = options.skip; if (options?.suggest) params.suggest = true; if (options?.withDetails) params.o = ['DETAILS']; const response = await this.client.get('/accounts/', { params }); return response.data; } // Groups API async listGroups(options) { const params = {}; if (options?.limit) params.n = options.limit; if (options?.skip) params.S = options.skip; if (options?.owned) params.owned = true; if (options?.visibleToAll) params['visible-to-all'] = true; if (options?.user) params.user = options.user; if (options?.group) params.group = options.group; if (options?.regex) params.r = options.regex; const response = await this.client.get('/groups/', { params }); return response.data; } async getGroup(groupId) { const response = await this.client.get(`/groups/${encodeURIComponent(groupId)}`); return response.data; } // Utility methods async testConnection() { try { const account = await this.getSelf(); return { success: true, account }; } catch (error) { return { success: false, error: error.response?.data?.message || error.message || 'Unknown error' }; } } async getServerVersion() { try { const response = await this.client.get('/config/server/version'); return response.data; } catch (error) { return 'Unknown'; } } // Extended Projects API async getProjectDescription(projectName) { const project = await this.getProject(projectName); return project.description || ''; } async getProjectHead(projectName) { const response = await this.client.get(`/projects/${encodeURIComponent(projectName)}/HEAD`); return response.data; } async listProjectBranches(projectName, options) { const params = {}; if (options?.limit) params.n = options.limit; if (options?.skip) params.S = options.skip; if (options?.substring) params.m = options.substring; if (options?.regex) params.r = options.regex; const response = await this.client.get(`/projects/${encodeURIComponent(projectName)}/branches/`, { params }); return response.data; } async listProjectTags(projectName, options) { const params = {}; if (options?.limit) params.n = options.limit; if (options?.skip) params.S = options.skip; if (options?.substring) params.m = options.substring; if (options?.regex) params.r = options.regex; const response = await this.client.get(`/projects/${encodeURIComponent(projectName)}/tags/`, { params }); return response.data; } // Change Comments API async getChangeComments(changeId) { const response = await this.client.get(`/changes/${encodeURIComponent(changeId)}/comments`); return response.data; } async replyToInlineComment(changeId, revisionId, draft) { const response = await this.client.put(`/changes/${encodeURIComponent(changeId)}/revisions/${revisionId}/drafts`, draft); return response.data; } // Commit Management API async getCommit(projectName, commitId) { try { // Ensure commit ID is properly formatted (full SHA or at least 7 chars) if (commitId.length < 7) { throw new Error(`Commit ID must be at least 7 characters long: ${commitId}`); } const response = await this.client.get(`/projects/${encodeURIComponent(projectName)}/commits/${commitId}`); return response.data; } catch (error) { if (error.response?.status === 404) { throw new Error(`Commit ${commitId} not found in project ${projectName}. Please verify the commit ID and project name.`); } throw error; } } async getIncludedIn(changeId, revisionId) { try { // First verify the change and revision exist const change = await this.getChange(changeId, ['ALL_REVISIONS', 'CURRENT_REVISION']); if (!change.revisions || Object.keys(change.revisions).length === 0) { throw new Error(`No revisions found for change ${changeId}`); } let actualRevisionId = revisionId; // Handle different revision ID formats if (revisionId === 'current') { actualRevisionId = change.current_revision; if (!actualRevisionId) { // Fallback to the first revision if current_revision is not available actualRevisionId = Object.keys(change.revisions)[0]; } } else if (revisionId.length < 40) { // If it's a short SHA, try to find the full SHA const matchingRevision = Object.keys(change.revisions).find(rev => rev.startsWith(revisionId)); if (matchingRevision) { actualRevisionId = matchingRevision; } } if (!change.revisions[actualRevisionId]) { const availableRevisions = Object.keys(change.revisions).map(rev => `${rev.substring(0, 8)}... (${change.revisions[rev]._number})`).join(', '); throw new Error(`Revision ${revisionId} not found for change ${changeId}. Available revisions: ${availableRevisions}`); } // Use the commit SHA instead of revision ID for the API call if available const revision = change.revisions[actualRevisionId]; const commitId = revision.commit?.commit || actualRevisionId; try { const response = await this.client.get(`/changes/${encodeURIComponent(changeId)}/revisions/${actualRevisionId}/included_in`); return response.data; } catch (apiError) { if (apiError.response?.status === 404) { // Try alternative approach with commit ID try { const commitResponse = await this.client.get(`/projects/${encodeURIComponent(change.project)}/commits/${commitId}/included_in`); return commitResponse.data; } catch (commitError) { // Check if this is an unmerged change if (change.status === 'NEW' || change.status === 'DRAFT') { throw new Error(`변경사항 ${changeId}는 아직 병합되지 않았습니다. 브랜치/태그 정보는 병합된 변경사항에서만 조회할 수 있습니다. (현재 상태: ${change.status})`); } else if (change.status === 'ABANDONED') { throw new Error(`변경사항 ${changeId}는 포기된 상태입니다. 포기된 변경사항은 브랜치/태그에 포함되지 않습니다.`); } else { throw new Error(`변경사항 ${changeId}의 브랜치/태그 정보를 찾을 수 없습니다. 커밋이 아직 병합되지 않았거나 접근 권한이 없을 수 있습니다.`); } } } else if (apiError.response?.status === 403) { throw new Error(`변경사항 ${changeId}의 브랜치/태그 정보에 접근할 권한이 없습니다. 프로젝트 접근 권한을 확인해주세요.`); } throw apiError; } } catch (error) { if (error.response?.status === 404) { throw new Error(`Change ${changeId} not found. Please verify the change ID.`); } throw error; } } // File Management API async listFiles(changeId, revisionId, options) { const params = {}; if (options?.base) params.base = options.base; if (options?.parent) params.parent = options.parent; if (options?.q) params.q = options.q; const response = await this.client.get(`/changes/${encodeURIComponent(changeId)}/revisions/${revisionId}/files`, { params }); return response.data; } async getFileContent(changeId, revisionId, filePath, options) { try { const params = {}; if (options?.base) params.base = options.base; if (options?.parent) params.parent = options.parent; const response = await this.client.get(`/changes/${encodeURIComponent(changeId)}/revisions/${revisionId}/files/${encodeURIComponent(filePath)}/content`, { params }); // Gerrit may return content as plain text or base64 encoded if (typeof response.data === 'string') { // First check if this is already plain text (common for text files) if (response.data.includes('\n') || response.data.includes('\r') || response.data.match(/^[\x20-\x7E\s]*$/)) { // This appears to be plain text already, return as-is return response.data; } try { // Try to decode as base64 (for binary files or some text files) const decodedBuffer = Buffer.from(response.data, 'base64'); // Get file extension for smarter detection const fileExt = filePath.split('.').pop()?.toLowerCase() || ''; const fileName = filePath.split('/').pop()?.toLowerCase() || ''; // Define known text file extensions (prioritize text treatment) const textExtensions = [ // Source code 'js', 'ts', 'jsx', 'tsx', 'java', 'cpp', 'c', 'cc', 'cxx', 'h', 'hpp', 'hxx', 'py', 'rb', 'php', 'go', 'rs', 'swift', 'kt', 'scala', 'cs', 'vb', 'pl', 'sh', // Web 'html', 'htm', 'css', 'scss', 'sass', 'less', 'vue', // Config/markup 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'properties', 'md', 'txt', 'rst', 'adoc', 'tex', 'log', // Build/project files 'makefile', 'cmake', 'gradle', 'pom', 'sbt', 'build', 'bzl', 'bazel', // Other 'sql', 'proto', 'thrift', 'graphql', 'dockerfile' ]; // Define known binary file extensions const binaryExtensions = [ // Images 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'svg', 'ico', // Archives 'zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar', 'jar', 'war', 'ear', // Executables 'exe', 'dll', 'so', 'dylib', 'bin', 'out', 'app', 'deb', 'rpm', // Documents 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', // Media 'mp3', 'mp4', 'avi', 'mov', 'wav', 'flac', 'ogg', 'webm', 'mkv', // Fonts 'ttf', 'otf', 'woff', 'woff2', 'eot' ]; // Special file names that are always text const textFileNames = ['makefile', 'dockerfile', 'readme', 'license', 'changelog']; let isDefinitelyText = false; let isDefinitelyBinary = false; // Check by file extension or name if (textExtensions.includes(fileExt) || textFileNames.some(name => fileName.includes(name))) { isDefinitelyText = true; } else if (binaryExtensions.includes(fileExt)) { isDefinitelyBinary = true; } // Smart text decoding with multiple encoding attempts const tryDecodeAsText = (buffer) => { // Define encodings to try in order const encodings = ['utf8', 'utf16le', 'latin1', 'ascii']; for (const encoding of encodings) { try { const decoded = buffer.toString(encoding); // Check for reasonable text characteristics const nullBytes = (decoded.match(/\x00/g) || []).length; const controlChars = (decoded.match(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g) || []).length; const printableChars = decoded.length - controlChars; // Consider it text if: // 1. Less than 5% null bytes // 2. Less than 30% control characters // 3. At least some printable content const nullRatio = nullBytes / decoded.length; const controlRatio = controlChars / decoded.length; if (nullRatio < 0.05 && controlRatio < 0.30 && printableChars > 0) { return { success: true, content: decoded, encoding }; } } catch (error) { // Try next encoding continue; } } return { success: false }; }; if (isDefinitelyBinary) { // Handle as binary file if (binaryExtensions.includes(fileExt)) { return `[Binary file: ${filePath} (${decodedBuffer.length} bytes)] This is a ${fileExt.toUpperCase()} file and cannot be displayed as text. To download the file, use the Gerrit web interface or git commands.`; } else { // Unknown binary file, show hex dump const hexDump = decodedBuffer.subarray(0, 256).toString('hex').match(/.{1,2}/g)?.join(' ') || ''; return `[Binary file: ${filePath} (${decodedBuffer.length} bytes)] Hex dump (first 256 bytes): ${hexDump} ...truncated`; } } else { // Try to decode as text const decodeResult = tryDecodeAsText(decodedBuffer); if (decodeResult.success && decodeResult.content) { return decodeResult.content; } else if (isDefinitelyText) { // It should be text but we can't decode it properly // Show it as hex with explanation const hexDump = decodedBuffer.subarray(0, 512).toString('hex').match(/.{1,2}/g)?.join(' ') || ''; return `[Text file with encoding issues: ${filePath}] This appears to be a text file (${fileExt.toUpperCase()}) but has encoding issues. File may use an unsupported encoding or be corrupted. Hex representation (first 512 bytes): ${hexDump} ...truncated`; } else { // Unknown file type that we couldn't decode - treat as binary const hexDump = decodedBuffer.subarray(0, 256).toString('hex').match(/.{1,2}/g)?.join(' ') || ''; return `[Unknown file type: ${filePath} (${decodedBuffer.length} bytes)] Could not determine if this is text or binary. Showing hex dump: ${hexDump} ...truncated`; } } } catch (base64Error) { // If base64 decoding fails, return the original string return response.data; } } // If response.data is not a string, return as JSON return typeof response.data === 'object' ? JSON.stringify(response.data, null, 2) : String(response.data); } catch (error) { if (error.response?.status === 404) { throw new Error(`File ${filePath} not found in change ${changeId} revision ${revisionId}`); } throw error; } } // Access Rights API async listAccessRights(projectName) { const response = await this.client.get(`/projects/${encodeURIComponent(projectName)}/access`); return response.data; } // Clone 정보 관련 메서드들 async getProjectCloneInfo(projectName) { const project = await this.getProject(projectName); const branches = await this.listProjectBranches(projectName); const head = await this.getProjectHead(projectName); const baseUrl = this.config.baseUrl.endsWith('/') ? this.config.baseUrl.slice(0, -1) : this.config.baseUrl; const cloneInfo = { project: projectName, cloneUrls: { http: `${baseUrl}/${projectName}`, anonymous: `${baseUrl}/${projectName}`, }, description: project.description, branches: branches.map(b => b.ref.replace('refs/heads/', '')), defaultBranch: head.replace('refs/heads/', '') }; // SSH URL은 일반적으로 다른 포트를 사용하므로 설정에 따라 추가 if (this.config.username) { const url = new URL(baseUrl); cloneInfo.cloneUrls.ssh = `ssh://${this.config.username}@${url.hostname}:29418/${projectName}`; } return cloneInfo; } async getChangeFetchInfo(changeId, patchSetNumber) { try { const change = await this.getChange(changeId, ['CURRENT_REVISION', 'ALL_REVISIONS', 'CURRENT_COMMIT']); if (!change.revisions || Object.keys(change.revisions).length === 0) { throw new Error(`No revisions found for change ${changeId}`); } let targetRevision; let targetRevisionId; if (patchSetNumber) { // 특정 patch-set 번호로 revision 찾기 const revisionEntry = Object.entries(change.revisions).find(([_, revision]) => revision._number === patchSetNumber); if (!revisionEntry) { const availablePatchSets = Object.values(change.revisions).map(r => r._number).join(', '); throw new Error(`Patch-set ${patchSetNumber} not found for change ${changeId}. Available patch-sets: ${availablePatchSets}`); } targetRevisionId = revisionEntry[0]; targetRevision = revisionEntry[1]; } else { // 현재 revision 사용 targetRevisionId = change.current_revision || Object.keys(change.revisions)[0]; targetRevision = change.revisions[targetRevisionId]; if (!targetRevision) { throw new Error(`No current revision found for change ${changeId}`); } } if (!targetRevision.ref) { throw new Error(`No ref found for revision in change ${changeId}`); } const baseUrl = this.config.baseUrl.endsWith('/') ? this.config.baseUrl.slice(0, -1) : this.config.baseUrl; const fetchInfo = { changeId: change.change_id || change.id || changeId, project: change.project, branch: change.branch, ref: targetRevision.ref, commitId: targetRevisionId || targetRevision.commit?.commit || '', patchSetNumber: targetRevision._number, cloneUrls: { http: `${baseUrl}/${change.project}`, anonymous: `${baseUrl}/${change.project}`, }, fetchCommands: { http: `git fetch ${baseUrl}/${change.project} ${targetRevision.ref} && git cherry-pick FETCH_HEAD`, } }; // SSH URL과 명령어 추가 if (this.config.username) { try { const url = new URL(baseUrl); const sshUrl = `ssh://${this.config.username}@${url.hostname}:29418/${change.project}`; fetchInfo.cloneUrls.ssh = sshUrl; fetchInfo.fetchCommands.ssh = `git fetch ${sshUrl} ${targetRevision.ref} && git cherry-pick FETCH_HEAD`; } catch (urlError) { // SSH URL 생성 실패시 무시 } } return fetchInfo; } catch (error) { if (error.response?.status === 400) { throw new Error(`Invalid change ID or revision: ${changeId}. Please check the change ID format.`); } throw error; } } // Enhanced error handling helper enhanceError(error, context) { if (!error.response) { return new Error(`Network error in ${context}: ${error.message}`); } const status = error.response.status; const data = error.response.data; const message = data?.message || error.message || 'Unknown error'; switch (status) { case 400: return new Error(`Bad Request in ${context}: ${message}`); case 401: return new Error(`Authentication failed in ${context}: Please check your credentials`); case 403: return new Error(`Permission denied in ${context}: ${message}`); case 404: return new Error(`Not found in ${context}: ${message}`); case 409: return new Error(`Conflict in ${context}: ${message}`); case 422: return new Error(`Unprocessable entity in ${context}: ${message}`); case 500: return new Error(`Server error in ${context}: ${message}`); default: return new Error(`HTTP ${status} error in ${context}: ${message}`); } } // Raw API call for custom endpoints async apiCall(method, endpoint, data) { try { const response = await this.client.request({ method, url: endpoint, data }); return response.data; } catch (error) { throw this.enhanceError(error, `API call ${method} ${endpoint}`); } } }