Files
LazyBearWorks/LazyBear.MCP/Services/GitLab/GitLabIssueTools.cs

753 lines
30 KiB
C#

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}");
}
}
}