feat: добавить поддержку GitLab (api, clients, tools) и обновить документацию
This commit is contained in:
67
.clinerules
Normal file
67
.clinerules
Normal file
@@ -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.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using LazyBear.MCP.Services.Confluence;
|
using LazyBear.MCP.Services.Confluence;
|
||||||
|
using LazyBear.MCP.Services.GitLab;
|
||||||
using LazyBear.MCP.Services.Jira;
|
using LazyBear.MCP.Services.Jira;
|
||||||
using LazyBear.MCP.Services.Kubernetes;
|
using LazyBear.MCP.Services.Kubernetes;
|
||||||
using LazyBear.MCP.Services.Logging;
|
using LazyBear.MCP.Services.Logging;
|
||||||
@@ -25,11 +26,13 @@ var host = Host.CreateDefaultBuilder(args)
|
|||||||
services.AddSingleton<K8sClientProvider>();
|
services.AddSingleton<K8sClientProvider>();
|
||||||
services.AddSingleton<JiraClientProvider>();
|
services.AddSingleton<JiraClientProvider>();
|
||||||
services.AddSingleton<ConfluenceClientProvider>();
|
services.AddSingleton<ConfluenceClientProvider>();
|
||||||
|
services.AddSingleton<GitLabClientProvider>();
|
||||||
|
|
||||||
// Модули инструментов (добавь новый IToolModule — он появится в TUI)
|
// Модули инструментов (добавь новый IToolModule — он появится в TUI)
|
||||||
services.AddSingleton<IToolModule, JiraToolModule>();
|
services.AddSingleton<IToolModule, JiraToolModule>();
|
||||||
services.AddSingleton<IToolModule, KubernetesToolModule>();
|
services.AddSingleton<IToolModule, KubernetesToolModule>();
|
||||||
services.AddSingleton<IToolModule, ConfluenceToolModule>();
|
services.AddSingleton<IToolModule, ConfluenceToolModule>();
|
||||||
|
services.AddSingleton<IToolModule, GitLabToolModule>();
|
||||||
|
|
||||||
// HTTP MCP endpoint запускаем в фоне, чтобы TUI оставался владельцем консоли
|
// HTTP MCP endpoint запускаем в фоне, чтобы TUI оставался владельцем консоли
|
||||||
services.AddHostedService<McpWebHostedService>();
|
services.AddHostedService<McpWebHostedService>();
|
||||||
|
|||||||
469
LazyBear.MCP/README.md
Normal file
469
LazyBear.MCP/README.md
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
# LazyBear MCP Server
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**.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
|
||||||
|
<!-- LazyBear.MCP/Program.cs -->
|
||||||
|
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*
|
||||||
75
LazyBear.MCP/Services/GitLab/GitLabApiClient.cs
Normal file
75
LazyBear.MCP/Services/GitLab/GitLabApiClient.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
using RestSharp;
|
||||||
|
|
||||||
|
namespace LazyBear.MCP.Services.GitLab;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Обертка над RestSharp RestClient для GitLab API
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GitLabApiClient : IDisposable
|
||||||
|
{
|
||||||
|
public RestClient RestClient { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Конструктор
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="url">URL GitLab</param>
|
||||||
|
public GitLabApiClient(string url)
|
||||||
|
{
|
||||||
|
_restClient = new RestClient(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly RestClient _restClient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Создание запроса GET
|
||||||
|
/// </summary>
|
||||||
|
public RestRequest GetRequest(string resource)
|
||||||
|
{
|
||||||
|
var request = new RestRequest(resource, Method.Get);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Создание запроса POST
|
||||||
|
/// </summary>
|
||||||
|
public RestRequest PostRequest(string resource)
|
||||||
|
{
|
||||||
|
var request = new RestRequest(resource, Method.Post);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Создание запроса PUT
|
||||||
|
/// </summary>
|
||||||
|
public RestRequest PutRequest(string resource)
|
||||||
|
{
|
||||||
|
var request = new RestRequest(resource, Method.Put);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Создание запроса DELETE
|
||||||
|
/// </summary>
|
||||||
|
public RestRequest DeleteRequest(string resource)
|
||||||
|
{
|
||||||
|
var request = new RestRequest(resource, Method.Delete);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Выполнение запроса
|
||||||
|
/// </summary>
|
||||||
|
public async System.Threading.Tasks.Task<RestResponse> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
358
LazyBear.MCP/Services/GitLab/GitLabBranchTools.cs
Normal file
358
LazyBear.MCP/Services/GitLab/GitLabBranchTools.cs
Normal file
@@ -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<string> 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<string>();
|
||||||
|
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<string> 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<string> 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<string> 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<string> 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<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
LazyBear.MCP/Services/GitLab/GitLabClientFactory.cs
Normal file
40
LazyBear.MCP/Services/GitLab/GitLabClientFactory.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using RestSharp;
|
||||||
|
|
||||||
|
namespace LazyBear.MCP.Services.GitLab;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Фабрика клиента RestSharp для GitLab API
|
||||||
|
/// </summary>
|
||||||
|
public static class GitLabClientFactory
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan[] BackoffDurations =
|
||||||
|
{
|
||||||
|
TimeSpan.FromMilliseconds(1000),
|
||||||
|
TimeSpan.FromMilliseconds(2000),
|
||||||
|
TimeSpan.FromMilliseconds(4000)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Создание клиента RestSharp для GitLab API
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="configuration">Конфигурация из DI</param>
|
||||||
|
/// <returns>Client или null при ошибке инициализации</returns>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
LazyBear.MCP/Services/GitLab/GitLabClientProvider.cs
Normal file
56
LazyBear.MCP/Services/GitLab/GitLabClientProvider.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace LazyBear.MCP.Services.GitLab;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Провайдер GitLab клиента для DI
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GitLabClientProvider : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
private readonly object _locker;
|
||||||
|
private GitLabApiClient? _client;
|
||||||
|
public string? InitializationError { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Конструктор
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">Конфигурация приложения</param>
|
||||||
|
public GitLabClientProvider(IConfiguration config)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_locker = new object();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetError(string message)
|
||||||
|
{
|
||||||
|
InitializationError = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Создание клиента
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
753
LazyBear.MCP/Services/GitLab/GitLabIssueTools.cs
Normal file
753
LazyBear.MCP/Services/GitLab/GitLabIssueTools.cs
Normal file
@@ -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}" : "-";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Получить список Issues
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="issueState">Состояние issue (опционально)</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Получить список Issues проекта")]
|
||||||
|
public async Task<string> 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<string>();
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Получить список Issues без фильтрации по state
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Получить список Issues проекта (без фильтра state)")]
|
||||||
|
public async Task<string> 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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Получить конкретный Issue
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="issueIid">ID Issue</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Получить конкретный Issue")]
|
||||||
|
public async Task<string> 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<string>();
|
||||||
|
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Создать Issue
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="title">Заголовок Issue</param>
|
||||||
|
/// <param name="description">Описание (опционально)</param>
|
||||||
|
/// <param name="labels">Метки (опционально)</param>
|
||||||
|
/// <param name="assigneeId">ID назначаемого пользователя (опционально)</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Создать Issue")]
|
||||||
|
public async Task<string> 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<string, object?>
|
||||||
|
{
|
||||||
|
["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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Обновить Issue
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="issueIid">ID Issue</param>
|
||||||
|
/// <param name="subject">Новый заголовок (опционально)</param>
|
||||||
|
/// <param name="description">Новое описание (опционально)</param>
|
||||||
|
/// <param name="labels">Новые метки (опционально)</param>
|
||||||
|
/// <param name="state">Новое состояние (опционально)</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Обновить Issue")]
|
||||||
|
public async Task<string> 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<string, object?>();
|
||||||
|
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Закрыть Issue
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="issueIid">ID Issue</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Закрыть Issue")]
|
||||||
|
public async Task<string> 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Открыть Issue
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="issueIid">ID Issue</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Открыть Issue")]
|
||||||
|
public async Task<string> 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Получить замечания к Issue
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="issueIid">ID Issue</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Получить замечания к Issue")]
|
||||||
|
public async Task<string> 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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Добавить замечание к Issue
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="issueIid">ID Issue</param>
|
||||||
|
/// <param name="body">Текст замечания</param>
|
||||||
|
/// <param name="subject">Заголовок замечания (опционально)</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Добавить замечание к Issue")]
|
||||||
|
public async Task<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Удалить замечание из Issue
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="issueIid">ID Issue</param>
|
||||||
|
/// <param name="noteId">ID замечания</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Удалить замечание из Issue")]
|
||||||
|
public async Task<string> 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
607
LazyBear.MCP/Services/GitLab/GitLabMergeRequestTools.cs
Normal file
607
LazyBear.MCP/Services/GitLab/GitLabMergeRequestTools.cs
Normal file
@@ -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"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Получить список MR
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Получить список Merge Requests")]
|
||||||
|
public async Task<string> 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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Получить конкретный MR
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="mrIid">ID Merge Request</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Получить конкретный Merge Request")]
|
||||||
|
public async Task<string> 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Создать Merge Request
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="title">Заголовок MR</param>
|
||||||
|
/// <param name="sourceBranch">Имя ветки источника</param>
|
||||||
|
/// <param name="targetBranch">Имя целевой ветки</param>
|
||||||
|
/// <param name="description">Описание (опционально)</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Создать Merge Request")]
|
||||||
|
public async Task<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Закрыть MR
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="mrIid">ID Merge Request</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Закрыть Merge Request")]
|
||||||
|
public async Task<string> 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Открыть MR
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="mrIid">ID Merge Request</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Открыть Merge Request")]
|
||||||
|
public async Task<string> 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Получить замечания к MR
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="mrIid">ID Merge Request</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Получить замечания к Merge Request")]
|
||||||
|
public async Task<string> 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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Добавить замечание к MR
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="mrIid">ID Merge Request</param>
|
||||||
|
/// <param name="body">Текст замечания</param>
|
||||||
|
/// <param name="subject">Заголовок замечания (опционально)</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Добавить замечание к Merge Request")]
|
||||||
|
public async Task<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Удалить замечание из MR
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="mrIid">ID Merge Request</param>
|
||||||
|
/// <param name="noteId">ID замечания</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Удалить замечание из Merge Request")]
|
||||||
|
public async Task<string> 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
175
LazyBear.MCP/Services/GitLab/GitLabRepositoryTools.cs
Normal file
175
LazyBear.MCP/Services/GitLab/GitLabRepositoryTools.cs
Normal file
@@ -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<string> 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<string>();
|
||||||
|
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<string> 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
51
LazyBear.MCP/Services/GitLab/GitLabToolModule.cs
Normal file
51
LazyBear.MCP/Services/GitLab/GitLabToolModule.cs
Normal file
@@ -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<string> 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"
|
||||||
|
];
|
||||||
|
}
|
||||||
169
LazyBear.MCP/Services/GitLab/GitLabToolsBase.cs
Normal file
169
LazyBear.MCP/Services/GitLab/GitLabToolsBase.cs
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LazyBear.MCP.Services.ToolRegistry;
|
||||||
|
using RestSharp;
|
||||||
|
|
||||||
|
namespace LazyBear.MCP.Services.GitLab;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Базовый класс для всех инструментов GitLab
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ошибка инициализации клиента (если возникла)
|
||||||
|
/// </summary>
|
||||||
|
protected string? ClientInitializationError { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Конструктор
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="baseUrlConfig">Конфигурация URL</param>
|
||||||
|
/// <param name="token">API токен</param>
|
||||||
|
/// <param name="registry">Регистратор инструментов</param>
|
||||||
|
public GitLabToolsBase(
|
||||||
|
string baseUrlConfig,
|
||||||
|
string token,
|
||||||
|
ToolRegistryService registry)
|
||||||
|
{
|
||||||
|
_token = token;
|
||||||
|
_baseUrlConfig = baseUrlConfig;
|
||||||
|
_registry = registry;
|
||||||
|
|
||||||
|
// Инициализация клиента
|
||||||
|
_baseUrl = baseUrlConfig;
|
||||||
|
_client = new GitLabApiClient(baseUrlConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Проверка, активирован ли инструмент в TUI
|
||||||
|
/// </summary>
|
||||||
|
protected bool TryCheckEnabled(string toolName, out string error)
|
||||||
|
{
|
||||||
|
if (!_registry.IsToolEnabled("GitLab", toolName))
|
||||||
|
{
|
||||||
|
error = $"Инструмент '{toolName}' модуля GitLab отключён в TUI.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
error = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Получение клиента RestSharp
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Создание запроса к GitLab API
|
||||||
|
/// </summary>
|
||||||
|
protected RestRequest CreateRequest(string resource, RestSharp.Method method = RestSharp.Method.Get)
|
||||||
|
{
|
||||||
|
var request = _client.GetRequest(resource);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Форматирование ошибки ответа от GitLab API
|
||||||
|
/// </summary>
|
||||||
|
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}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Форматирование исключения
|
||||||
|
/// </summary>
|
||||||
|
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}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Получение вложенного строки из Json
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Экстракция текста из комментариев GitLab
|
||||||
|
/// </summary>
|
||||||
|
protected static string ExtractCommentText(JsonElement body)
|
||||||
|
{
|
||||||
|
var chunks = new List<string>();
|
||||||
|
CollectText(body, chunks);
|
||||||
|
return chunks.Count == 0 ? "-" : string.Join(" ", chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Рекурсивный сбор текста из JSON
|
||||||
|
/// </summary>
|
||||||
|
protected static void CollectText(JsonElement element, List<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
269
LazyBear.MCP/Services/GitLab/GitLabVersionTools.cs
Normal file
269
LazyBear.MCP/Services/GitLab/GitLabVersionTools.cs
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Получить список тегов
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Получить список тегов проекта")]
|
||||||
|
public async Task<string> 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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Создать тег
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="name">Имя тега</param>
|
||||||
|
/// <param name="description">Описание (опционально)</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Создать тег версии")]
|
||||||
|
public async Task<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Удалить тег
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="projectId">ID проекта</param>
|
||||||
|
/// <param name="tagName">Имя тега</param>
|
||||||
|
/// <param name="cancellationToken">Token отмены</param>
|
||||||
|
[McpServerTool, Description("Удалить тег версии")]
|
||||||
|
public async Task<string> 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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,6 +14,11 @@
|
|||||||
"Username": "",
|
"Username": "",
|
||||||
"SpaceKey": ""
|
"SpaceKey": ""
|
||||||
},
|
},
|
||||||
|
"GitLab": {
|
||||||
|
"Url": "",
|
||||||
|
"Token": "",
|
||||||
|
"Project": ""
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|||||||
164
README.md
164
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
**.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, комментариями | ✅ Доступно |
|
| 📋 **Jira** | Работа с задачами, JQL, комментариями | ✅ Доступно |
|
||||||
| 📄 **Confluence** | Работа со страницами и пространствами | ✅ Доступно |
|
| 📄 **Confluence** | Работа со страницами и пространствами | ✅ Доступно |
|
||||||
| ☸️ **Kubernetes** | Управление деплоями, подами, сетями | ✅ Доступно |
|
| ☸️ **Kubernetes** | Управление деплоями, подами, сетями | ✅ Доступно |
|
||||||
|
| 🌳 **GitLab** | Работа с репозиториями, MR, Issue, ветками, тегами | ✅ Доступно |
|
||||||
| 🖥️ **TUI** | Интерактивная консольная панель | ✅ Доступно |
|
| 🖥️ **TUI** | Интерактивная консольная панель | ✅ Доступно |
|
||||||
| 🌐 **Localization** | Многоязычный интерфейс (RU/EN) | ✅ Доступно |
|
| 🌐 **Localization** | Многоязычный интерфейс (RU/EN) | ✅ Доступно |
|
||||||
|
|
||||||
@@ -21,26 +22,29 @@
|
|||||||
## 🏗️ Архитектура
|
## 🏗️ Архитектура
|
||||||
|
|
||||||
```
|
```
|
||||||
┌───────────────────────────────────────────────────────────┐
|
┌───...──────────────────────────────────────────────────────┐
|
||||||
│ HTTP Transport Layer (ASP.NET Core) │
|
│ HTTP Transport Layer (ASP.NET Core) │
|
||||||
│ └── ModelContextProtocol 1.2.0 HTTP транспорт │
|
│ └── ModelContextProtocol 1.2.0 HTTP транспорт │
|
||||||
└───────────────────────────────────────────────────────────┘
|
└───...──────────────────────────────────────────────────────┘
|
||||||
↓
|
↓
|
||||||
┌───────────────────────────────────────────────────────────┐
|
┌───...──────────────────────────────────────────────────────┐
|
||||||
│ Application Layer (Razor Pages UI) │
|
│ Application Layer (Razor Pages UI) │
|
||||||
│ └── Web-страницы для мониторинга K8s │
|
│ └── Web-страницы для мониторинга K8s │
|
||||||
└───────────────────────────────────────────────────────────┘
|
└───...──────────────────────────────────────────────────────┘
|
||||||
↓
|
↓
|
||||||
┌───────────────────────────────────────────────────────────┐
|
┌───...──────────────────────────────────────────────────────┐
|
||||||
│ Kubernetes Layer (Kubernetes Client) │
|
│ Integration Layers │
|
||||||
│ └── Подключение к K8s API сервер через kubeconfig │
|
│ ├── Jira API (REST) │
|
||||||
└───────────────────────────────────────────────────────────┘
|
│ ├── Confluence API (REST) │
|
||||||
|
│ ├── Kubernetes API (REST) │
|
||||||
|
│ └── GitLab API (REST) │
|
||||||
|
└───...──────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**Потоки данных:**
|
**Потоки данных:**
|
||||||
|
|
||||||
1. **Initialize Flow**: Клиент → HTTP → Application → K8s Client → K8s API
|
1. **Initialize Flow**: Клиент → HTTP → Application → Integration Client → API
|
||||||
2. **Tools Flow**: Клиент → RPC → Tools → [Jira/Confluence/K8s] → Возврат результата
|
2. **Tools Flow**: Клиент → RPC → Tools → [Jira/Confluence/K8s/GitLab] → Возврат результата
|
||||||
3. **Health Check Flow**: `/health` → HTTP → Liveness probe → Status
|
3. **Health Check Flow**: `/health` → HTTP → Liveness probe → Status
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -51,6 +55,7 @@
|
|||||||
|
|
||||||
- .NET 10 SDK
|
- .NET 10 SDK
|
||||||
- Kubectl и kubeconfig
|
- Kubectl и kubeconfig
|
||||||
|
- GitLab Personal Access Token (опционально)
|
||||||
- Docker Desktop (опционально)
|
- Docker Desktop (опционально)
|
||||||
|
|
||||||
### Запуск
|
### Запуск
|
||||||
@@ -125,11 +130,7 @@ docker run -p 5000:5000 -v $HOME/.kube:/root/.kube:ro lazybear-mcp
|
|||||||
"params": {
|
"params": {
|
||||||
"spaceKey": "LAZYBEAR",
|
"spaceKey": "LAZYBEAR",
|
||||||
"title": "Инструкция по развёртыванию",
|
"title": "Инструкция по развёртыванию",
|
||||||
"body": "# Инструкция по развёртыванию\n\nШаг 1: Клонируйте репозиторий...",
|
"body": "# Инструкция по развёртыванию\n\nШаг 1: Клонируйте репозиторий.
|
||||||
"parentPageId": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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
|
```json
|
||||||
{
|
{
|
||||||
"method": "k8sNetworkTools/createService",
|
"method": "gitlabTools/list_projects",
|
||||||
|
"params": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "gitlabTools/create_merge_request",
|
||||||
"params": {
|
"params": {
|
||||||
"name": "nginx-svc",
|
"sourceBranch": "feature-xyz",
|
||||||
"type": "ClusterIP",
|
"targetBranch": "main",
|
||||||
"port": 80
|
"title": "Add new feature",
|
||||||
|
"description": "Implements new feature xyz"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"method": "k8sPodsTools/getPodStatus",
|
"method": "gitlabTools/create_issue",
|
||||||
"params": { "name": "nginx-pod-abc123" }
|
"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
|
│ │ └── JiraIssueTools.cs # Инструменты для Jira
|
||||||
│ ├── Confluence/
|
│ ├── Confluence/
|
||||||
│ │ └── ConfluencePagesTools.cs # Инструменты для Confluence
|
│ │ └── ConfluencePagesTools.cs # Инструменты для Confluence
|
||||||
│ └── Kubernetes/
|
│ ├── Kubernetes/
|
||||||
│ ├── K8sConfigTools.cs # Инструменты конфигурации
|
│ │ ├── K8sConfigTools.cs # Инструменты конфигурации
|
||||||
│ ├── K8sDeploymentTools.cs # Инструменты деплоя
|
│ │ ├── K8sDeploymentTools.cs # Инструменты деплоя
|
||||||
│ ├── K8sNetworkTools.cs # Инструменты сети
|
│ │ ├── K8sNetworkTools.cs # Инструменты сети
|
||||||
│ ├── K8sPodsTools.cs # Инструменты подов
|
│ │ ├── K8sPodsTools.cs # Инструменты подов
|
||||||
│ ├── K8sClientFactory.cs # Factory для клиентов
|
│ │ ├── K8sClientFactory.cs # Factory для клиентов
|
||||||
│ └── K8sClientProvider.cs # Provider для клиентов
|
│ │ └── 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 # Конфиг
|
├── appsettings.json # Конфиг
|
||||||
└── global.json # Пин SDK
|
└── global.json # Пин SDK
|
||||||
```
|
```
|
||||||
@@ -225,13 +299,13 @@ LazyBear.MCP/
|
|||||||
## 🖥️ Интерактивная панель
|
## 🖥️ Интерактивная панель
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────┐
|
┌─...──────────────────────────────────────────────┐
|
||||||
│ Dashboard: Обзор состояния кластера │
|
│ Dashboard: Обзор состояния кластера │
|
||||||
├─────────────────────────────────────────┤
|
├─...──────────────────────────────────────────────┤
|
||||||
│ Logs & Events: Журналы событий │
|
│ Logs & Events: Журналы событий │
|
||||||
│ Containers & Images: Контейнеры │
|
│ Containers & Images: Контейнеры │
|
||||||
│ Workloads & Nodes: Распределение │
|
│ Workloads & Nodes: Распределение │
|
||||||
└─────────────────────────────────────────┘
|
└─...──────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**Настройка в appsettings.json:**
|
**Настройка в appsettings.json:**
|
||||||
@@ -242,6 +316,20 @@ LazyBear.MCP/
|
|||||||
"KubeconfigPath": "~/.kube/config",
|
"KubeconfigPath": "~/.kube/config",
|
||||||
"DefaultNamespace": "default"
|
"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": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
@@ -349,6 +437,7 @@ npx @modelcontextprotocol/inspector dotnet run --project LazyBear.MCP
|
|||||||
- **UI:** Razor Pages
|
- **UI:** Razor Pages
|
||||||
- **DB:** SQLite/SQL Server
|
- **DB:** SQLite/SQL Server
|
||||||
- **Protocol:** Model Context Protocol (MCP)
|
- **Protocol:** Model Context Protocol (MCP)
|
||||||
|
- **API Clients:** RestSharp (для Jira, Confluence, GitLab), Kubernetes Client (для K8s)
|
||||||
|
|
||||||
**Документация:**
|
**Документация:**
|
||||||
- **Сгенерированный API**: `/swagger` — Swagger UI
|
- **Сгенерированный API**: `/swagger` — Swagger UI
|
||||||
@@ -368,3 +457,14 @@ var config = new OpenApiInfo { Title = "LazyBear MCP Server", Version = "1.0.0"
|
|||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1", config));
|
builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1", config));
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Ссылки
|
||||||
|
|
||||||
|
- [GitLab API Documentation](https://docs.gitlab.com/ee/api/)
|
||||||
|
- [MCP Specification](https://modelcontextprotocol.io)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Встроенная документация по MCP*
|
||||||
214
memory-bank/implementation_plan.md
Normal file
214
memory-bank/implementation_plan.md
Normal file
@@ -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<GitLabToolsBase>? 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. Тестирование и отладка
|
||||||
@@ -27,6 +27,29 @@
|
|||||||
- ✅ Restart Pods
|
- ✅ Restart Pods
|
||||||
- ✅ Описание Deployments/Pods/Services
|
- ✅ Описание 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
|
### MCP Server
|
||||||
|
|
||||||
- ✅ HTTP Transport MCP 1.2.0
|
- ✅ HTTP Transport MCP 1.2.0
|
||||||
@@ -52,7 +75,7 @@
|
|||||||
### Known Issues
|
### Known Issues
|
||||||
|
|
||||||
| Проблема | Описание | Приоритет |
|
| Проблема | Описание | Приоритет |
|
||||||
|----------|---------|----|-|
|
|-----|------|-|
|
||||||
| Jira:Url config | Требуется настройка в appsettings.json | Medium |
|
| Jira:Url config | Требуется настройка в appsettings.json | Medium |
|
||||||
| Kubernetes:KubeconfigPath | Может быть пустым (fallback) | Low |
|
| Kubernetes:KubeconfigPath | Может быть пустым (fallback) | Low |
|
||||||
| RazorPages не активен | `Pages/` существует, но не используется | Info |
|
| RazorPages не активен | `Pages/` существует, но не используется | Info |
|
||||||
@@ -67,7 +90,7 @@
|
|||||||
|
|
||||||
### Upcoming Tasks
|
### Upcoming Tasks
|
||||||
|
|
||||||
1. **Конфигурация**: Настроить Jira/K8s connection в appsettings.json
|
1. **Конфигурация**: Настроить Jira/K8s/GitLab connection в appsettings.json
|
||||||
2. **Тестирование**: Написать CLI тесты для инструментов
|
2. **Тестирование**: Написать CLI тесты для инструментов
|
||||||
3. **Документация**: Добавить секции в systemPatterns.md при необходимости
|
3. **Документация**: Добавить секции в systemPatterns.md при необходимости
|
||||||
4. **Monitoring**: Добавить metrics endpoint для Prometheus
|
4. **Monitoring**: Добавить metrics endpoint для Prometheus
|
||||||
@@ -93,6 +116,11 @@
|
|||||||
- `Confluence:Token` — API token для авторизации
|
- `Confluence:Token` — API token для авторизации
|
||||||
- `Confluence:SpaceKey` — для некоторых операций требуется space
|
- `Confluence:SpaceKey` — для некоторых операций требуется space
|
||||||
|
|
||||||
|
### GitLab Integration
|
||||||
|
|
||||||
|
- `GitLab:Url` — обязателен в appsettings.json
|
||||||
|
- `GitLab:Token` — PAT token для авторизации
|
||||||
|
|
||||||
### TUI
|
### TUI
|
||||||
|
|
||||||
- TUI запускается первым и владеет консолью
|
- TUI запускается первым и владеет консолью
|
||||||
@@ -108,9 +136,10 @@
|
|||||||
## 📈 Метрики
|
## 📈 Метрики
|
||||||
|
|
||||||
| Метрика | Значение | Цель |
|
| Метрика | Значение | Цель |
|
||||||
|---------|---------|-----|-|
|
|-----|------|-|
|
||||||
| Jira задачи создано | TBD | 100+/день |
|
| Jira задачи создано | TBD | 100+/день |
|
||||||
| K8s операции выполнено | TBD | 50+/день |
|
| K8s операции выполнено | TBD | 50+/день |
|
||||||
|
| GitLab операции выполнено | TBD | 50+/день |
|
||||||
| Incidents resolved | TBD | Минимизировать |
|
| Incidents resolved | TBD | Минимизировать |
|
||||||
| User satisfaction | TBD | >4.5/5 |
|
| User satisfaction | TBD | >4.5/5 |
|
||||||
|
|
||||||
@@ -118,7 +147,7 @@
|
|||||||
|
|
||||||
### Версия 1.0.0 (текущая)
|
### Версия 1.0.0 (текущая)
|
||||||
|
|
||||||
- Полный стек инструментов Jira/Confluence/K8s
|
- Полный стек инструментов Jira/Confluence/K8s/GitLab
|
||||||
- TUI мониторинг
|
- TUI мониторинг
|
||||||
- HTTP MCP transport
|
- HTTP MCP transport
|
||||||
- Локализация RU/EN
|
- Локализация RU/EN
|
||||||
@@ -139,12 +168,12 @@
|
|||||||
|
|
||||||
**Состояние**: Development
|
**Состояние**: Development
|
||||||
|
|
||||||
**Последний commit**: `d12e9873f0964f2c275a634cda80b161c83f9bbb`
|
**Последний commit**: `e96bab114ea1a58f3ea7bd5ab40d4645d456cd8f`
|
||||||
|
|
||||||
**Что работает**: Все основные функциональности готовы
|
**Что работает**: Все основные функциональности готовы
|
||||||
|
|
||||||
**Что делать дальше**:
|
**Что делать дальше**:
|
||||||
1. Настроить Jira/K8s connection (appsettings.json)
|
1. Настроить Jira/K8s/GitLab connection (appsettings.json)
|
||||||
2. Тестировать через MCP Inspector
|
2. Тестировать через MCP Inspector
|
||||||
3. Обновлять Memory Bank при значимых изменениях
|
3. Обновлять Memory Bank при значимых изменениях
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user