using System.Collections.Generic; 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 GitLabIssueTools( 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? GetNestedString(JsonElement element, params string[] path) { if (path.Length == 0) return null; 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 int GetNestedInt(JsonElement element, params string[] path) { var raw = GetNestedString(element, path); return int.TryParse(raw, out var value) ? value : 0; } private static string GetIid(int iid) => iid > 0 ? $"#{iid}" : "-"; /// /// Получить список Issues /// /// ID проекта /// Состояние issue (опционально) /// Token отмены [McpServerTool, Description("Получить список Issues проекта")] public async Task ListIssues( [Description("ID проекта")] int projectId, [Description("Состояние issue")] string? issueState = null, CancellationToken cancellationToken = default) { if (!TryCheckEnabled("ListIssues", 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}/issues", RestSharp.Method.Get); request.AddHeader("Accept", "application/json"); request.AddHeader("PRIVATE-TOKEN", _token); request.AddQueryParameter("per_page", "30"); if (!string.IsNullOrWhiteSpace(issueState)) { request.AddQueryParameter("state", issueState); } var response = await client.ExecuteAsync(request, cancellationToken); if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) { return FormatResponseError("list_issues", response, $"/projects/{projectId}/issues"); } using var document = JsonDocument.Parse(response.Content); var root = document.RootElement; if (!root.TryGetProperty("issues", out var issuesElement) || issuesElement.GetArrayLength() == 0) { return $"Issues в проекте #{projectId} не найдены."; } var lines = new List(); foreach (var issue in issuesElement.EnumerateArray()) { var iid = GetIid(GetNestedInt(issue, "iid")); var title = GetNestedString(issue, "title") ?? "-"; var state = GetNestedString(issue, "state") ?? "-"; var created_at = GetNestedString(issue, "created_at") ?? "-"; var author = GetNestedString(issue, "author", "name") ?? "-"; var labels = GetNestedString(issue, "labels") ?? "-"; var labelsList = GetNestedString(issue, "labels") is string labelsStr && !string.IsNullOrWhiteSpace(labelsStr) ? labelsStr.Split(',').Select(l => $"[{l.Trim()}]").ToArray() ?? new[] { labelsStr } : Array.Empty(); lines.Add($"{iid} - {title} [{state}]\n author: {author}\n labels: {string.Join(", ", labelsList)}"); lines.Add($" created_at: {created_at}"); } return $"Issues проекта #{projectId} ({issuesElement.GetArrayLength()} шт.):\n{string.Join(Environment.NewLine, lines)}"; } catch (Exception ex) { return FormatException("list_issues", ex); } } /// /// Получить список Issues без фильтрации по state /// /// ID проекта /// Token отмены [McpServerTool, Description("Получить список Issues проекта (без фильтра state)")] public async Task ListIssuesSimple( [Description("ID проекта")] int projectId, CancellationToken cancellationToken = default) { if (!TryCheckEnabled("ListIssuesSimple", 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}/issues", 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_issues_simple", response, $"/projects/{projectId}/issues"); } using var document = JsonDocument.Parse(response.Content); var root = document.RootElement; if (!root.TryGetProperty("issues", out var issuesElement) || issuesElement.GetArrayLength() == 0) { return $"Issues в проекте #{projectId} не найдены."; } var lines = new List(); foreach (var issue in issuesElement.EnumerateArray()) { var iid = GetIid(GetNestedInt(issue, "iid")); var title = GetNestedString(issue, "title") ?? "-"; var state = GetNestedString(issue, "state") ?? "-"; var author = GetNestedString(issue, "author", "name") ?? "-"; var description = GetNestedString(issue, "description") ?? "-"; lines.Add($"{iid} - {title} [{state}]\n author: {author}\n description: {description}"); } return $"Issues проекта #{projectId} ({issuesElement.GetArrayLength()} шт.):\n{string.Join(Environment.NewLine, lines)}"; } catch (Exception ex) { return FormatException("list_issues_simple", ex); } } /// /// Получить конкретный Issue /// /// ID проекта /// ID Issue /// Token отмены [McpServerTool, Description("Получить конкретный Issue")] public async Task GetIssue( [Description("ID проекта")] int projectId, [Description("ID Issue")] int issueIid, CancellationToken cancellationToken = default) { if (!TryCheckEnabled("GetIssue", out var enabledError)) return enabledError; if (projectId <= 0) { return "ID проекта GitLab некорректно задан."; } if (issueIid <= 0) { return "ID Issue GitLab некорректно задан."; } if (!TryGetClient(out var client, out var error)) return error; try { var request = new RestRequest($"/projects/{projectId}/issues/{issueIid}", 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_issue", response, $"/projects/{projectId}/issues/{issueIid}"); } using var document = JsonDocument.Parse(response.Content); var root = document.RootElement; var iid = GetIid(GetNestedInt(root, "iid")); var title = GetNestedString(root, "title") ?? "-"; var state = GetNestedString(root, "state") ?? "-"; var author = GetNestedString(root, "author", "name") ?? "-"; var created_at = GetNestedString(root, "created_at") ?? "-"; var labels = GetNestedString(root, "labels") ?? "-"; var description = GetNestedString(root, "description") ?? "-"; var labelsList = GetNestedString(root, "labels") is string labelsStr && !string.IsNullOrWhiteSpace(labelsStr) ? labelsStr.Split(',').Select(l => $"[{l.Trim()}]").ToArray() ?? new[] { labelsStr } : Array.Empty(); return $"Issue #{iid} в проекте #{projectId}:\n{title} [{state}]\n author: {author}\n labels: {string.Join(", ", labelsList)}\n created_at: {created_at}\n description: {description}"; } catch (Exception ex) { return FormatException("get_issue", ex, $"/projects/{projectId}/issues/{issueIid}"); } } /// /// Создать Issue /// /// ID проекта /// Заголовок Issue /// Описание (опционально) /// Метки (опционально) /// ID назначаемого пользователя (опционально) /// Token отмены [McpServerTool, Description("Создать Issue")] public async Task CreateIssue( [Description("ID проекта")] int projectId, [Description("Заголовок Issue")] string title, [Description("Описание Issue")] string? description = null, [Description("Метки Issue")] string? labels = null, [Description("ID назначаемого пользователя")] int? assigneeId = null, CancellationToken cancellationToken = default) { if (!TryCheckEnabled("CreateIssue", out var enabledError)) return enabledError; if (projectId <= 0) { return "ID проекта GitLab некорректно задан."; } if (string.IsNullOrWhiteSpace(title)) { return "Заголовок Issue GitLab не может быть пустым."; } if (!TryGetClient(out var client, out var error)) return error; try { var request = new RestRequest($"/projects/{projectId}/issues", RestSharp.Method.Post); request.AddHeader("Accept", "application/json"); request.AddHeader("PRIVATE-TOKEN", _token); request.AddHeader("Content-Type", "application/json"); var payload = new Dictionary { ["title"] = title, ["description"] = description ?? string.Empty }; if (!string.IsNullOrWhiteSpace(labels)) { payload["labels"] = labels; } if (assigneeId.HasValue) { payload["assignee_id"] = assigneeId.Value; } request.AddJsonBody(payload); var response = await client.ExecuteAsync(request, cancellationToken); if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) { return FormatResponseError("create_issue", response, $"/projects/{projectId}/issues"); } using var document = JsonDocument.Parse(response.Content); var root = document.RootElement; var iid = GetIid(GetNestedInt(root, "iid")); var issueTitle = GetNestedString(root, "title") ?? "-"; var issueState = GetNestedString(root, "state") ?? "-"; return $"Issue успешно создан в проекте #{projectId}:\nID: {iid}\n{issueTitle} [{issueState}]"; } catch (Exception ex) { return FormatException("create_issue", ex); } } /// /// Обновить Issue /// /// ID проекта /// ID Issue /// Новый заголовок (опционально) /// Новое описание (опционально) /// Новые метки (опционально) /// Новое состояние (опционально) /// Token отмены [McpServerTool, Description("Обновить Issue")] public async Task UpdateIssue( [Description("ID проекта")] int projectId, [Description("ID Issue")] int issueIid, [Description("Новый заголовок")] string? subject = null, [Description("Новое описание")] string? description = null, [Description("Новые метки")] string? labels = null, [Description("Новое состояние")] string? state = null, CancellationToken cancellationToken = default) { if (!TryCheckEnabled("UpdateIssue", out var enabledError)) return enabledError; if (projectId <= 0) { return "ID проекта GitLab некорректно задан."; } if (issueIid <= 0) { return "ID Issue GitLab некорректно задан."; } if (!TryGetClient(out var client, out var error)) return error; try { var request = new RestRequest($"/projects/{projectId}/issues/{issueIid}", RestSharp.Method.Put); request.AddHeader("Accept", "application/json"); request.AddHeader("PRIVATE-TOKEN", _token); request.AddHeader("Content-Type", "application/json"); var payload = new Dictionary(); if (!string.IsNullOrWhiteSpace(subject)) { payload["title"] = subject; } if (!string.IsNullOrWhiteSpace(description)) { payload["description"] = description; } if (!string.IsNullOrWhiteSpace(labels)) { payload["labels"] = labels; } if (!string.IsNullOrWhiteSpace(state)) { payload["state_event"] = state; } request.AddJsonBody(payload); var response = await client.ExecuteAsync(request, cancellationToken); if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) { return FormatResponseError("update_issue", response, $"/projects/{projectId}/issues/{issueIid}"); } using var document = JsonDocument.Parse(response.Content); var root = document.RootElement; var iid = GetIid(GetNestedInt(root, "iid")); var issueTitle = GetNestedString(root, "title") ?? "-"; var issueState = GetNestedString(root, "state") ?? "-"; return $"Issue #{iid} ({issueTitle}) успешно обновлён в проекте #{projectId}."; } catch (Exception ex) { return FormatException("update_issue", ex, $"/projects/{projectId}/issues/{issueIid}"); } } /// /// Закрыть Issue /// /// ID проекта /// ID Issue /// Token отмены [McpServerTool, Description("Закрыть Issue")] public async Task CloseIssue( [Description("ID проекта")] int projectId, [Description("ID Issue")] int issueIid, CancellationToken cancellationToken = default) { if (!TryCheckEnabled("CloseIssue", out var enabledError)) return enabledError; if (projectId <= 0) { return "ID проекта GitLab некорректно задан."; } if (issueIid <= 0) { return "ID Issue GitLab некорректно задан."; } if (!TryGetClient(out var client, out var error)) return error; try { var request = new RestRequest($"/projects/{projectId}/issues/{issueIid}", RestSharp.Method.Put); request.AddHeader("Accept", "application/json"); request.AddHeader("PRIVATE-TOKEN", _token); request.AddHeader("Content-Type", "application/json"); request.AddJsonBody(new { state_event = "close" }); var response = await client.ExecuteAsync(request, cancellationToken); if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) { return FormatResponseError("close_issue", response, $"/projects/{projectId}/issues/{issueIid}"); } using var document = JsonDocument.Parse(response.Content); var root = document.RootElement; var issueTitle = GetNestedString(root, "title") ?? "-"; return $"Issue #{issueIid} ({issueTitle}) успешно закрыт в проекте #{projectId}."; } catch (Exception ex) { return FormatException("close_issue", ex, $"/projects/{projectId}/issues/{issueIid}"); } } /// /// Открыть Issue /// /// ID проекта /// ID Issue /// Token отмены [McpServerTool, Description("Открыть Issue")] public async Task OpenIssue( [Description("ID проекта")] int projectId, [Description("ID Issue")] int issueIid, CancellationToken cancellationToken = default) { if (!TryCheckEnabled("OpenIssue", out var enabledError)) return enabledError; if (projectId <= 0) { return "ID проекта GitLab некорректно задан."; } if (issueIid <= 0) { return "ID Issue GitLab некорректно задан."; } if (!TryGetClient(out var client, out var error)) return error; try { var request = new RestRequest($"/projects/{projectId}/issues/{issueIid}", RestSharp.Method.Put); request.AddHeader("Accept", "application/json"); request.AddHeader("PRIVATE-TOKEN", _token); request.AddHeader("Content-Type", "application/json"); request.AddJsonBody(new { state_event = "reopen" }); var response = await client.ExecuteAsync(request, cancellationToken); if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) { return FormatResponseError("open_issue", response, $"/projects/{projectId}/issues/{issueIid}"); } using var document = JsonDocument.Parse(response.Content); var root = document.RootElement; var issueTitle = GetNestedString(root, "title") ?? "-"; return $"Issue #{issueIid} ({issueTitle}) успешно открыт в проекте #{projectId}."; } catch (Exception ex) { return FormatException("open_issue", ex, $"/projects/{projectId}/issues/{issueIid}"); } } /// /// Получить замечания к Issue /// /// ID проекта /// ID Issue /// Token отмены [McpServerTool, Description("Получить замечания к Issue")] public async Task ListIssueNotes( [Description("ID проекта")] int projectId, [Description("ID Issue")] int issueIid, CancellationToken cancellationToken = default) { if (!TryCheckEnabled("ListIssueNotes", out var enabledError)) return enabledError; if (projectId <= 0) { return "ID проекта GitLab некорректно задан."; } if (issueIid <= 0) { return "ID Issue GitLab некорректно задан."; } if (!TryGetClient(out var client, out var error)) return error; try { var request = new RestRequest($"/projects/{projectId}/issues/{issueIid}/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_issue_notes", response, $"/projects/{projectId}/issues/{issueIid}/notes"); } using var document = JsonDocument.Parse(response.Content); var root = document.RootElement; if (!root.TryGetProperty("notes", out var notesElement) || notesElement.GetArrayLength() == 0) { return $"Замечаний к Issue #{issueIid} не найдено."; } 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 $"Замечания к Issue #{issueIid} ({notesElement.GetArrayLength()} шт.):\n{string.Join(Environment.NewLine, lines)}"; } catch (Exception ex) { return FormatException("list_issue_notes", ex); } } /// /// Добавить замечание к Issue /// /// ID проекта /// ID Issue /// Текст замечания /// Заголовок замечания (опционально) /// Token отмены [McpServerTool, Description("Добавить замечание к Issue")] public async Task CreateIssueNote( [Description("ID проекта")] int projectId, [Description("ID Issue")] int issueIid, [Description("Текст замечания")] string body, [Description("Заголовок замечания")] string? subject = null, CancellationToken cancellationToken = default) { if (!TryCheckEnabled("CreateIssueNote", out var enabledError)) return enabledError; if (projectId <= 0) { return "ID проекта GitLab некорректно задан."; } if (issueIid <= 0) { return "ID Issue GitLab некорректно задан."; } if (string.IsNullOrWhiteSpace(body)) { return "Текст замечания GitLab не может быть пустым."; } if (!TryGetClient(out var client, out var error)) return error; try { var request = new RestRequest($"/projects/{projectId}/issues/{issueIid}/notes", RestSharp.Method.Post); request.AddHeader("Accept", "application/json"); request.AddHeader("PRIVATE-TOKEN", _token); request.AddHeader("Content-Type", "application/json"); request.AddJsonBody(new { body, subject = subject ?? string.Empty }); var response = await client.ExecuteAsync(request, cancellationToken); if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) { return FormatResponseError("create_issue_note", response, $"/projects/{projectId}/issues/{issueIid}/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 $"Замечание успешно добавлено к Issue #{issueIid}:\nID: #{noteId}\n{noteSubject}\n{noteBody}"; } catch (Exception ex) { return FormatException("create_issue_note", ex); } } /// /// Удалить замечание из Issue /// /// ID проекта /// ID Issue /// ID замечания /// Token отмены [McpServerTool, Description("Удалить замечание из Issue")] public async Task DeleteIssueNote( [Description("ID проекта")] int projectId, [Description("ID Issue")] int issueIid, [Description("ID замечания")] int noteId, CancellationToken cancellationToken = default) { if (!TryCheckEnabled("DeleteIssueNote", out var enabledError)) return enabledError; if (projectId <= 0) { return "ID проекта GitLab некорректно задан."; } if (issueIid <= 0) { return "ID Issue GitLab некорректно задан."; } if (noteId <= 0) { return "ID замечания GitLab некорректно задан."; } if (!TryGetClient(out var client, out var error)) return error; try { var request = new RestRequest($"/projects/{projectId}/issues/{issueIid}/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_issue_note", response, $"/projects/{projectId}/issues/{issueIid}/notes/{noteId}"); } using var document = JsonDocument.Parse(response.Content); var root = document.RootElement; var noteSubject = GetNestedString(root, "subject") ?? "-"; return $"Замечание #{noteId} ({noteSubject}) успешно удалено из Issue #{issueIid}."; } catch (Exception ex) { return FormatException("delete_issue_note", ex, $"/projects/{projectId}/issues/{issueIid}/notes/{noteId}"); } } }