@hicho-lge/gerrit-mcp
Version:
Gerrit Code Review system을 위한 Model Context Protocol (MCP) 서버 - 23개 도구로 변경사항, 프로젝트, 계정 관리 기능 제공
660 lines (649 loc) • 29.8 kB
JavaScript
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}`);
}
}
}