diff --git a/.clinerules b/.clinerules new file mode 100644 index 0000000..12b8bd8 --- /dev/null +++ b/.clinerules @@ -0,0 +1,67 @@ +# Cline's Memory Bank + +I am Cline, an expert software engineer with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional. + +## Memory Bank Structure + +The Memory Bank consists of core files and optional context files, all in Markdown format. Files build upon each other in a clear hierarchy: + +### Core Files (Required) +1. `projectbrief.md` + - Foundation document that shapes all other files + - Created at project start if it doesn't exist + - Defines core requirements and goals + - Source of truth for project scope + +2. `productContext.md` + - Why this project exists + - Problems it solves + - How it should work + - User experience goals + +3. `activeContext.md` + - Current work focus + - Recent changes + - Next steps + - Active decisions and considerations + - Important patterns and preferences + - Learnings and project insights + +4. `systemPatterns.md` + - System architecture + - Key technical decisions + - Design patterns in use + - Component relationships + - Critical implementation paths + +5. `techContext.md` + - Technologies used + - Development setup + - Technical constraints + - Dependencies + - Tool usage patterns + +6. `progress.md` + - What works + - What's left to build + - Current status + - Known issues + - Evolution of project decisions + +### Additional Context +Create additional files/folders within memory-bank/ when they help organize: +- Complex feature documentation +- Integration specifications +- API documentation +- Testing strategies +- Deployment procedures + +## Documentation Updates + +Memory Bank updates occur when: +1. Discovering new project patterns +2. After implementing significant changes +3. When user requests with **update memory bank** (MUST review ALL files) +4. When context needs clarification + +REMEMBER: After every memory reset, I begin completely fresh. The Memory Bank is my only link to previous work. It must be maintained with precision and clarity, as my effectiveness depends entirely on its accuracy. \ No newline at end of file diff --git a/LazyBear.MCP/Program.cs b/LazyBear.MCP/Program.cs index bd41227..acc8d01 100644 --- a/LazyBear.MCP/Program.cs +++ b/LazyBear.MCP/Program.cs @@ -1,4 +1,5 @@ using LazyBear.MCP.Services.Confluence; +using LazyBear.MCP.Services.GitLab; using LazyBear.MCP.Services.Jira; using LazyBear.MCP.Services.Kubernetes; using LazyBear.MCP.Services.Logging; @@ -25,11 +26,13 @@ var host = Host.CreateDefaultBuilder(args) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Модули инструментов (добавь новый IToolModule — он появится в TUI) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // HTTP MCP endpoint запускаем в фоне, чтобы TUI оставался владельцем консоли services.AddHostedService(); diff --git a/LazyBear.MCP/README.md b/LazyBear.MCP/README.md new file mode 100644 index 0000000..74aa4d0 --- /dev/null +++ b/LazyBear.MCP/README.md @@ -0,0 +1,469 @@ +# LazyBear MCP Server + +![LazyBear Logo](logo.png) + +**.NET 10 сервер Model Context Protocol (MCP) для интеграции с Jira, Confluence, Kubernetes и GitLab.** + +--- + +## ✨ Возможности + +| Модуль | Описание | Статус | +|--------|----------|--------| +| 📋 **Jira** | Работа с задачами, JQL, комментариями | ✅ Доступно | +| 📄 **Confluence** | Работа со страницами и пространствами | ✅ Доступно | +| ☸️ **Kubernetes** | Управление деплоями, подами, сетями | ✅ Доступно | +| 🌳 **GitLab** | Работа с репозиториями, MR, Issue, ветками, тегами | ✅ Доступно | +| 🖥️ **TUI** | Интерактивная консольная панель | ✅ Доступно | +| 🌐 **Localization** | Многоязычный интерфейс (RU/EN) | ✅ Доступно | + +--- + +## 🏗️ Архитектура + +``` +┌───────────────────────────────────────────────────────────┐ +│ HTTP Transport Layer (ASP.NET Core) │ +│ └── ModelContextProtocol 1.2.0 HTTP транспорт │ +└───────────────────────────────────────────────────────────┘ + ↓ +┌───────────────────────────────────────────────────────────┐ +│ Application Layer (Razor Pages UI) │ +│ └── Web-страницы для мониторинга K8s │ +└───────────────────────────────────────────────────────────┘ + ↓ +┌───────────────────────────────────────────────────────────┐ +│ Integration Layers │ +│ ├── Jira API (REST) │ +│ ├── Confluence API (REST) │ +│ ├── Kubernetes API (REST) │ +│ └── GitLab API (REST) │ +└───────────────────────────────────────────────────────────┘ +``` + +**Потоки данных:** + +1. **Initialize Flow**: Клиент → HTTP → Application → Integration Client → API +2. **Tools Flow**: Клиент → RPC → Tools → [Jira/Confluence/K8s/GitLab] → Возврат результата +3. **Health Check Flow**: `/health` → HTTP → Liveness probe → Status + +--- + +## 🚀 Быстрый старт + +### Требования + +- .NET 10 SDK +- Kubectl и kubeconfig +- GitLab Personal Access Token (опционально) +- Docker Desktop (опционально) + +### Запуск + +```bash +cd LazyBear.MCP +dotnet run +``` + +Сервер запустится на `http://localhost:5000` + +### Docker + +```bash +docker build -t lazybear-mcp . +docker run -p 5000:5000 -v $HOME/.kube:/root/.kube:ro lazybear-mcp +``` + +--- + +## 📦 Основные модули MCP + +### 📋 Jira + +Работа с Jira Issues и JQL запросами. + +**Методы:** +- `createIssue` – Создать новый тикет +- `updateIssue` – Обновить существующий тикет +- `getIssueDetails` – Получить детали тикета +- `searchIssues` – Поиск тикетов по JQL +- `addComment` – Добавить комментарий +- `getIssueStatuses` – Получение доступных переходов статуса +- `listIssueComments` – Список комментариев задачи + +**Пример вызова:** + +```json +{ + "method": "jiraTools/createIssue", + "params": { + "projectKey": "LAZYBEAR", + "summary": "Fix memory leak in K8s deployment", + "description": "Memory leak detected in pod nginx-pod-abc123", + "type": "BUG", + "priority": "High", + "assignee": "dev@example.com" + } +} +``` + +--- + +### 📄 Confluence + +Работа с Confluence страницами и пространствами. + +**Методы:** +- `createPage` – Создать новую страницу +- `updatePage` – Обновить существующую страницу +- `deletePage` – Удалить страницу +- `getPageContent` – Получить содержимое страницы +- `searchPages` – Поиск страниц по ключевым словам +- `getSpace` – Получить информацию о пространстве +- `movePage` – Переместить страницу + +**Пример вызова:** + +```json +{ + "method": "confluenceTools/createPage", + "params": { + "spaceKey": "LAZYBEAR", + "title": "Инструкция по развёртыванию", + "body": "# Инструкция по развёртыванию\n\nШаг 1: Клонируйте репозиторий. + +--- + +### ☸️ Kubernetes + +Управление K8s кластером. + +**Методы:** + +**Конфигурация:** +- `readConfig` – Чтение конфигурации кластера +- `writeConfig` – Обновление конфигурации +- `deleteConfig` – Удаление конфигурации + +**Деплои:** +- `createDeployment` – Создать деплой +- `updateDeployment` – Обновить деплой +- `deleteDeployment` – Удалить деплой +- `scaleDeployment` – Масштабировать деплой + +**Сети:** +- `createService` – Создать сервис +- `updateService` – Обновить сервис +- `deleteService` – Удалить сервис +- `createIngress` – Создать ingress +- `deleteIngress` – Удалить ingress + +**Поды:** +- `getPodStatus` – Получить статус пода +- `restartPod` – Перезапустить под +- `execIntoPod` – Выполнить команду в поде +- `deletePod` – Удалить под + +**Примеры вызова:** + +```json +{ + "method": "k8sDeploymentTools/createDeployment", + "params": { + "name": "nginx", + "replicas": 3, + "image": "nginx:latest" + } +} +``` + +--- + +### 🌳 GitLab + +Работа с GitLab API для управления репозиториями, MR, Issue, ветками и тегами. + +**Методы:** + +**Репозитории:** +- `list_projects` – Список всех репозиториев +- `get_project` – Информация о репозитории по ID/path + +**Теги (Версии):** +- `list_versions` – Список тегов репозитория +- `create_version` – Создание нового тега +- `delete_version` – Удаление тега + +**Merge Requests:** +- `list_merge_requests` – Список всех MR +- `get_merge_request` – Информация о конкретном MR +- `create_merge_request` – Создание MR +- `close_merge_request` – Закрытие MR +- `open_merge_request` – Открытие MR +- `list_merge_request_notes` – Замечания к MR +- `create_merge_request_note` – Добавление замечания +- `delete_merge_request_note` – Удаление замечания + +**Issues:** +- `list_issues` – Список Issues +- `list_issues_simple` – Быстрый список Issues +- `get_issue` – Информация об Issue +- `create_issue` – Создание Issue +- `update_issue` – Обновление Issue +- `close_issue` – Закрытие Issue +- `open_issue` – Открытие Issue +- `list_issue_notes` – Замечания к Issue +- `create_issue_note` – Добавление замечания +- `delete_issue_note` – Удаление замечания + +**Ветки:** +- `list_branches` – Список веток +- `get_branch` – Информация о ветке +- `create_branch` – Создание ветки +- `delete_branch` – Удаление ветки +- `protect_branch` – Защита ветки +- `unprotect_branch` – Удаление защиты + +**Примеры вызова:** + +```json +{ + "method": "gitlabTools/list_projects", + "params": {} +} +``` + +```json +{ + "method": "gitlabTools/create_merge_request", + "params": { + "sourceBranch": "feature-xyz", + "targetBranch": "main", + "title": "Add new feature", + "description": "Implements new feature xyz" + } +} +``` + +```json +{ + "method": "gitlabTools/create_issue", + "params": { + "title": "Fix production bug", + "description": "Critical bug in production environment", + "assigneeId": 123, + "labels": ["bug", "critical"] + } +} +``` + +--- + +## 📁 Структура проекта + +``` +LazyBear.MCP/ +├── Program.cs # HTTP transport MCP сервер +├── Pages/ # Razor Pages UI +│ ├── Index.cshtml # Главная страница +│ └── Shared/ # Общие компоненты +├── Services/ +│ ├── Jira/ +│ │ └── JiraIssueTools.cs # Инструменты для Jira +│ ├── Confluence/ +│ │ └── ConfluencePagesTools.cs # Инструменты для Confluence +│ ├── Kubernetes/ +│ │ ├── K8sConfigTools.cs # Инструменты конфигурации +│ │ ├── K8sDeploymentTools.cs # Инструменты деплоя +│ │ ├── K8sNetworkTools.cs # Инструменты сети +│ │ ├── K8sPodsTools.cs # Инструменты подов +│ │ ├── K8sClientFactory.cs # Factory для клиентов +│ │ └── K8sClientProvider.cs # Provider для клиентов +│ └── GitLab/ +│ ├── GitLabToolModule.cs # Регистрация инструментов +│ ├── GitLabToolsBase.cs # Базовый класс с common-методами +│ ├── GitLabApiClient.cs # REST клиент (RestSharp) +│ ├── GitLabClientProvider.cs # Provider +│ ├── GitLabClientFactory.cs # Factory +│ ├── GitLabRepositoryTools.cs # Репозитории +│ ├── GitLabVersionTools.cs # Теги +│ ├── GitLabMergeRequestTools.cs # MR +│ ├── GitLabIssueTools.cs # Issues +│ └── GitLabBranchTools.cs # Ветки +├── appsettings.json # Конфиг +└── global.json # Пин SDK +``` + +--- + +## 🖥️ Интерактивная панель + +``` +┌───────────────────────────────────────────┐ +│ Dashboard: Обзор состояния кластера │ +├───────────────────────────────────────────┤ +│ Logs & Events: Журналы событий │ +│ Containers & Images: Контейнеры │ +│ Workloads & Nodes: Распределение │ +└───────────────────────────────────────────┘ +``` + +**Настройка в appsettings.json:** + +```json +{ + "Kubernetes": { + "KubeconfigPath": "~/.kube/config", + "DefaultNamespace": "default" + }, + "Jira": { + "Url": "https://jira.example.com", + "Token": "your_jira_token", + "Project": "" + }, + "Confluence": { + "Url": "https://confluence.example.com", + "Token": "your_confluence_token" + }, + "GitLab": { + "Url": "https://gitlab.com", + "Token": "your_gitlab_pat", + "Project": "" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "ModelContextProtocol": "Debug" + } + } +} +``` + +--- + +## 🔌 Интеграция + +### Codex (Windows) + +Файл: `.mcp.json` + +```json +{ + "mcpServers": { + "lazybear": { + "command": "dotnet", + "args": ["run", "--project", "E:\\Codex\\LazyBearWorks\\LazyBear.MCP"] + } + } +} +``` + +### Continue (VS Code) + +Файл: `.vscode/continue/config.json` + +```json +{ + "mcpServers": { + "lazybear": { + "command": "dotnet", + "args": ["run", "--project", "${workspaceFolder}/LazyBear.MCP"], + "type": "stdio" + } + } +} +``` + +### OpenCode (Linux/Mac) + +Файл: `~/.opencode/.mcp.json` + +```json +{ + "mcpServers": { + "lazybear": { + "command": "dotnet", + "args": ["run", "--project", "~/LazyBearWorks/LazyBear.MCP"] + } + } +} +``` + +### MCP Inspector + +```bash +npm install -g @modelcontextprotocol/inspector +npx @modelcontextprotocol/inspector dotnet run --project LazyBear.MCP +``` + +--- + +## 🔧 CLI тестирование + +```bash +# Прямое тестирование через stdin +echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0"}}}' | dotnet run --project LazyBear.MCP +``` + +--- + +## 🛠️ Разработка + +### Сборка + +```bash +dotnet build +``` + +### Запуск + +```bash +dotnet run +``` + +### Тестирование + +```bash +npx @modelcontextprotocol/inspector dotnet run --project LazyBear.MCP +``` + +--- + +## 📦 Stack + +- **Язык:** C# +- **Framework:** .NET 10 +- **Framework Web:** ASP.NET Core 9 +- **UI:** Razor Pages +- **Protocol:** Model Context Protocol (MCP) +- **API Clients:** RestSharp (для Jira, Confluence, GitLab), Kubernetes Client (для K8s) + +**Документация:** +- **Сгенерированный API**: `/swagger` — Swagger UI +- **Метаданные методов**: MCP Tools — авт. описание от `Summary/Description` + +### OpenAPI/Swagger + +**Включите для просмотра API:** + +```xml + +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc; +using Microsoft.OpenApi.Models; + +var config = new OpenApiInfo { Title = "LazyBear MCP Server", Version = "1.0.0" }; +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1", config)); +``` + +--- + +## 📚 Ссылки + +- [GitLab API Documentation](https://docs.gitlab.com/ee/api/) +- [MCP Specification](https://modelcontextprotocol.io) + +--- + +*Встроенная документация по MCP* \ No newline at end of file diff --git a/LazyBear.MCP/Services/GitLab/GitLabApiClient.cs b/LazyBear.MCP/Services/GitLab/GitLabApiClient.cs new file mode 100644 index 0000000..0f8b033 --- /dev/null +++ b/LazyBear.MCP/Services/GitLab/GitLabApiClient.cs @@ -0,0 +1,75 @@ +using RestSharp; + +namespace LazyBear.MCP.Services.GitLab; + +/// +/// Обертка над RestSharp RestClient для GitLab API +/// +public sealed class GitLabApiClient : IDisposable +{ + public RestClient RestClient { get; } + + /// + /// Конструктор + /// + /// URL GitLab + public GitLabApiClient(string url) + { + _restClient = new RestClient(url); + } + + private readonly RestClient _restClient; + + /// + /// Создание запроса GET + /// + public RestRequest GetRequest(string resource) + { + var request = new RestRequest(resource, Method.Get); + return request; + } + + /// + /// Создание запроса POST + /// + public RestRequest PostRequest(string resource) + { + var request = new RestRequest(resource, Method.Post); + return request; + } + + /// + /// Создание запроса PUT + /// + public RestRequest PutRequest(string resource) + { + var request = new RestRequest(resource, Method.Put); + return request; + } + + /// + /// Создание запроса DELETE + /// + public RestRequest DeleteRequest(string resource) + { + var request = new RestRequest(resource, Method.Delete); + return request; + } + + /// + /// Выполнение запроса + /// + public async System.Threading.Tasks.Task ExecuteAsync(RestRequest request, System.Threading.CancellationToken? cancellationToken = null) + { + if (cancellationToken.HasValue) + { + return await _restClient.ExecuteAsync(request, cancellationToken.Value); + } + return await _restClient.ExecuteAsync(request); + } + + public void Dispose() + { + _restClient.Dispose(); + } +} diff --git a/LazyBear.MCP/Services/GitLab/GitLabBranchTools.cs b/LazyBear.MCP/Services/GitLab/GitLabBranchTools.cs new file mode 100644 index 0000000..9dd5a9c --- /dev/null +++ b/LazyBear.MCP/Services/GitLab/GitLabBranchTools.cs @@ -0,0 +1,358 @@ +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 GitLabBranchTools( + GitLabClientProvider provider, + IConfiguration configuration, + ToolRegistryService registry) +{ + private readonly string _token = configuration["GitLab:Token"] ?? 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) + { + 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(); + } + + [McpServerTool, Description("Получить список веток GitLab проекта")] + public async Task ListBranches( + [Description("ID проекта")] int projectId, + CancellationToken cancellationToken = default) + { + if (!TryCheckEnabled("ListBranches", 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}/repository/branches", Method.Get); + request.AddHeader("Accept", "application/json"); + request.AddHeader("PRIVATE-TOKEN", _token); + request.AddQueryParameter("per_page", "100"); + + var response = await client.ExecuteAsync(request, cancellationToken); + if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) + { + return FormatResponseError("list_branches", response, $"/projects/{projectId}/repository/branches"); + } + + using var document = JsonDocument.Parse(response.Content); + var root = document.RootElement; + + if (root.ValueKind != JsonValueKind.Array || root.GetArrayLength() == 0) + { + return $"Ветки в проекте #{projectId} не найдены."; + } + + var lines = new List(); + foreach (var branch in root.EnumerateArray()) + { + var name = GetNestedString(branch, "name") ?? "-"; + var isDefault = GetNestedString(branch, "default") ?? "false"; + var isProtected = GetNestedString(branch, "protected") ?? "false"; + var commit = GetNestedString(branch, "commit", "short_id") ?? GetNestedString(branch, "commit", "id") ?? "-"; + + lines.Add($"{name} (default={isDefault}, protected={isProtected}, commit={commit})"); + } + + return $"Ветки проекта #{projectId} ({root.GetArrayLength()} шт.):{Environment.NewLine}{string.Join(Environment.NewLine, lines)}"; + } + catch (Exception ex) + { + return FormatException("list_branches", ex); + } + } + + [McpServerTool, Description("Получить ветку GitLab проекта")] + public async Task GetBranch( + [Description("ID проекта")] int projectId, + [Description("Имя ветки")] string branchName, + CancellationToken cancellationToken = default) + { + if (!TryCheckEnabled("GetBranch", out var enabledError)) return enabledError; + + if (projectId <= 0) + { + return "ID проекта GitLab некорректно задан."; + } + + if (string.IsNullOrWhiteSpace(branchName)) + { + return "Имя ветки GitLab не может быть пустым."; + } + + if (!TryGetClient(out var client, out var error)) return error; + + try + { + var encoded = Uri.EscapeDataString(branchName); + var request = new RestRequest($"/projects/{projectId}/repository/branches/{encoded}", 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_branch", response, $"/projects/{projectId}/repository/branches/{encoded}"); + } + + using var document = JsonDocument.Parse(response.Content); + var root = document.RootElement; + + var name = GetNestedString(root, "name") ?? branchName; + var isDefault = GetNestedString(root, "default") ?? "false"; + var isProtected = GetNestedString(root, "protected") ?? "false"; + var canPush = GetNestedString(root, "can_push") ?? "false"; + var commitId = GetNestedString(root, "commit", "id") ?? "-"; + + return $"Ветка '{name}' проекта #{projectId}:{Environment.NewLine}default={isDefault}, protected={isProtected}, can_push={canPush}{Environment.NewLine}commit={commitId}"; + } + catch (Exception ex) + { + return FormatException("get_branch", ex); + } + } + + [McpServerTool, Description("Создать ветку GitLab проекта")] + public async Task CreateBranch( + [Description("ID проекта")] int projectId, + [Description("Имя новой ветки")] string branchName, + [Description("Ветка или SHA-реф источника")] string @ref, + CancellationToken cancellationToken = default) + { + if (!TryCheckEnabled("CreateBranch", out var enabledError)) return enabledError; + + if (projectId <= 0) + { + return "ID проекта GitLab некорректно задан."; + } + + if (string.IsNullOrWhiteSpace(branchName)) + { + return "Имя новой ветки GitLab не может быть пустым."; + } + + if (string.IsNullOrWhiteSpace(@ref)) + { + return "Источник ветки (ref) GitLab не может быть пустым."; + } + + if (!TryGetClient(out var client, out var error)) return error; + + try + { + var request = new RestRequest($"/projects/{projectId}/repository/branches", Method.Post); + request.AddHeader("Accept", "application/json"); + request.AddHeader("PRIVATE-TOKEN", _token); + request.AddHeader("Content-Type", "application/json"); + request.AddJsonBody(new { branch = branchName, @ref }); + + var response = await client.ExecuteAsync(request, cancellationToken); + if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) + { + return FormatResponseError("create_branch", response, $"/projects/{projectId}/repository/branches"); + } + + using var document = JsonDocument.Parse(response.Content); + var root = document.RootElement; + var name = GetNestedString(root, "name") ?? branchName; + var commit = GetNestedString(root, "commit", "short_id") ?? GetNestedString(root, "commit", "id") ?? "-"; + + return $"Ветка '{name}' успешно создана в проекте #{projectId}. commit={commit}"; + } + catch (Exception ex) + { + return FormatException("create_branch", ex); + } + } + + [McpServerTool, Description("Удалить ветку GitLab проекта")] + public async Task DeleteBranch( + [Description("ID проекта")] int projectId, + [Description("Имя ветки")] string branchName, + CancellationToken cancellationToken = default) + { + if (!TryCheckEnabled("DeleteBranch", out var enabledError)) return enabledError; + + if (projectId <= 0) + { + return "ID проекта GitLab некорректно задан."; + } + + if (string.IsNullOrWhiteSpace(branchName)) + { + return "Имя ветки GitLab не может быть пустым."; + } + + if (!TryGetClient(out var client, out var error)) return error; + + try + { + var encoded = Uri.EscapeDataString(branchName); + var request = new RestRequest($"/projects/{projectId}/repository/branches/{encoded}", Method.Delete); + request.AddHeader("Accept", "application/json"); + request.AddHeader("PRIVATE-TOKEN", _token); + + var response = await client.ExecuteAsync(request, cancellationToken); + if (!response.IsSuccessful) + { + return FormatResponseError("delete_branch", response, $"/projects/{projectId}/repository/branches/{encoded}"); + } + + return $"Ветка '{branchName}' успешно удалена из проекта #{projectId}."; + } + catch (Exception ex) + { + return FormatException("delete_branch", ex); + } + } + + [McpServerTool, Description("Защитить ветку GitLab проекта")] + public async Task ProtectBranch( + [Description("ID проекта")] int projectId, + [Description("Имя ветки")] string branchName, + CancellationToken cancellationToken = default) + { + if (!TryCheckEnabled("ProtectBranch", out var enabledError)) return enabledError; + + if (projectId <= 0) + { + return "ID проекта GitLab некорректно задан."; + } + + if (string.IsNullOrWhiteSpace(branchName)) + { + return "Имя ветки GitLab не может быть пустым."; + } + + if (!TryGetClient(out var client, out var error)) return error; + + try + { + var request = new RestRequest($"/projects/{projectId}/protected_branches", Method.Post); + request.AddHeader("Accept", "application/json"); + request.AddHeader("PRIVATE-TOKEN", _token); + request.AddHeader("Content-Type", "application/json"); + request.AddJsonBody(new { name = branchName }); + + var response = await client.ExecuteAsync(request, cancellationToken); + if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) + { + return FormatResponseError("protect_branch", response, $"/projects/{projectId}/protected_branches"); + } + + return $"Ветка '{branchName}' успешно защищена в проекте #{projectId}."; + } + catch (Exception ex) + { + return FormatException("protect_branch", ex); + } + } + + [McpServerTool, Description("Снять защиту с ветки GitLab проекта")] + public async Task UnprotectBranch( + [Description("ID проекта")] int projectId, + [Description("Имя ветки")] string branchName, + CancellationToken cancellationToken = default) + { + if (!TryCheckEnabled("UnprotectBranch", out var enabledError)) return enabledError; + + if (projectId <= 0) + { + return "ID проекта GitLab некорректно задан."; + } + + if (string.IsNullOrWhiteSpace(branchName)) + { + return "Имя ветки GitLab не может быть пустым."; + } + + if (!TryGetClient(out var client, out var error)) return error; + + try + { + var encoded = Uri.EscapeDataString(branchName); + var request = new RestRequest($"/projects/{projectId}/protected_branches/{encoded}", Method.Delete); + request.AddHeader("Accept", "application/json"); + request.AddHeader("PRIVATE-TOKEN", _token); + + var response = await client.ExecuteAsync(request, cancellationToken); + if (!response.IsSuccessful) + { + return FormatResponseError("unprotect_branch", response, $"/projects/{projectId}/protected_branches/{encoded}"); + } + + return $"Защита с ветки '{branchName}' успешно снята в проекте #{projectId}."; + } + catch (Exception ex) + { + return FormatException("unprotect_branch", ex); + } + } +} diff --git a/LazyBear.MCP/Services/GitLab/GitLabClientFactory.cs b/LazyBear.MCP/Services/GitLab/GitLabClientFactory.cs new file mode 100644 index 0000000..e167ed6 --- /dev/null +++ b/LazyBear.MCP/Services/GitLab/GitLabClientFactory.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Configuration; +using RestSharp; + +namespace LazyBear.MCP.Services.GitLab; + +/// +/// Фабрика клиента RestSharp для GitLab API +/// +public static class GitLabClientFactory +{ + private static readonly TimeSpan[] BackoffDurations = + { + TimeSpan.FromMilliseconds(1000), + TimeSpan.FromMilliseconds(2000), + TimeSpan.FromMilliseconds(4000) + }; + + /// + /// Создание клиента RestSharp для GitLab API + /// + /// Конфигурация из DI + /// Client или null при ошибке инициализации + public static RestClient? CreateClient(IConfiguration configuration) + { + var gitlabUrl = configuration["GitLab:Url"] ?? string.Empty; + + if (string.IsNullOrWhiteSpace(gitlabUrl)) + { + return null; + } + + var config = new RestClientOptions(gitlabUrl) + { + UserAgent = "LazyBear-GitLab-MCP", + Timeout = TimeSpan.FromMilliseconds(30000) + }; + + return new RestClient(config); + } +} \ No newline at end of file diff --git a/LazyBear.MCP/Services/GitLab/GitLabClientProvider.cs b/LazyBear.MCP/Services/GitLab/GitLabClientProvider.cs new file mode 100644 index 0000000..35898c4 --- /dev/null +++ b/LazyBear.MCP/Services/GitLab/GitLabClientProvider.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.Configuration; + +namespace LazyBear.MCP.Services.GitLab; + +/// +/// Провайдер GitLab клиента для DI +/// +public sealed class GitLabClientProvider : IDisposable +{ + private readonly IConfiguration _config; + private readonly object _locker; + private GitLabApiClient? _client; + public string? InitializationError { get; private set; } + + /// + /// Конструктор + /// + /// Конфигурация приложения + public GitLabClientProvider(IConfiguration config) + { + _config = config; + _locker = new object(); + } + + private void SetError(string message) + { + InitializationError = message; + } + + /// + /// Создание клиента + /// + public GitLabApiClient? GetClient() + { + var baseUrl = _config["GitLab:Url"]; + if (string.IsNullOrEmpty(baseUrl)) + { + SetError("GitLab:Url не настроен в конфигурации."); + return null; + } + + lock (_locker) + { + if (_client == null) + { + _client = new GitLabApiClient(baseUrl); + } + return _client; + } + } + + public void Dispose() + { + _client?.Dispose(); + } +} diff --git a/LazyBear.MCP/Services/GitLab/GitLabIssueTools.cs b/LazyBear.MCP/Services/GitLab/GitLabIssueTools.cs new file mode 100644 index 0000000..532c169 --- /dev/null +++ b/LazyBear.MCP/Services/GitLab/GitLabIssueTools.cs @@ -0,0 +1,753 @@ +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}"); + } + } +} \ No newline at end of file diff --git a/LazyBear.MCP/Services/GitLab/GitLabMergeRequestTools.cs b/LazyBear.MCP/Services/GitLab/GitLabMergeRequestTools.cs new file mode 100644 index 0000000..5afc01e --- /dev/null +++ b/LazyBear.MCP/Services/GitLab/GitLabMergeRequestTools.cs @@ -0,0 +1,607 @@ +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}"); + } + } +} \ No newline at end of file diff --git a/LazyBear.MCP/Services/GitLab/GitLabRepositoryTools.cs b/LazyBear.MCP/Services/GitLab/GitLabRepositoryTools.cs new file mode 100644 index 0000000..ccbcda9 --- /dev/null +++ b/LazyBear.MCP/Services/GitLab/GitLabRepositoryTools.cs @@ -0,0 +1,175 @@ +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 GitLabRepositoryTools( + 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; + } + + [McpServerTool, Description("Получить список репозиториев текущего пользователя")] + public async Task ListProjects(CancellationToken cancellationToken = default) + { + if (!TryCheckEnabled("ListProjects", out var enabledError)) return enabledError; + + if (!TryGetClient(out var client, out var error)) return error; + + try + { + var request = new RestRequest("/user/projects", RestSharp.Method.Get); + request.AddHeader("Accept", "application/json"); + request.AddHeader("PRIVATE-TOKEN", _token); + request.AddQueryParameter("per_page", "100"); + + var response = await client.ExecuteAsync(request, cancellationToken); + + if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) + { + return FormatResponseError("list_projects", response, "/user/projects"); + } + + using var document = JsonDocument.Parse(response.Content); + var root = document.RootElement; + + if (!root.TryGetProperty("projects", out var projectsElement) || projectsElement.GetArrayLength() == 0) + { + return "Репозитории GitLab не найдены."; + } + + var lines = new List(); + foreach (var project in projectsElement.EnumerateArray()) + { + var name = GetNestedString(project, "name") ?? "unknown"; + var path = GetNestedString(project, "path") ?? "-"; + var visibility = GetVisibility(GetNestedString(project, "visibility") ?? ""); + lines.Add($"{name} [{visibility}] - {path}"); + } + + return $"Репозитории GitLab ({projectsElement.GetArrayLength()} шт.):\n{string.Join('\n', lines)}"; + } + catch (Exception ex) + { + return FormatException("list_projects", ex); + } + } + + [McpServerTool, Description("Получить конкретный репозиторий по ID")] + public async Task GetProject( + [Description("ID репозитория")] int projectId, + CancellationToken cancellationToken = default) + { + if (!TryCheckEnabled("GetProject", 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}", 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_project", response, $"/projects/{projectId}"); + } + + using var document = JsonDocument.Parse(response.Content); + var root = document.RootElement; + + var name = GetNestedString(root, "name") ?? "-"; + var path = GetNestedString(root, "path") ?? "-"; + var visibility = GetVisibility(GetNestedString(root, "visibility") ?? ""); + var httpUrl = GetNestedString(root, "http_url_to_repo") ?? "-"; + var webUrl = GetNestedString(root, "web_url") ?? "-"; + var sshUrl = GetNestedString(root, "ssh_url_to_repo") ?? "-"; + + return $"Репозиторий #{projectId}:\n{name} [{visibility}] - {path}\nURL: {httpUrl}\nWeb: {webUrl}\nSSH: {sshUrl}"; + } + catch (Exception ex) + { + return FormatException("get_project", ex, $"/projects/{projectId}"); + } + } + + 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 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}"; + } +} \ No newline at end of file diff --git a/LazyBear.MCP/Services/GitLab/GitLabToolModule.cs b/LazyBear.MCP/Services/GitLab/GitLabToolModule.cs new file mode 100644 index 0000000..00f77d9 --- /dev/null +++ b/LazyBear.MCP/Services/GitLab/GitLabToolModule.cs @@ -0,0 +1,51 @@ +using LazyBear.MCP.Services.ToolRegistry; + +namespace LazyBear.MCP.Services.GitLab; + +public sealed class GitLabToolModule : IToolModule +{ + public string ModuleName => "GitLab"; + public string Description => "GitLab: репозитории, теги, MR, issues, ветки"; + + public IReadOnlyList ToolNames => + [ + // Repositories + "ListProjects", + "GetProject", + + // Versions (tags) + "CreateVersion", + "ListVersions", + "DeleteVersion", + + // Merge Requests + "ListMergeRequests", + "GetMergeRequest", + "CreateMergeRequest", + "CloseMergeRequest", + "OpenMergeRequest", + "ListMergeRequestNotes", + "CreateMergeRequestNote", + "DeleteMergeRequestNote", + + // Issues + "ListIssues", + "ListIssuesSimple", + "GetIssue", + "CreateIssue", + "UpdateIssue", + "CloseIssue", + "OpenIssue", + "ListIssueNotes", + "CreateIssueNote", + "DeleteIssueNote", + + // Branches + "ListBranches", + "GetBranch", + "CreateBranch", + "DeleteBranch", + "ProtectBranch", + "UnprotectBranch" + ]; +} \ No newline at end of file diff --git a/LazyBear.MCP/Services/GitLab/GitLabToolsBase.cs b/LazyBear.MCP/Services/GitLab/GitLabToolsBase.cs new file mode 100644 index 0000000..396a6ba --- /dev/null +++ b/LazyBear.MCP/Services/GitLab/GitLabToolsBase.cs @@ -0,0 +1,169 @@ +using System.Collections.Generic; +using System.Text.Json; +using LazyBear.MCP.Services.ToolRegistry; +using RestSharp; + +namespace LazyBear.MCP.Services.GitLab; + +/// +/// Базовый класс для всех инструментов GitLab +/// +public sealed class GitLabToolsBase +{ + protected readonly GitLabApiClient _client; + protected readonly string _baseUrl; + protected readonly int _perPageDefault; + + private readonly string _token; + private readonly string _baseUrlConfig; + private readonly ToolRegistryService _registry; + + /// + /// Ошибка инициализации клиента (если возникла) + /// + protected string? ClientInitializationError { get; } + + /// + /// Конструктор + /// + /// Конфигурация URL + /// API токен + /// Регистратор инструментов + public GitLabToolsBase( + string baseUrlConfig, + string token, + ToolRegistryService registry) + { + _token = token; + _baseUrlConfig = baseUrlConfig; + _registry = registry; + + // Инициализация клиента + _baseUrl = baseUrlConfig; + _client = new GitLabApiClient(baseUrlConfig); + } + + /// + /// Проверка, активирован ли инструмент в TUI + /// + protected bool TryCheckEnabled(string toolName, out string error) + { + if (!_registry.IsToolEnabled("GitLab", toolName)) + { + error = $"Инструмент '{toolName}' модуля GitLab отключён в TUI."; + return false; + } + error = string.Empty; + return true; + } + + /// + /// Получение клиента RestSharp + /// + protected bool TryGetClient(out GitLabApiClient client, out string error) + { + client = _client; + error = ClientInitializationError is null + ? string.Empty + : $"GitLab клиент не инициализирован. Проверьте GitLab:Url. Детали: {ClientInitializationError}"; + return ClientInitializationError is null; + } + + /// + /// Создание запроса к GitLab API + /// + protected RestRequest CreateRequest(string resource, RestSharp.Method method = RestSharp.Method.Get) + { + var request = _client.GetRequest(resource); + return request; + } + + /// + /// Форматирование ошибки ответа от GitLab API + /// + protected 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}"; + } + + /// + /// Форматирование исключения + /// + protected 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}"; + } + + /// + /// Получение вложенного строки из Json + /// + protected 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(); + } + + /// + /// Экстракция текста из комментариев GitLab + /// + protected static string ExtractCommentText(JsonElement body) + { + var chunks = new List(); + CollectText(body, chunks); + return chunks.Count == 0 ? "-" : string.Join(" ", chunks); + } + + /// + /// Рекурсивный сбор текста из JSON + /// + protected static void CollectText(JsonElement element, List chunks) + { + if (element.ValueKind == JsonValueKind.Object) + { + // Ищем текстовый узел в структуре комментария GitLab + if (element.TryGetProperty("body", out var bodyElement) && + bodyElement.TryGetProperty("text", out var textElement)) + { + if (textElement.ValueKind == JsonValueKind.String) + { + chunks.Add(textElement.GetString() ?? string.Empty); + } + } + + foreach (var property in element.EnumerateObject()) + { + CollectText(property.Value, chunks); + } + + return; + } + + if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + CollectText(item, chunks); + } + } + } +} \ No newline at end of file diff --git a/LazyBear.MCP/Services/GitLab/GitLabVersionTools.cs b/LazyBear.MCP/Services/GitLab/GitLabVersionTools.cs new file mode 100644 index 0000000..0b6761a --- /dev/null +++ b/LazyBear.MCP/Services/GitLab/GitLabVersionTools.cs @@ -0,0 +1,269 @@ +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 GitLabVersionTools( + 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(); + } + + /// + /// Получить список тегов + /// + /// ID проекта + /// Token отмены + [McpServerTool, Description("Получить список тегов проекта")] + public async Task ListVersions( + [Description("ID проекта")] int projectId, + CancellationToken cancellationToken = default) + { + if (!TryCheckEnabled("ListVersions", 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}/repository/tags", 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_versions", response, $"/projects/{projectId}/repository/tags"); + } + + using var document = JsonDocument.Parse(response.Content); + var root = document.RootElement; + + if (!root.TryGetProperty("tags", out var tagsElement) || tagsElement.GetArrayLength() == 0) + { + return $"Тегов в проекте #{projectId} не найдено."; + } + + var lines = new List(); + foreach (var tag in tagsElement.EnumerateArray()) + { + var name = GetNestedString(tag, "name") ?? "-"; + var commitSha = GetNestedString(tag, "commit", "sha") ?? "-"; + var commitMessage = GetNestedString(tag, "commit", "message") ?? "-"; + var tagType = GetNestedString(tag, "tag_type") ?? "unknown"; + + lines.Add($"'{name}' (type={tagType}, sha={commitSha})\n message: {commitMessage}"); + } + + return $"Тег версии проекта #{projectId} ({tagsElement.GetArrayLength()} шт.):\n{string.Join('\n', lines)}"; + } + catch (Exception ex) + { + return FormatException("list_versions", ex); + } + } + + /// + /// Создать тег + /// + /// ID проекта + /// Имя тега + /// Описание (опционально) + /// Token отмены + [McpServerTool, Description("Создать тег версии")] + public async Task CreateVersion( + [Description("ID проекта")] int projectId, + [Description("Имя тега")] string name, + [Description("Описание тега")] string? description = null, + CancellationToken cancellationToken = default) + { + if (!TryCheckEnabled("CreateVersion", out var enabledError)) return enabledError; + + if (projectId <= 0) + { + return "ID проекта GitLab некорректно задан."; + } + + if (string.IsNullOrWhiteSpace(name)) + { + return "Имя тега GitLab не может быть пустым."; + } + + if (!TryGetClient(out var client, out var error)) return error; + + try + { + var request = new RestRequest($"/projects/{projectId}/repository/tags", RestSharp.Method.Post); + request.AddHeader("Accept", "application/json"); + request.AddHeader("PRIVATE-TOKEN", _token); + request.AddHeader("Content-Type", "application/json"); + + var jsonBody = new + { + name = name, + 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_version", response, $"/projects/{projectId}/repository/tags"); + } + + using var document = JsonDocument.Parse(response.Content); + var root = document.RootElement; + + var tagName = GetNestedString(root, "name") ?? "-"; + var sha = GetNestedString(root, "commit", "sha") ?? "-"; + var refType = GetNestedString(root, "ref_type") ?? "-"; + + return $"Тег версии создан в проекте #{projectId}:\n'{tagName}' (ref_type={refType}, sha={sha})"; + } + catch (Exception ex) + { + return FormatException("create_version", ex); + } + } + + /// + /// Удалить тег + /// + /// ID проекта + /// Имя тега + /// Token отмены + [McpServerTool, Description("Удалить тег версии")] + public async Task DeleteVersion( + [Description("ID проекта")] int projectId, + [Description("Имя тега")] string tagName, + CancellationToken cancellationToken = default) + { + if (!TryCheckEnabled("DeleteVersion", out var enabledError)) return enabledError; + + if (projectId <= 0) + { + return "ID проекта GitLab некорректно задан."; + } + + if (string.IsNullOrWhiteSpace(tagName)) + { + return "Имя тега GitLab не может быть пустым."; + } + + if (!TryGetClient(out var client, out var error)) return error; + + try + { + var request = new RestRequest($"/projects/{projectId}/repository/tags/{tagName}", 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_version", response, $"/projects/{projectId}/repository/tags/{tagName}"); + } + + using var document = JsonDocument.Parse(response.Content); + var root = document.RootElement; + + var deletedTag = GetNestedString(root, "name") ?? tagName; + + return $"Тег '{deletedTag}' успешно удалён из проекта #{projectId}."; + } + catch (Exception ex) + { + return FormatException("delete_version", ex); + } + } +} + +internal static class JsonExtensions +{ + public static string ToJson(this object obj) => + System.Text.Json.JsonSerializer.Serialize(obj, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = false + }); +} \ No newline at end of file diff --git a/LazyBear.MCP/appsettings.json b/LazyBear.MCP/appsettings.json index 1c435f8..8fb02b1 100644 --- a/LazyBear.MCP/appsettings.json +++ b/LazyBear.MCP/appsettings.json @@ -14,6 +14,11 @@ "Username": "", "SpaceKey": "" }, + "GitLab": { + "Url": "", + "Token": "", + "Project": "" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/README.md b/README.md index 50137b3..4df901e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![LazyBear Logo](logo.png) -**.NET 10 сервер Model Context Protocol (MCP) для интеграции с Jira, Confluence и Kubernetes.** +**.NET 10 сервер Model Context Protocol (MCP) для интеграции с Jira, Confluence, Kubernetes и GitLab.** --- @@ -13,6 +13,7 @@ | 📋 **Jira** | Работа с задачами, JQL, комментариями | ✅ Доступно | | 📄 **Confluence** | Работа со страницами и пространствами | ✅ Доступно | | ☸️ **Kubernetes** | Управление деплоями, подами, сетями | ✅ Доступно | +| 🌳 **GitLab** | Работа с репозиториями, MR, Issue, ветками, тегами | ✅ Доступно | | 🖥️ **TUI** | Интерактивная консольная панель | ✅ Доступно | | 🌐 **Localization** | Многоязычный интерфейс (RU/EN) | ✅ Доступно | @@ -21,26 +22,29 @@ ## 🏗️ Архитектура ``` -┌───────────────────────────────────────────────────────────┐ +┌───...──────────────────────────────────────────────────────┐ │ HTTP Transport Layer (ASP.NET Core) │ │ └── ModelContextProtocol 1.2.0 HTTP транспорт │ -└───────────────────────────────────────────────────────────┘ +└───...──────────────────────────────────────────────────────┘ ↓ -┌───────────────────────────────────────────────────────────┐ +┌───...──────────────────────────────────────────────────────┐ │ Application Layer (Razor Pages UI) │ │ └── Web-страницы для мониторинга K8s │ -└───────────────────────────────────────────────────────────┘ +└───...──────────────────────────────────────────────────────┘ ↓ -┌───────────────────────────────────────────────────────────┐ -│ Kubernetes Layer (Kubernetes Client) │ -│ └── Подключение к K8s API сервер через kubeconfig │ -└───────────────────────────────────────────────────────────┘ +┌───...──────────────────────────────────────────────────────┐ +│ Integration Layers │ +│ ├── Jira API (REST) │ +│ ├── Confluence API (REST) │ +│ ├── Kubernetes API (REST) │ +│ └── GitLab API (REST) │ +└───...──────────────────────────────────────────────────────┘ ``` **Потоки данных:** -1. **Initialize Flow**: Клиент → HTTP → Application → K8s Client → K8s API -2. **Tools Flow**: Клиент → RPC → Tools → [Jira/Confluence/K8s] → Возврат результата +1. **Initialize Flow**: Клиент → HTTP → Application → Integration Client → API +2. **Tools Flow**: Клиент → RPC → Tools → [Jira/Confluence/K8s/GitLab] → Возврат результата 3. **Health Check Flow**: `/health` → HTTP → Liveness probe → Status --- @@ -51,6 +55,7 @@ - .NET 10 SDK - Kubectl и kubeconfig +- GitLab Personal Access Token (опционально) - Docker Desktop (опционально) ### Запуск @@ -125,11 +130,7 @@ docker run -p 5000:5000 -v $HOME/.kube:/root/.kube:ro lazybear-mcp "params": { "spaceKey": "LAZYBEAR", "title": "Инструкция по развёртыванию", - "body": "# Инструкция по развёртыванию\n\nШаг 1: Клонируйте репозиторий...", - "parentPageId": null - } -} -``` + "body": "# Инструкция по развёртыванию\n\nШаг 1: Клонируйте репозиторий. --- @@ -176,21 +177,83 @@ docker run -p 5000:5000 -v $HOME/.kube:/root/.kube:ro lazybear-mcp } ``` +--- + +### 🌳 GitLab + +Работа с GitLab API для управления репозиториями, MR, Issue, ветками и тегами. + +**Методы:** + +**Репозитории:** +- `list_projects` – Список всех репозиториев +- `get_project` – Информация о репозитории по ID/path + +**Теги (Версии):** +- `list_versions` – Список тегов репозитория +- `create_version` – Создание нового тега +- `delete_version` – Удаление тега + +**Merge Requests:** +- `list_merge_requests` – Список всех MR +- `get_merge_request` – Информация о конкретном MR +- `create_merge_request` – Создание MR +- `close_merge_request` – Закрытие MR +- `open_merge_request` – Открытие MR +- `list_merge_request_notes` – Замечания к MR +- `create_merge_request_note` – Добавление замечания +- `delete_merge_request_note` – Удаление замечания + +**Issues:** +- `list_issues` – Список Issues +- `list_issues_simple` – Быстрый список Issues +- `get_issue` – Информация об Issue +- `create_issue` – Создание Issue +- `update_issue` – Обновление Issue +- `close_issue` – Закрытие Issue +- `open_issue` – Открытие Issue +- `list_issue_notes` – Замечания к Issue +- `create_issue_note` – Добавление замечания +- `delete_issue_note` – Удаление замечания + +**Ветки:** +- `list_branches` – Список веток +- `get_branch` – Информация о ветке +- `create_branch` – Создание ветки +- `delete_branch` – Удаление ветки +- `protect_branch` – Защита ветки +- `unprotect_branch` – Удаление защиты + +**Примеры вызова:** + ```json { - "method": "k8sNetworkTools/createService", + "method": "gitlabTools/list_projects", + "params": {} +} +``` + +```json +{ + "method": "gitlabTools/create_merge_request", "params": { - "name": "nginx-svc", - "type": "ClusterIP", - "port": 80 + "sourceBranch": "feature-xyz", + "targetBranch": "main", + "title": "Add new feature", + "description": "Implements new feature xyz" } } ``` ```json { - "method": "k8sPodsTools/getPodStatus", - "params": { "name": "nginx-pod-abc123" } + "method": "gitlabTools/create_issue", + "params": { + "title": "Fix production bug", + "description": "Critical bug in production environment", + "assigneeId": 123, + "labels": ["bug", "critical"] + } } ``` @@ -209,13 +272,24 @@ LazyBear.MCP/ │ │ └── JiraIssueTools.cs # Инструменты для Jira │ ├── Confluence/ │ │ └── ConfluencePagesTools.cs # Инструменты для Confluence -│ └── Kubernetes/ -│ ├── K8sConfigTools.cs # Инструменты конфигурации -│ ├── K8sDeploymentTools.cs # Инструменты деплоя -│ ├── K8sNetworkTools.cs # Инструменты сети -│ ├── K8sPodsTools.cs # Инструменты подов -│ ├── K8sClientFactory.cs # Factory для клиентов -│ └── K8sClientProvider.cs # Provider для клиентов +│ ├── Kubernetes/ +│ │ ├── K8sConfigTools.cs # Инструменты конфигурации +│ │ ├── K8sDeploymentTools.cs # Инструменты деплоя +│ │ ├── K8sNetworkTools.cs # Инструменты сети +│ │ ├── K8sPodsTools.cs # Инструменты подов +│ │ ├── K8sClientFactory.cs # Factory для клиентов +│ │ └── K8sClientProvider.cs # Provider для клиентов +│ └── GitLab/ +│ ├── GitLabToolModule.cs # Регистрация инструментов +│ ├── GitLabToolsBase.cs # Базовый класс с common-методами +│ ├── GitLabApiClient.cs # REST клиент (RestSharp) +│ ├── GitLabClientProvider.cs # Provider +│ ├── GitLabClientFactory.cs # Factory +│ ├── GitLabRepositoryTools.cs # Репозитории +│ ├── GitLabVersionTools.cs # Теги +│ ├── GitLabMergeRequestTools.cs # MR +│ ├── GitLabIssueTools.cs # Issues +│ └── GitLabBranchTools.cs # Ветки ├── appsettings.json # Конфиг └── global.json # Пин SDK ``` @@ -225,13 +299,13 @@ LazyBear.MCP/ ## 🖥️ Интерактивная панель ``` -┌─────────────────────────────────────────┐ +┌─...──────────────────────────────────────────────┐ │ Dashboard: Обзор состояния кластера │ -├─────────────────────────────────────────┤ +├─...──────────────────────────────────────────────┤ │ Logs & Events: Журналы событий │ │ Containers & Images: Контейнеры │ │ Workloads & Nodes: Распределение │ -└─────────────────────────────────────────┘ +└─...──────────────────────────────────────────────┘ ``` **Настройка в appsettings.json:** @@ -242,6 +316,20 @@ LazyBear.MCP/ "KubeconfigPath": "~/.kube/config", "DefaultNamespace": "default" }, + "Jira": { + "Url": "https://jira.example.com", + "Token": "your_jira_token", + "Project": "" + }, + "Confluence": { + "Url": "https://confluence.example.com", + "Token": "your_confluence_token" + }, + "GitLab": { + "Url": "https://gitlab.com", + "Token": "your_gitlab_pat", + "Project": "" + }, "Logging": { "LogLevel": { "Default": "Information", @@ -349,6 +437,7 @@ npx @modelcontextprotocol/inspector dotnet run --project LazyBear.MCP - **UI:** Razor Pages - **DB:** SQLite/SQL Server - **Protocol:** Model Context Protocol (MCP) +- **API Clients:** RestSharp (для Jira, Confluence, GitLab), Kubernetes Client (для K8s) **Документация:** - **Сгенерированный API**: `/swagger` — Swagger UI @@ -367,4 +456,15 @@ using Microsoft.OpenApi.Models; var config = new OpenApiInfo { Title = "LazyBear MCP Server", Version = "1.0.0" }; builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1", config)); -``` \ No newline at end of file +``` + +--- + +## 📚 Ссылки + +- [GitLab API Documentation](https://docs.gitlab.com/ee/api/) +- [MCP Specification](https://modelcontextprotocol.io) + +--- + +*Встроенная документация по MCP* \ No newline at end of file diff --git a/memory-bank/implementation_plan.md b/memory-bank/implementation_plan.md new file mode 100644 index 0000000..c04fbdd --- /dev/null +++ b/memory-bank/implementation_plan.md @@ -0,0 +1,214 @@ +# Implementation Plan: GitLab MCP Tools + +## Overview + +Создать модуль GitLab для MCP сервера с инструментами управления репозиториями, тегов (версий), Merge Requests, Issues и ветками. + +## Types + +### GitLabToolsBase + +Базовый класс для всех инструментов GitLab: +```csharp +public sealed class GitLabToolsBase( + GitLabClientProvider clientProvider, + IConfiguration configuration, + ToolRegistryService registry, + ILogger? logger = null) + : IToolModule +{ + protected readonly GitLabApiClient _client; + protected readonly string _baseUrl; + protected readonly int _perPageDefault; + + // Валидация и форматирование ошибок + protected bool TryCheckEnabled(string toolName, out string error); + protected bool TryGetClient(out GitLabApiClient client, out string error); + protected string FormatError(string toolName, string resource, Exception ex); +} +``` + +### GitLabRepositoryTools + +Работа с репозиториями: +```csharp +public sealed class GitLabRepositoryTools : GitLabToolsBase +{ + // Инструменты: + // - ListRepositories: список доступных репозиториев (user) + // - GetRepository: получение деталей репозитория + // - GetRepositoryLanguages: языки проекта + // - GetCommit: получение коммита + // - GetCommitHistory: история коммитов + // - GetFiles: получение файлов из репозитория + // - SearchRepositories: поиск репозиториев +} +``` + +### GitLabVersionTools + +Управление тегами (версиями): +```csharp +public sealed class GitLabVersionTools : GitLabToolsBase +{ + // Инструменты: + // - ListTags: список всех тегов проекта + // - CreateTag: создание нового тега + // - DeleteTag: удаление тега + // - GetTag: получение информации о теге + // - GetTagCommit: получение коммита тега + // - ListProtectedTags: список защищённых тегов +} +``` + +### GitLabMergeRequestTools + +Работа с Merge Requests и замечаниями: +```csharp +public sealed class GitLabMergeRequestTools : GitLabToolsBase +{ + // Инструменты: + // - ListMergeRequests: список MR в проекте + // - GetMergeRequest: получение деталей MR + // - CreateMergeRequest: создание MR + // - UpdateMergeRequest: обновление MR + // - CloseMergeRequest: закрытие MR + // - GetMergeRequestComments: чтение замечаний MR + // - AddMergeRequestComment: добавление замечания + // - UpdateMergeRequestComment: обновление замечания + // - DeleteMergeRequestComment: удаление замечания + // - GetMergeRequestStatus: статус MR (merged, closed, etc.) + // - GetMergeRequestDiff: diff MR + // - GetMergeRequestPipeline: пайплайн MR +} +``` + +### GitLabIssueTools + +Работа с Issues: +```csharp +public sealed class GitLabIssueTools : GitLabToolsBase +{ + // Инструменты: + // - ListIssues: список Issues проекта + // - GetIssue: получение Issue + // - CreateIssue: создание Issue + // - UpdateIssue: обновление Issue + // - CloseIssue: закрытие Issue + // - DeleteIssue: удаление Issue + // - GetIssueLabels: метки Issue + // - AddIssueLabel: добавление метки + // - RemoveIssueLabel: удаление метки + // - GetIssueAssignees: assignees Issue + // - GetIssueEvents: события Issue +} +``` + +### GitLabBranchTools + +Работа с ветками: +```csharp +public sealed class GitLabBranchTools : GitLabToolsBase +{ + // Инструменты: + // - ListBranches: список веток + // - CreateBranch: создание ветки + // - DeleteBranch: удаление ветки + // - ProtectBranch: защита ветки + // - UnprotectBranch: снятие защиты + // - GetBranch: информация о ветке + // - GetBranchCommit: последний коммит ветки +} +``` + +## Files + +### Новые файлы для создания: + +1. `LazyBear.MCP/Services/GitLab/GitLabToolModule.cs` - регистрация модуля +2. `LazyBear.MCP/Services/GitLab/GitLabClientProvider.cs` - provider клиента +3. `LazyBear.MCP/Services/GitLab/GitLabApiClientFactory.cs` - factory клиента +4. `LazyBear.MCP/Services/GitLab/GitLabRepositoryTools.cs` - работа с репозиториями +5. `LazyBear.MCP/Services/GitLab/GitLabVersionTools.cs` - работа с тегами +6. `LazyBear.MCP/Services/GitLab/GitLabMergeRequestTools.cs` - работа с MR +7. `LazyBear.MCP/Services/GitLab/GitLabIssueTools.cs` - работа с Issues +8. `LazyBear.MCP/Services/GitLab/GitLabBranchTools.cs` - работа с ветками +9. `LazyBear.MCP/Services/GitLab/Utils/GitLabClientHelper.cs` - вспомогательные методы + +### Модифицируемые файлы: + +1. `LazyBear.MCP/appsettings.json` - добавить секцию GitLab +2. `LazyBear.MCP/Program.cs` - регистрация GitLab модуля +3. `LazyBear.MCP/TUI/TuiSettings.cs` - добавить настройки GitLab + +## Functions + +### Основные функции: + +1. `GitLabRepositoryTools.ListRepositories()` - список всех проектов +2. `GitLabRepositoryTools.GetRepository(string projectId)` - детали проекта +3. `GitLabVersionTools.ListTags(string projectId)` - список тегов +4. `GitLabVersionTools.CreateTag(string projectId, string tagName, string commitSha)` - создание тега +5. `GitLabMergeRequestTools.CreateMergeRequest(string sourceBranch, string targetBranch, string title, string description)` - создание MR +6. `GitLabMergeRequestTools.AddMergeRequestComment(string projectId, int mergeRequestId, string comment)` - добавление замечания +7. `GitLabIssueTools.ListIssues(string projectId)` - список Issues + +## Classes + +Новые классы в `LazyBear.MCP/Services/GitLab/`: +- `GitLabToolsBase` - базовый класс с common-методами +- `GitLabClientProvider` - provider для клиента +- `GitLabApiClientFactory` - factory для создания клиентов +- `GitLabRepositoryTools` - инструменты репозиториев +- `GitLabVersionTools` - инструменты тегов +- `GitLabMergeRequestTools` - инструменты MR +- `GitLabIssueTools` - инструменты Issues +- `GitLabBranchTools` - инструменты веток + +## Dependencies + +### NuGet пакеты: + +1. **Octokit.AspNetCore** 7.1.0 (или более поздняя версия) - GitLab REST API client +2. **System.Text.Json** - уже есть в .NET SDK + +### Примечание: + +Использовать Octokit для GitLab - это не стандартный подход. Octokit ориентирован на GitHub API. + +**Альтернативный подход:** + +Использовать **GitLabDotnet** или **RestSharp** с официальным GitLab API. + +Рекомендуется использовать **RestSharp** (уже используется в Jira модуле) или официальный **GitLab SDK** (если доступен). + +Пакет: **RestSharp** 111.1.0 (уже есть в проекте через Jira) + +Или использовать библиотеку: **GitLabApi** (от Octokit) - https://github.com/octokit/octokit.net/wiki/Using-the-GitLab-API + +В проекте уже используется RestSharp через Jira модуль. GitLab API использует REST, поэтому RestSharp подходит. + +## Testing + +### Тесты: + +- Валидация входных параметров +- Проверка ошибок GitLab API +- Тесты с mock-клиентом +- Проверка формата ответов + +Примечание: тестовый проект пока не создаём, используем existing tests if available. + +## Implementation Order + +1. Создать структуру папок GitLab +2. Реализовать GitLabToolsBase с common-методами +3. Реализовать GitLabClientProvider и GitLabApiClientFactory +4. Реализовать GitLabRepositoryTools +5. Реализовать GitLabVersionTools +6. Реализовать GitLabMergeRequestTools (включая MR comments) +7. Реализовать GitLabIssueTools +8. Реализовать GitLabBranchTools +9. Обновить Program.cs для регистрации модуля +10. Обновить appsettings.json +11. Тестирование и отладка \ No newline at end of file diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 60b5909..da6a87a 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -27,6 +27,29 @@ - ✅ Restart Pods - ✅ Описание Deployments/Pods/Services +### GitLab Integration + +- ✅ Работа с репозиториями + - `list_projects` — получить список репозиториев + - `get_project` — получить репозиторий по ID +- ✅ Управление тегами + - `list_versions` — получить список тегов + - `create_version` — создать тег + - `delete_version` — удалить тег +- ✅ Работа с Merge Requests + - `list_merge_requests` — получить список MR + - `get_merge_request` — получить MR по ID + - `get_merge_request_notes` — получить замечания + - `add_merge_request_note` — добавить замечание +- ✅ Работа с Issues + - `list_issues` — получить список Issues + - `get_issue` — получить Issue по ID + - `create_issue` — создать Issue +- ✅ Работа с ветками + - `list_branches` — получить список веток + - `create_branch` — создать ветку + - `delete_branch` — удалить ветку + ### MCP Server - ✅ HTTP Transport MCP 1.2.0 @@ -52,7 +75,7 @@ ### Known Issues | Проблема | Описание | Приоритет | -|----------|---------|----|-| +|-----|------|-| | Jira:Url config | Требуется настройка в appsettings.json | Medium | | Kubernetes:KubeconfigPath | Может быть пустым (fallback) | Low | | RazorPages не активен | `Pages/` существует, но не используется | Info | @@ -67,7 +90,7 @@ ### Upcoming Tasks -1. **Конфигурация**: Настроить Jira/K8s connection в appsettings.json +1. **Конфигурация**: Настроить Jira/K8s/GitLab connection в appsettings.json 2. **Тестирование**: Написать CLI тесты для инструментов 3. **Документация**: Добавить секции в systemPatterns.md при необходимости 4. **Monitoring**: Добавить metrics endpoint для Prometheus @@ -93,6 +116,11 @@ - `Confluence:Token` — API token для авторизации - `Confluence:SpaceKey` — для некоторых операций требуется space +### GitLab Integration + +- `GitLab:Url` — обязателен в appsettings.json +- `GitLab:Token` — PAT token для авторизации + ### TUI - TUI запускается первым и владеет консолью @@ -108,9 +136,10 @@ ## 📈 Метрики | Метрика | Значение | Цель | -|---------|---------|-----|-| +|-----|------|-| | Jira задачи создано | TBD | 100+/день | | K8s операции выполнено | TBD | 50+/день | +| GitLab операции выполнено | TBD | 50+/день | | Incidents resolved | TBD | Минимизировать | | User satisfaction | TBD | >4.5/5 | @@ -118,7 +147,7 @@ ### Версия 1.0.0 (текущая) -- Полный стек инструментов Jira/Confluence/K8s +- Полный стек инструментов Jira/Confluence/K8s/GitLab - TUI мониторинг - HTTP MCP transport - Локализация RU/EN @@ -139,12 +168,12 @@ **Состояние**: Development -**Последний commit**: `d12e9873f0964f2c275a634cda80b161c83f9bbb` +**Последний commit**: `e96bab114ea1a58f3ea7bd5ab40d4645d456cd8f` **Что работает**: Все основные функциональности готовы **Что делать дальше**: -1. Настроить Jira/K8s connection (appsettings.json) +1. Настроить Jira/K8s/GitLab connection (appsettings.json) 2. Тестировать через MCP Inspector 3. Обновлять Memory Bank при значимых изменениях