using System.ComponentModel; using System.Text.Json; using LazyBear.MCP.Services.ToolRegistry; using ModelContextProtocol.Server; using RestSharp; namespace LazyBear.MCP.Services.GitLab; public sealed class GitLabMergeRequestTools( GitLabClientProvider provider, IConfiguration configuration, ToolRegistryService registry) { private readonly string _token = configuration["GitLab:Token"] ?? string.Empty; private readonly string _baseUrl = configuration["GitLab:Url"] ?? string.Empty; private const string ModuleName = "GitLab"; private bool TryCheckEnabled(string toolName, out string error) { if (!registry.IsToolEnabled(ModuleName, toolName)) { error = $"Инструмент '{toolName}' модуля GitLab отключён в TUI."; return false; } error = string.Empty; return true; } private bool TryGetClient(out RestSharp.IRestClient client, out string error) { if (provider.InitializationError is not null) { client = null!; error = $"GitLab клиент не инициализирован. Детали: {provider.InitializationError}"; return false; } var clientInstance = provider.GetClient(); if (clientInstance is null) { client = null!; error = "GitLab клиент не создан."; return false; } client = clientInstance.RestClient; error = string.Empty; return true; } private string FormatResponseError(string toolName, RestResponse response, string? resource = null) { var resourcePart = string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'"; var body = string.IsNullOrWhiteSpace(response.Content) ? "-" : response.Content; return $"Ошибка GitLab в tool '{toolName}'{resourcePart}: status={(int)response.StatusCode}, details={body}"; } private string FormatException(string toolName, Exception exception, string? resource = null) { var resourcePart = string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'"; return $"Ошибка GitLab в tool '{toolName}'{resourcePart}: {exception.GetType().Name}: {exception.Message}"; } private static string GetVisibility(string visibility) => visibility switch { "public" => "Public", "internal" => "Internal", "private" => "Private", _ => visibility ?? "unknown" }; private static string? GetNestedString(JsonElement element, params string[] path) { var current = element; foreach (var segment in path) { if (!current.TryGetProperty(segment, out current)) { return null; } } return current.ValueKind == JsonValueKind.String ? current.GetString() : current.ToString(); } private static string GetState(string state) => state switch { "opened" => "Opened", "merged" => "Merged", "closed" => "Closed", "declined" => "Declined", _ => state ?? "unknown" }; /// /// Получить список MR /// /// ID проекта /// Token отмены [McpServerTool, Description("Получить список Merge Requests")] public async Task ListMergeRequests( [Description("ID проекта")] int projectId, CancellationToken cancellationToken = default) { if (!TryCheckEnabled("ListMergeRequests", out var enabledError)) return enabledError; if (projectId <= 0) { return "ID проекта GitLab некорректно задан."; } if (!TryGetClient(out var client, out var error)) return error; try { var request = new RestRequest($"/projects/{projectId}/merge_requests", RestSharp.Method.Get); request.AddHeader("Accept", "application/json"); request.AddHeader("PRIVATE-TOKEN", _token); request.AddQueryParameter("per_page", "30"); var response = await client.ExecuteAsync(request, cancellationToken); if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) { return FormatResponseError("list_merge_requests", response, $"/projects/{projectId}/merge_requests"); } using var document = JsonDocument.Parse(response.Content); var root = document.RootElement; if (!root.TryGetProperty("merge_requests", out var mrElement) || mrElement.GetArrayLength() == 0) { return $"Merge Request в проекте #{projectId} не найдены."; } var lines = new List(); foreach (var mr in mrElement.EnumerateArray()) { var iid = GetNestedString(mr, "iid") ?? "-"; var title = GetNestedString(mr, "title") ?? "-"; var state = GetState(GetNestedString(mr, "state") ?? ""); var sourceBranch = GetNestedString(mr, "source", "branch") ?? "-"; var targetBranch = GetNestedString(mr, "target", "branch") ?? "-"; var author = GetNestedString(mr, "author", "name") ?? "-"; var webUrl = GetNestedString(mr, "web_url") ?? "-"; lines.Add($"#{iid} - {state}\n {title}"); lines.Add($" {sourceBranch} -> {targetBranch}\n author: {author}\n URL: {webUrl}"); } return $"Merge Requests проекта #{projectId} ({mrElement.GetArrayLength()} шт.):\n{string.Join('\n', lines)}"; } catch (Exception ex) { return FormatException("list_merge_requests", ex); } } /// /// Получить конкретный MR /// /// ID проекта /// ID Merge Request /// Token отмены [McpServerTool, Description("Получить конкретный Merge Request")] public async Task GetMergeRequest( [Description("ID проекта")] int projectId, [Description("ID Merge Request")] int mrIid, CancellationToken cancellationToken = default) { if (!TryCheckEnabled("GetMergeRequest", out var enabledError)) return enabledError; if (projectId <= 0) { return "ID проекта GitLab некорректно задан."; } if (mrIid <= 0) { return "ID Merge Request GitLab некорректно задан."; } if (!TryGetClient(out var client, out var error)) return error; try { var request = new RestRequest($"/projects/{projectId}/merge_requests/{mrIid}", RestSharp.Method.Get); request.AddHeader("Accept", "application/json"); request.AddHeader("PRIVATE-TOKEN", _token); var response = await client.ExecuteAsync(request, cancellationToken); if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) { return FormatResponseError("get_merge_request", response, $"/projects/{projectId}/merge_requests/{mrIid}"); } using var document = JsonDocument.Parse(response.Content); var root = document.RootElement; var iid = GetNestedString(root, "iid") ?? "-"; var title = GetNestedString(root, "title") ?? "-"; var state = GetState(GetNestedString(root, "state") ?? ""); var sourceBranch = GetNestedString(root, "source", "branch") ?? "-"; var targetBranch = GetNestedString(root, "target", "branch") ?? "-"; var author = GetNestedString(root, "author", "name") ?? "-"; var webUrl = GetNestedString(root, "web_url") ?? "-"; var mergedAt = GetNestedString(root, "merged_at") ?? "-"; var status = GetNestedString(root, "status") ?? "unknown"; return $"Merge Request #{iid} в проекте #{projectId}:\n{title} [{state}]\n {sourceBranch} -> {targetBranch}\n author: {author}\n URL: {webUrl}\n merged_at: {mergedAt}\n status: {status}"; } catch (Exception ex) { return FormatException("get_merge_request", ex, $"/projects/{projectId}/merge_requests/{mrIid}"); } } /// /// Создать Merge Request /// /// ID проекта /// Заголовок MR /// Имя ветки источника /// Имя целевой ветки /// Описание (опционально) /// Token отмены [McpServerTool, Description("Создать Merge Request")] public async Task CreateMergeRequest( [Description("ID проекта")] int projectId, [Description("Заголовок MR")] string title, [Description("Имя ветки источника")] string sourceBranch, [Description("Имя целевой ветки")] string targetBranch, [Description("Описание MR")] string? description = null, CancellationToken cancellationToken = default) { if (!TryCheckEnabled("CreateMergeRequest", out var enabledError)) return enabledError; if (projectId <= 0) { return "ID проекта GitLab некорректно задан."; } if (string.IsNullOrWhiteSpace(title)) { return "Заголовок MR GitLab не может быть пустым."; } if (string.IsNullOrWhiteSpace(sourceBranch)) { return "Имя ветки источника GitLab не может быть пустым."; } if (string.IsNullOrWhiteSpace(targetBranch)) { return "Имя целевой ветки GitLab не может быть пустым."; } if (!TryGetClient(out var client, out var error)) return error; try { var request = new RestRequest($"/projects/{projectId}/merge_requests", RestSharp.Method.Post); request.AddHeader("Accept", "application/json"); request.AddHeader("PRIVATE-TOKEN", _token); request.AddHeader("Content-Type", "application/json"); var jsonBody = new { title = title, source_branch = sourceBranch, target_branch = targetBranch, description = description ?? string.Empty }.ToJson(); request.AddJsonBody(jsonBody); var response = await client.ExecuteAsync(request, cancellationToken); if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) { return FormatResponseError("create_merge_request", response, $"/projects/{projectId}/merge_requests"); } using var document = JsonDocument.Parse(response.Content); var root = document.RootElement; var iid = GetNestedString(root, "iid") ?? "-"; var mrTitle = GetNestedString(root, "title") ?? "-"; var state = GetState(GetNestedString(root, "state") ?? ""); var webUrl = GetNestedString(root, "web_url") ?? "-"; return $"Merge Request успешно создан в проекте #{projectId}:\nID: #{iid}\n{mrTitle} [{state}]\nURL: {webUrl}"; } catch (Exception ex) { return FormatException("create_merge_request", ex); } } /// /// Закрыть MR /// /// ID проекта /// ID Merge Request /// Token отмены [McpServerTool, Description("Закрыть Merge Request")] public async Task CloseMergeRequest( [Description("ID проекта")] int projectId, [Description("ID Merge Request")] int mrIid, CancellationToken cancellationToken = default) { if (!TryCheckEnabled("CloseMergeRequest", out var enabledError)) return enabledError; if (projectId <= 0) { return "ID проекта GitLab некорректно задан."; } if (mrIid <= 0) { return "ID Merge Request GitLab некорректно задан."; } if (!TryGetClient(out var client, out var error)) return error; try { var request = new RestRequest($"/projects/{projectId}/merge_requests/{mrIid}", RestSharp.Method.Put); request.AddHeader("Accept", "application/json"); request.AddHeader("PRIVATE-TOKEN", _token); request.AddHeader("Content-Type", "application/json"); var jsonBody = new { state = "closed" }.ToJson(); request.AddJsonBody(jsonBody); var response = await client.ExecuteAsync(request, cancellationToken); if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) { return FormatResponseError("close_merge_request", response, $"/projects/{projectId}/merge_requests/{mrIid}"); } using var document = JsonDocument.Parse(response.Content); var root = document.RootElement; var mrTitle = GetNestedString(root, "title") ?? "-"; return $"Merge Request #{mrIid} ({mrTitle}) успешно закрыт в проекте #{projectId}."; } catch (Exception ex) { return FormatException("close_merge_request", ex, $"/projects/{projectId}/merge_requests/{mrIid}"); } } /// /// Открыть MR /// /// ID проекта /// ID Merge Request /// Token отмены [McpServerTool, Description("Открыть Merge Request")] public async Task OpenMergeRequest( [Description("ID проекта")] int projectId, [Description("ID Merge Request")] int mrIid, CancellationToken cancellationToken = default) { if (!TryCheckEnabled("OpenMergeRequest", out var enabledError)) return enabledError; if (projectId <= 0) { return "ID проекта GitLab некорректно задан."; } if (mrIid <= 0) { return "ID Merge Request GitLab некорректно задан."; } if (!TryGetClient(out var client, out var error)) return error; try { var request = new RestRequest($"/projects/{projectId}/merge_requests/{mrIid}", RestSharp.Method.Put); request.AddHeader("Accept", "application/json"); request.AddHeader("PRIVATE-TOKEN", _token); request.AddHeader("Content-Type", "application/json"); var jsonBody = new { state = "opened" }.ToJson(); request.AddJsonBody(jsonBody); var response = await client.ExecuteAsync(request, cancellationToken); if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) { return FormatResponseError("open_merge_request", response, $"/projects/{projectId}/merge_requests/{mrIid}"); } using var document = JsonDocument.Parse(response.Content); var root = document.RootElement; var mrTitle = GetNestedString(root, "title") ?? "-"; return $"Merge Request #{mrIid} ({mrTitle}) успешно открыт в проекте #{projectId}."; } catch (Exception ex) { return FormatException("open_merge_request", ex, $"/projects/{projectId}/merge_requests/{mrIid}"); } } /// /// Получить замечания к MR /// /// ID проекта /// ID Merge Request /// Token отмены [McpServerTool, Description("Получить замечания к Merge Request")] public async Task ListMergeRequestNotes( [Description("ID проекта")] int projectId, [Description("ID Merge Request")] int mrIid, CancellationToken cancellationToken = default) { if (!TryCheckEnabled("ListMergeRequestNotes", out var enabledError)) return enabledError; if (projectId <= 0) { return "ID проекта GitLab некорректно задан."; } if (mrIid <= 0) { return "ID Merge Request GitLab некорректно задан."; } if (!TryGetClient(out var client, out var error)) return error; try { var request = new RestRequest($"/projects/{projectId}/merge_requests/{mrIid}/notes", RestSharp.Method.Get); request.AddHeader("Accept", "application/json"); request.AddHeader("PRIVATE-TOKEN", _token); request.AddQueryParameter("per_page", "30"); var response = await client.ExecuteAsync(request, cancellationToken); if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) { return FormatResponseError("list_merge_request_notes", response, $"/projects/{projectId}/merge_requests/{mrIid}/notes"); } using var document = JsonDocument.Parse(response.Content); var root = document.RootElement; if (!root.TryGetProperty("notes", out var notesElement) || notesElement.GetArrayLength() == 0) { return $"Замечаний к MR #{mrIid} не найдено."; } var lines = new List(); foreach (var note in notesElement.EnumerateArray()) { var author = GetNestedString(note, "author", "name") ?? "-"; var createdAt = GetNestedString(note, "created_at") ?? "-"; var subject = GetNestedString(note, "subject") ?? "-"; var body = GetNestedString(note, "body") ?? "-"; lines.Add($"Author: {author}, Time: {createdAt}\n Subject: {subject}\n Body: {body}"); } return $"Замечания к MR #{mrIid} ({notesElement.GetArrayLength()} шт.):\n{string.Join('\n', lines)}"; } catch (Exception ex) { return FormatException("list_merge_request_notes", ex); } } /// /// Добавить замечание к MR /// /// ID проекта /// ID Merge Request /// Текст замечания /// Заголовок замечания (опционально) /// Token отмены [McpServerTool, Description("Добавить замечание к Merge Request")] public async Task CreateMergeRequestNote( [Description("ID проекта")] int projectId, [Description("ID Merge Request")] int mrIid, [Description("Текст замечания")] string body, [Description("Заголовок замечания")] string? subject = null, CancellationToken cancellationToken = default) { if (!TryCheckEnabled("CreateMergeRequestNote", out var enabledError)) return enabledError; if (projectId <= 0) { return "ID проекта GitLab некорректно задан."; } if (mrIid <= 0) { return "ID Merge Request GitLab некорректно задан."; } if (string.IsNullOrWhiteSpace(body)) { return "Текст замечания GitLab не может быть пустым."; } if (!TryGetClient(out var client, out var error)) return error; try { var request = new RestRequest($"/projects/{projectId}/merge_requests/{mrIid}/notes", RestSharp.Method.Post); request.AddHeader("Accept", "application/json"); request.AddHeader("PRIVATE-TOKEN", _token); request.AddHeader("Content-Type", "application/json"); var jsonBody = new { body = body, subject = subject ?? string.Empty }.ToJson(); request.AddJsonBody(jsonBody); var response = await client.ExecuteAsync(request, cancellationToken); if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) { return FormatResponseError("create_merge_request_note", response, $"/projects/{projectId}/merge_requests/{mrIid}/notes"); } using var document = JsonDocument.Parse(response.Content); var root = document.RootElement; var noteId = GetNestedString(root, "id") ?? "-"; var noteSubject = GetNestedString(root, "subject") ?? "-"; var noteBody = GetNestedString(root, "body") ?? "-"; return $"Замечание успешно добавлено к MR #{mrIid}:\nID: #{noteId}\n{noteSubject}\n{noteBody}"; } catch (Exception ex) { return FormatException("create_merge_request_note", ex); } } /// /// Удалить замечание из MR /// /// ID проекта /// ID Merge Request /// ID замечания /// Token отмены [McpServerTool, Description("Удалить замечание из Merge Request")] public async Task DeleteMergeRequestNote( [Description("ID проекта")] int projectId, [Description("ID Merge Request")] int mrIid, [Description("ID замечания")] int noteId, CancellationToken cancellationToken = default) { if (!TryCheckEnabled("DeleteMergeRequestNote", out var enabledError)) return enabledError; if (projectId <= 0) { return "ID проекта GitLab некорректно задан."; } if (mrIid <= 0) { return "ID Merge Request GitLab некорректно задан."; } if (noteId <= 0) { return "ID замечания GitLab некорректно задан."; } if (!TryGetClient(out var client, out var error)) return error; try { var request = new RestRequest($"/projects/{projectId}/merge_requests/{mrIid}/notes/{noteId}", RestSharp.Method.Delete); request.AddHeader("Accept", "application/json"); request.AddHeader("PRIVATE-TOKEN", _token); var response = await client.ExecuteAsync(request, cancellationToken); if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) { return FormatResponseError("delete_merge_request_note", response, $"/projects/{projectId}/merge_requests/{mrIid}/notes/{noteId}"); } using var document = JsonDocument.Parse(response.Content); var root = document.RootElement; var noteSubject = GetNestedString(root, "subject") ?? "-"; return $"Замечание #{noteId} ({noteSubject}) успешно удалено из MR #{mrIid}."; } catch (Exception ex) { return FormatException("delete_merge_request_note", ex, $"/projects/{projectId}/merge_requests/{mrIid}/notes/{noteId}"); } } }