feat: добавить поддержку GitLab (api, clients, tools) и обновить документацию
This commit is contained in:
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
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user