Compare commits
3 Commits
bf995b162e
...
f3964075cc
| Author | SHA1 | Date | |
|---|---|---|---|
| f3964075cc | |||
| d75a08e7d7 | |||
| 305496eb11 |
60
AGENTS.md
60
AGENTS.md
@@ -14,6 +14,7 @@ PRIORITY: user > this_file > code_style
|
|||||||
|
|
||||||
## [B] КОММУНИКАЦИЯ — ALWAYS
|
## [B] КОММУНИКАЦИЯ — ALWAYS
|
||||||
|
|
||||||
|
LANG: thinking=en | output=ru | code+comments+commits=ru
|
||||||
DO: act_first → confirm_if_needed | short_and_precise | sequential_questions
|
DO: act_first → confirm_if_needed | short_and_precise | sequential_questions
|
||||||
DONT: end_with_question_only | print_secret_values | commit_.env.local
|
DONT: end_with_question_only | print_secret_values | commit_.env.local
|
||||||
SECRETS: use .env.local if exists; else state_once + ref .env.example
|
SECRETS: use .env.local if exists; else state_once + ref .env.example
|
||||||
@@ -25,7 +26,7 @@ RULES_EDIT: minimal + non_duplicative
|
|||||||
## [C] MEMORY LOG — ALWAYS
|
## [C] MEMORY LOG — ALWAYS
|
||||||
|
|
||||||
PREFIX: lazybear/
|
PREFIX: lazybear/
|
||||||
KEY: lazybear/<type>/<name> (ex: lazybear/bug/auth-fail)
|
KEY: lazybear/<type>/<n> (ex: lazybear/bug/auth-fail)
|
||||||
TYPES: architecture | mcp_tool | decision | bug | config | task_log
|
TYPES: architecture | mcp_tool | decision | bug | config | task_log
|
||||||
ONE_TYPE_PER_ENTITY: true
|
ONE_TYPE_PER_ENTITY: true
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ ON significant_change → create_entities OR add_observations
|
|||||||
|
|
||||||
## [D] INTERACTION — только при вопросе пользователю
|
## [D] INTERACTION — только при вопросе пользователю
|
||||||
|
|
||||||
### Формат
|
### Формат вопроса
|
||||||
|
|
||||||
```
|
```
|
||||||
<инструкция ≤15 слов>
|
<инструкция ≤15 слов>
|
||||||
@@ -46,37 +47,18 @@ ON significant_change → create_entities OR add_observations
|
|||||||
```
|
```
|
||||||
|
|
||||||
RULES: первый_символ=инструкция | max_options=7 | blank_line_before_options
|
RULES: первый_символ=инструкция | max_options=7 | blank_line_before_options
|
||||||
FORBIDDEN: текст до/после блока | метки ("Выберите:") | пояснения | статусы
|
FORBIDDEN: текст до/после блока | метки ("Выберите:") | пояснения
|
||||||
|
|
||||||
### Ввод
|
### Ввод — принимать любой
|
||||||
|
|
||||||
VALID:
|
Принимать как валидный:
|
||||||
N → single select
|
- N или N,N → выбор по номеру
|
||||||
N,N → multi (no spaces)
|
- любой текст → интерпретировать по смыслу и продолжать выполнение
|
||||||
abort|cancel → FLOW_CANCEL
|
- abort|cancel → остановиться
|
||||||
<text> → pass_to_system as-is
|
|
||||||
|
|
||||||
INVALID → reject+repeat: "вариант 2" | "1, 3" | "" | любая фраза вместо числа
|
НЕ блокироваться на невалидном вводе — интерпретировать намерение и продолжать.
|
||||||
|
|
||||||
### Поток
|
### После получения ответа
|
||||||
|
|
||||||
ONE_Q_PER_STEP: true
|
|
||||||
NO_SKIP: true
|
|
||||||
NO_STATE_BETWEEN_SESSIONS: true
|
|
||||||
|
|
||||||
CONFIRMATION (обязательный финальный шаг):
|
|
||||||
```
|
|
||||||
Итог:
|
|
||||||
- <поле>: <значение>
|
|
||||||
|
|
||||||
1) Подтвердить
|
|
||||||
2) Начать заново
|
|
||||||
3) Изменить шаг
|
|
||||||
```
|
|
||||||
|
|
||||||
COMPACT_MODE: `key=N key=N` → apply valid fields, skip their steps → go to CONFIRMATION
|
|
||||||
|
|
||||||
### После валидного ввода
|
|
||||||
|
|
||||||
EXECUTE: silent
|
EXECUTE: silent
|
||||||
NEXT_OUTPUT: result XOR next_question_block
|
NEXT_OUTPUT: result XOR next_question_block
|
||||||
@@ -86,25 +68,12 @@ NEXT_OUTPUT: result XOR next_question_block
|
|||||||
✓ [молча] → результат или следующий вопрос
|
✓ [молча] → результат или следующий вопрос
|
||||||
```
|
```
|
||||||
|
|
||||||
### Валидация
|
|
||||||
|
|
||||||
MATCH: exact_only | no_autocorrect | no_implicit_defaults
|
|
||||||
|
|
||||||
RETRY:
|
|
||||||
1 → error + repeat
|
|
||||||
2 → error + emphasize format (1 / 2 / 1,3)
|
|
||||||
3 → minimal hint
|
|
||||||
4 → STEP_ABORT
|
|
||||||
|
|
||||||
ABORT:
|
|
||||||
invalid×4 → STEP_ABORT (emit code only, no text)
|
|
||||||
abort|cancel → FLOW_CANCEL (emit code only, no text)
|
|
||||||
|
|
||||||
### Запрещённые шаблоны
|
### Запрещённые шаблоны
|
||||||
|
|
||||||
```
|
```
|
||||||
✗ "Если хочешь, могу сделать коммит"
|
✗ "Если хочешь, могу сделать коммит"
|
||||||
✗ "Дай знать если продолжить"
|
✗ "Дай знать если продолжить"
|
||||||
|
✗ вопросы в конце ответа без блока выбора
|
||||||
```
|
```
|
||||||
|
|
||||||
RULE: action available → always show choice block:
|
RULE: action available → always show choice block:
|
||||||
@@ -116,8 +85,3 @@ RULE: action available → always show choice block:
|
|||||||
```
|
```
|
||||||
|
|
||||||
OPTIONS: exhaustive | no "Другое" | freetext implicit (не предлагать как вариант)
|
OPTIONS: exhaustive | no "Другое" | freetext implicit (не предлагать как вариант)
|
||||||
|
|
||||||
### Приоритеты
|
|
||||||
|
|
||||||
PRIORITY: safety(absolute) > determinism > structure > format > convenience
|
|
||||||
DETERMINISM: same_input→same_output | option_order=fixed | option_text=never_rephrase
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.AI" Version="10.4.1" />
|
<PackageReference Include="Microsoft.Extensions.AI" Version="10.4.1" />
|
||||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
||||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
||||||
|
<PackageReference Include="RestSharp" Version="112.0.0" />
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
using LazyBear.MCP.Services.Jira;
|
||||||
using LazyBear.MCP.Services.Kubernetes;
|
using LazyBear.MCP.Services.Kubernetes;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
builder.Services.AddSingleton<K8sClientProvider>();
|
builder.Services.AddSingleton<K8sClientProvider>();
|
||||||
|
builder.Services.AddSingleton<JiraClientProvider>();
|
||||||
|
|
||||||
builder.Services.AddMcpServer()
|
builder.Services.AddMcpServer()
|
||||||
.WithHttpTransport()
|
.WithHttpTransport()
|
||||||
|
|||||||
25
LazyBear.MCP/Services/Jira/JiraClientFactory.cs
Normal file
25
LazyBear.MCP/Services/Jira/JiraClientFactory.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using RestSharp;
|
||||||
|
|
||||||
|
namespace LazyBear.MCP.Services.Jira;
|
||||||
|
|
||||||
|
public static class JiraClientFactory
|
||||||
|
{
|
||||||
|
public static RestClient CreateClient(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var jiraUrl = configuration["Jira:Url"] ?? "";
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(jiraUrl))
|
||||||
|
{
|
||||||
|
throw new Exception("Jira:Url не задан");
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = new RestClientOptions(jiraUrl)
|
||||||
|
{
|
||||||
|
UserAgent = "LazyBear-Jira-MCP",
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(30000)
|
||||||
|
};
|
||||||
|
|
||||||
|
return new RestClient(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
LazyBear.MCP/Services/Jira/JiraClientProvider.cs
Normal file
23
LazyBear.MCP/Services/Jira/JiraClientProvider.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using RestSharp;
|
||||||
|
|
||||||
|
namespace LazyBear.MCP.Services.Jira;
|
||||||
|
|
||||||
|
public sealed class JiraClientProvider
|
||||||
|
{
|
||||||
|
public RestClient? Client { get; }
|
||||||
|
|
||||||
|
public string? InitializationError { get; }
|
||||||
|
|
||||||
|
public JiraClientProvider(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Client = JiraClientFactory.CreateClient(configuration);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
InitializationError = $"{ex.GetType().Name}: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
508
LazyBear.MCP/Services/Jira/JiraIssueTools.cs
Normal file
508
LazyBear.MCP/Services/Jira/JiraIssueTools.cs
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using RestSharp;
|
||||||
|
|
||||||
|
namespace LazyBear.MCP.Services.Jira;
|
||||||
|
|
||||||
|
[McpServerToolType]
|
||||||
|
public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
private readonly RestClient? _client = provider.Client;
|
||||||
|
private readonly string? _clientInitializationError = provider.InitializationError;
|
||||||
|
private readonly string _token = configuration["Jira:Token"] ?? string.Empty;
|
||||||
|
private readonly string _defaultProject = configuration["Jira:Project"] ?? string.Empty;
|
||||||
|
|
||||||
|
[McpServerTool, Description("Получить задачу Jira по ключу")]
|
||||||
|
public async Task<string> GetIssue(
|
||||||
|
[Description("Ключ задачи, например PROJ-123")] string issueKey,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(issueKey))
|
||||||
|
{
|
||||||
|
return "Ключ задачи Jira не задан.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryGetClient(out var client, out var error))
|
||||||
|
{
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = CreateRequest($"/rest/api/3/issue/{issueKey}");
|
||||||
|
var response = await client.ExecuteAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
|
||||||
|
{
|
||||||
|
return FormatResponseError("get_issue", response, issueKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(response.Content);
|
||||||
|
var root = document.RootElement;
|
||||||
|
|
||||||
|
var summary = GetNestedString(root, "fields", "summary") ?? "-";
|
||||||
|
var status = GetNestedString(root, "fields", "status", "name") ?? "-";
|
||||||
|
var issueType = GetNestedString(root, "fields", "issuetype", "name") ?? "-";
|
||||||
|
var assignee = GetNestedString(root, "fields", "assignee", "displayName") ?? "unassigned";
|
||||||
|
|
||||||
|
return $"Задача '{issueKey}': summary={summary}, status={status}, type={issueType}, assignee={assignee}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatException("get_issue", ex, issueKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Список задач Jira по JQL")]
|
||||||
|
public async Task<string> ListIssues(
|
||||||
|
[Description("JQL запрос. Если пусто, используется проект по умолчанию")] string? jql = null,
|
||||||
|
[Description("Максимум задач в ответе")] int maxResults = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!TryGetClient(out var client, out var error))
|
||||||
|
{
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedJql = ResolveJql(jql);
|
||||||
|
if (string.IsNullOrWhiteSpace(resolvedJql))
|
||||||
|
{
|
||||||
|
return "JQL не задан и Jira:Project не настроен.";
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = CreateRequest("/rest/api/3/search/jql");
|
||||||
|
request.AddQueryParameter("jql", resolvedJql);
|
||||||
|
request.AddQueryParameter("maxResults", Math.Max(1, maxResults).ToString());
|
||||||
|
request.AddQueryParameter("fields", "summary,status,issuetype");
|
||||||
|
|
||||||
|
var response = await client.ExecuteAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
|
||||||
|
{
|
||||||
|
return FormatResponseError("list_issues", response, resolvedJql);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(response.Content);
|
||||||
|
var root = document.RootElement;
|
||||||
|
|
||||||
|
if (!root.TryGetProperty("issues", out var issuesElement) || issuesElement.GetArrayLength() == 0)
|
||||||
|
{
|
||||||
|
return "Задачи Jira не найдены.";
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = new List<string>();
|
||||||
|
foreach (var issue in issuesElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
var key = issue.TryGetProperty("key", out var keyElement) ? keyElement.GetString() ?? "unknown" : "unknown";
|
||||||
|
var summary = GetNestedString(issue, "fields", "summary") ?? "-";
|
||||||
|
var status = GetNestedString(issue, "fields", "status", "name") ?? "-";
|
||||||
|
lines.Add($"{key}: {summary} [{status}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"Задачи Jira по JQL '{resolvedJql}':\n{string.Join('\n', lines)}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatException("list_issues", ex, resolvedJql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Создать задачу Jira")]
|
||||||
|
public async Task<string> CreateIssue(
|
||||||
|
[Description("Summary задачи")] string summary,
|
||||||
|
[Description("Ключ проекта. Если пусто, используется Jira:Project")] string? projectKey = null,
|
||||||
|
[Description("Тип задачи, например Task или Bug")] string issueType = "Task",
|
||||||
|
[Description("Описание задачи")] string? description = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!TryGetClient(out var client, out var error))
|
||||||
|
{
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedProject = string.IsNullOrWhiteSpace(projectKey) ? _defaultProject : projectKey;
|
||||||
|
if (string.IsNullOrWhiteSpace(resolvedProject))
|
||||||
|
{
|
||||||
|
return "Проект Jira не задан. Укажите projectKey или настройте Jira:Project.";
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = CreateRequest("/rest/api/3/issue", Method.Post);
|
||||||
|
request.AddJsonBody(new
|
||||||
|
{
|
||||||
|
fields = new
|
||||||
|
{
|
||||||
|
project = new { key = resolvedProject },
|
||||||
|
summary,
|
||||||
|
issuetype = new { name = issueType },
|
||||||
|
description = string.IsNullOrWhiteSpace(description)
|
||||||
|
? null
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
type = "doc",
|
||||||
|
version = 1,
|
||||||
|
content = new object[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
type = "paragraph",
|
||||||
|
content = new object[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
type = "text",
|
||||||
|
text = description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.ExecuteAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
|
||||||
|
{
|
||||||
|
return FormatResponseError("create_issue", response, resolvedProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(response.Content);
|
||||||
|
var root = document.RootElement;
|
||||||
|
var createdKey = root.TryGetProperty("key", out var keyElement) ? keyElement.GetString() ?? "-" : "-";
|
||||||
|
|
||||||
|
return $"Задача Jira создана: {createdKey}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatException("create_issue", ex, resolvedProject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Обновить summary или описание задачи Jira")]
|
||||||
|
public async Task<string> UpdateIssue(
|
||||||
|
[Description("Ключ задачи")] string issueKey,
|
||||||
|
[Description("Новый summary")] string? summary = null,
|
||||||
|
[Description("Новое описание")] string? description = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(summary) && string.IsNullOrWhiteSpace(description))
|
||||||
|
{
|
||||||
|
return $"Нет полей для обновления задачи '{issueKey}'.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryGetClient(out var client, out var error))
|
||||||
|
{
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fields = new Dictionary<string, object?>();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(summary))
|
||||||
|
{
|
||||||
|
fields["summary"] = summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(description))
|
||||||
|
{
|
||||||
|
fields["description"] = new
|
||||||
|
{
|
||||||
|
type = "doc",
|
||||||
|
version = 1,
|
||||||
|
content = new object[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
type = "paragraph",
|
||||||
|
content = new object[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
type = "text",
|
||||||
|
text = description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = CreateRequest($"/rest/api/3/issue/{issueKey}", Method.Put);
|
||||||
|
request.AddJsonBody(new { fields });
|
||||||
|
|
||||||
|
var response = await client.ExecuteAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessful)
|
||||||
|
{
|
||||||
|
return FormatResponseError("update_issue", response, issueKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"Задача Jira '{issueKey}' обновлена.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatException("update_issue", ex, issueKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Доступные переходы статуса для задачи Jira")]
|
||||||
|
public async Task<string> GetIssueStatuses(
|
||||||
|
[Description("Ключ задачи")] string issueKey,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!TryGetClient(out var client, out var error))
|
||||||
|
{
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = CreateRequest($"/rest/api/3/issue/{issueKey}/transitions");
|
||||||
|
var response = await client.ExecuteAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
|
||||||
|
{
|
||||||
|
return FormatResponseError("get_issue_statuses", response, issueKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(response.Content);
|
||||||
|
var root = document.RootElement;
|
||||||
|
|
||||||
|
if (!root.TryGetProperty("transitions", out var transitionsElement) || transitionsElement.GetArrayLength() == 0)
|
||||||
|
{
|
||||||
|
return $"Для задачи '{issueKey}' нет доступных переходов.";
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = new List<string>();
|
||||||
|
foreach (var transition in transitionsElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
var name = transition.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? "-" : "-";
|
||||||
|
var targetStatus = GetNestedString(transition, "to", "name") ?? "-";
|
||||||
|
lines.Add($"{name} -> {targetStatus}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"Переходы статуса для '{issueKey}':\n{string.Join('\n', lines)}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatException("get_issue_statuses", ex, issueKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Список комментариев задачи Jira")]
|
||||||
|
public async Task<string> ListIssueComments(
|
||||||
|
[Description("Ключ задачи")] string issueKey,
|
||||||
|
[Description("Максимум комментариев")] int limit = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!TryGetClient(out var client, out var error))
|
||||||
|
{
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = CreateRequest($"/rest/api/3/issue/{issueKey}/comment");
|
||||||
|
request.AddQueryParameter("maxResults", Math.Max(1, limit).ToString());
|
||||||
|
|
||||||
|
var response = await client.ExecuteAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
|
||||||
|
{
|
||||||
|
return FormatResponseError("list_issue_comments", response, issueKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(response.Content);
|
||||||
|
var root = document.RootElement;
|
||||||
|
|
||||||
|
if (!root.TryGetProperty("comments", out var commentsElement) || commentsElement.GetArrayLength() == 0)
|
||||||
|
{
|
||||||
|
return $"Комментарии к задаче '{issueKey}' отсутствуют.";
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = new List<string>();
|
||||||
|
foreach (var comment in commentsElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
var id = comment.TryGetProperty("id", out var idElement) ? idElement.GetString() ?? "-" : "-";
|
||||||
|
var author = GetNestedString(comment, "author", "displayName") ?? "unknown";
|
||||||
|
var text = ExtractCommentText(comment.GetProperty("body"));
|
||||||
|
lines.Add($"{id}: {author} -> {text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"Комментарии задачи '{issueKey}':\n{string.Join('\n', lines)}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatException("list_issue_comments", ex, issueKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Добавить комментарий к задаче Jira")]
|
||||||
|
public async Task<string> AddComment(
|
||||||
|
[Description("Ключ задачи")] string issueKey,
|
||||||
|
[Description("Текст комментария")] string body,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(body))
|
||||||
|
{
|
||||||
|
return "Текст комментария Jira не задан.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryGetClient(out var client, out var error))
|
||||||
|
{
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = CreateRequest($"/rest/api/3/issue/{issueKey}/comment", Method.Post);
|
||||||
|
request.AddJsonBody(new
|
||||||
|
{
|
||||||
|
body = new
|
||||||
|
{
|
||||||
|
type = "doc",
|
||||||
|
version = 1,
|
||||||
|
content = new object[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
type = "paragraph",
|
||||||
|
content = new object[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
type = "text",
|
||||||
|
text = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.ExecuteAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
|
||||||
|
{
|
||||||
|
return FormatResponseError("add_comment", response, issueKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(response.Content);
|
||||||
|
var root = document.RootElement;
|
||||||
|
var commentId = root.TryGetProperty("id", out var idElement) ? idElement.GetString() ?? "-" : "-";
|
||||||
|
|
||||||
|
return $"Комментарий добавлен к задаче '{issueKey}', id={commentId}.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatException("add_comment", ex, issueKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ResolveJql(string? jql)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(jql))
|
||||||
|
{
|
||||||
|
return jql;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(_defaultProject) ? null : $"project = {_defaultProject} ORDER BY updated DESC";
|
||||||
|
}
|
||||||
|
|
||||||
|
private RestRequest CreateRequest(string resource, Method method = Method.Get)
|
||||||
|
{
|
||||||
|
var request = new RestRequest(resource, method);
|
||||||
|
request.AddHeader("Accept", "application/json");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_token))
|
||||||
|
{
|
||||||
|
request.AddHeader("Authorization", $"Bearer {_token}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetClient(out RestClient client, out string error)
|
||||||
|
{
|
||||||
|
if (_client is null)
|
||||||
|
{
|
||||||
|
client = null!;
|
||||||
|
var details = string.IsNullOrWhiteSpace(_clientInitializationError)
|
||||||
|
? string.Empty
|
||||||
|
: $" Детали: {_clientInitializationError}";
|
||||||
|
error = "Jira клиент не инициализирован. Проверьте Jira:Url." + details;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
client = _client;
|
||||||
|
error = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ExtractCommentText(JsonElement body)
|
||||||
|
{
|
||||||
|
var chunks = new List<string>();
|
||||||
|
CollectText(body, chunks);
|
||||||
|
return chunks.Count == 0 ? "-" : string.Join(" ", chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CollectText(JsonElement element, List<string> chunks)
|
||||||
|
{
|
||||||
|
if (element.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
if (element.TryGetProperty("text", out var textElement) && 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static 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 $"Ошибка Jira в tool '{toolName}'{resourcePart}: status={(int)response.StatusCode}, details={body}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatException(string toolName, Exception exception, string? resource = null)
|
||||||
|
{
|
||||||
|
var resourcePart = string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'";
|
||||||
|
return $"Ошибка Jira в tool '{toolName}'{resourcePart}: {exception.GetType().Name}: {exception.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,11 @@
|
|||||||
"KubeconfigPath": "",
|
"KubeconfigPath": "",
|
||||||
"DefaultNamespace": "default"
|
"DefaultNamespace": "default"
|
||||||
},
|
},
|
||||||
|
"Jira": {
|
||||||
|
"Url": "",
|
||||||
|
"Token": "",
|
||||||
|
"Project": ""
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|||||||
BIN
logo_v2.jpg
Normal file
BIN
logo_v2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
Reference in New Issue
Block a user