feat(mcp): инструмент comment — комментарий задачи целиком (#80)

issue показывает у каждого из 5 последних комментариев номер «#<comment_id>»
(текст по-прежнему до 300 символов); comment(key, comment_id) отдаёт один
комментарий целиком — автор, создан/изменён, полный текст с переносами строк.
Данные — из GET /issues/{key}, новый маршрут API не нужен. Спека mcp и README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 13:18:59 +04:00
parent 4bd8a1f988
commit 352d8bba5e
4 changed files with 71 additions and 9 deletions

View File

@ -68,17 +68,18 @@ claude mcp add -s user jira-hub -- uv run --project /Users/andrey.epifancev/proj
Без привязки (user scope, без `--project`) работают только служебные инструменты (`projects`, `project_create`, Без привязки (user scope, без `--project`) работают только служебные инструменты (`projects`, `project_create`,
`agent_status`, `sync_log`, `settings_get`, `settings_set`, `batches`, `batch_status`, `batch_wait`, `batch_cancel`); `agent_status`, `sync_log`, `settings_get`, `settings_set`, `batches`, `batch_status`, `batch_wait`, `batch_cancel`);
`issues`, `issue`, `notes_set`, `batch_create`, `command_retry` и `refresh` `issues`, `issue`, `comment`, `notes_set`, `batch_create`, `command_retry` и `refresh`
отвечают «сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)». Метка, которой нет в отвечают «сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)». Метка, которой нет в
хабе, — «проект <метка> не заведён в хабе: project_create или исправьте --project в .mcp.json». В привязанной сессии хабе, — «проект <метка> не заведён в хабе: project_create или исправьте --project в .mcp.json». В привязанной сессии
`issues`, `batches` и `notes_set` видят только свой проект (`notes_set` — свои задачи и временные ключи `new:<n>`), `issues`, `batches` и `notes_set` видят только свой проект (`notes_set` — свои задачи и временные ключи `new:<n>`),
`issue` открывает любую задачу по ключу, пакеты и команды ставятся с проектом: хаб сам дописывает метку проекта в `issue` и `comment` открывают любую задачу по ключу, пакеты и команды ставятся с проектом: хаб сам дописывает метку проекта в
`ensure_issue` и отвергает чужую метку. `ensure_issue` и отвергает чужую метку.
| Инструмент | Параметры | Результат | | Инструмент | Параметры | Результат |
|---|---|---| |---|---|---|
| `issues` | фильтры как у `GET /issues`, `limit` | таблица задач проекта сессии | | `issues` | фильтры как у `GET /issues`, `limit` | таблица задач проекта сессии |
| `issue` | `key` | поля, связи, заметки, последние команды, комментарии, 5 последних изменений («изменения (N)») | | `issue` | `key` | поля, связи, заметки, последние команды, 5 последних комментариев (номер «#<id>», текст до 300 символов), 5 последних изменений («изменения (N)») |
| `comment` | `key`, `comment_id` (номер из `issue`) | один комментарий целиком: автор, время, полный текст |
| `changes` | `hours`, `since`, `until` (ISO; без смещения — местное время), `kinds` (`status`, `description`, `dates`, `comment`, `other` или `all`), `key` | лента изменений по задачам: что закрылось, правки описания, сроки, новые комментарии; по умолчанию — с начала суток; без привязки — только с `key` | | `changes` | `hours`, `since`, `until` (ISO; без смещения — местное время), `kinds` (`status`, `description`, `dates`, `comment`, `other` или `all`), `key` | лента изменений по задачам: что закрылось, правки описания, сроки, новые комментарии; по умолчанию — с начала суток; без привязки — только с `key` |
| `notes_set` | `key`, поля заметок | обновлённые заметки | | `notes_set` | `key`, поля заметок | обновлённые заметки |
| `batch_create` | `title`, `commands` (у `ensure_attachment` вместо `file` можно `path` — абсолютный путь к локальному файлу до 10 МБ) | `id`, оценка длительности, число команд | | `batch_create` | `title`, `commands` (у `ensure_attachment` вместо `file` можно `path` — абсолютный путь к локальному файлу до 10 МБ) | `id`, оценка длительности, число команд |

View File

@ -22,7 +22,8 @@ MCP-сервер хаба (`jira-hub mcp`, stdio) для Claude: инструм
| `projects` | — | таблица: метка, название, доска, тип доски («неизвестен», пока агент не определил), число своих задач; в привязанной сессии текущий проект отмечен | | `projects` | — | таблица: метка, название, доска, тип доски («неизвестен», пока агент не определил), число своих задач; в привязанной сессии текущий проект отмечен |
| `project_create` | `label`, `name`, `board_id` (необязательно) | `POST /projects` с автором `claude`: проект, id пакета обновления и фрагмент `.mcp.json` для репозитория проекта (см. ниже); 409/422 — текст ошибки | | `project_create` | `label`, `name`, `board_id` (необязательно) | `POST /projects` с автором `claude`: проект, id пакета обновления и фрагмент `.mcp.json` для репозитория проекта (см. ниже); 409/422 — текст ошибки |
| `issues` | фильтры как у `GET /issues` (кроме `project` — его задаёт привязка), `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт, статус по факту | | `issues` | фильтры как у `GET /issues` (кроме `project` — его задаёт привязка), `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт, статус по факту |
| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды, комментарии: «комментарии (N):» и 5 последних (автор, время, текст до 300 символов с «…»), «ещё M», если их больше 5; без комментариев — «комментариев нет» | | `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды, комментарии: «комментарии (N):» и 5 последних (номер «#<comment_id>», автор, время, текст до 300 символов с «…»), «ещё M», если их больше 5; без комментариев — «комментариев нет» |
| `comment` | `key`, `comment_id` (номер из ответа `issue`, «#» в начале допускается) | один комментарий целиком по `GET /issues/{key}`: строка «<key> #<id> <автор>, создан <created>» (и «, изменён <updated>», если `updated` есть и отличается от `created`), под ней полный текст с переносами строк; нет такого номера — «у <key> нет комментария #<id>» |
| `notes_set` | `key`, подмножество полей заметок | обновлённые заметки | | `notes_set` | `key`, подмножество полей заметок | обновлённые заметки |
| `batch_create` | `title`, `commands` (список объектов по контракту `commands`; у `ensure_attachment` вместо `file` можно `path`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации | | `batch_create` | `title`, `commands` (список объектов по контракту `commands`; у `ensure_attachment` вместо `file` можно `path`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации |
| `batch_status` | `id` | статусы команд, ошибки, оценка остатка | | `batch_status` | `id` | статусы команд, ошибки, оценка остатка |
@ -98,7 +99,15 @@ MCP-сервер хаба (`jira-hub mcp`, stdio) для Claude: инструм
#### Scenario: Задача с семью комментариями #### Scenario: Задача с семью комментариями
- **WHEN** `issue {key: "AURORA-9001"}`, у задачи 7 комментариев, у самого нового текст 500 символов - **WHEN** `issue {key: "AURORA-9001"}`, у задачи 7 комментариев, у самого нового текст 500 символов
- **THEN** ответ содержит «комментарии (7):», 5 последних комментариев (самый новый — первым, обрезан до 300 символов с «…») и «ещё 2» - **THEN** ответ содержит «комментарии (7):», 5 последних комментариев с номерами «#<comment_id>» (самый новый — первым, обрезан до 300 символов с «…») и «ещё 2»
#### Scenario: Комментарий целиком
- **WHEN** у задачи `AURORA-9001` комментарий `#101` длиной 800 символов с переносами строк, вызван `comment {key: "AURORA-9001", comment_id: "101"}`
- **THEN** ответ — «AURORA-9001 #101 <автор>, создан <created>» и полный текст комментария без обрезки, переносы строк сохранены
#### Scenario: Нет такого комментария
- **WHEN** `comment {key: "AURORA-9001", comment_id: "999"}`, у задачи нет комментария 999
- **THEN** ответ «у AURORA-9001 нет комментария #999»
### Requirement: Привязка сессии к проекту ### Requirement: Привязка сессии к проекту
`jira-hub mcp --project <метка>` SHALL привязывать сервер MCP к проекту. Привязка SHALL проверяться при каждом вызове `jira-hub mcp --project <метка>` SHALL привязывать сервер MCP к проекту. Привязка SHALL проверяться при каждом вызове
@ -107,7 +116,7 @@ MCP-сервер хаба (`jira-hub mcp`, stdio) для Claude: инструм
В привязанной к проекту P сессии: В привязанной к проекту P сессии:
- `issues` — `GET /issues?project=P` с остальными фильтрами; - `issues` — `GET /issues?project=P` с остальными фильтрами;
- `issue` — любая задача по ключу; - `issue`, `comment` — любая задача по ключу;
- `notes_set` — только своя задача P (метка P; соседи — нет) или временный ключ `new:<n>` (его задача создаётся пакетом - `notes_set` — только своя задача P (метка P; соседи — нет) или временный ключ `new:<n>` (его задача создаётся пакетом
проекта), иначе ответ «<key> не в проекте P»; проекта), иначе ответ «<key> не в проекте P»;
- `batch_create`, `command_retry` — с `project = P` (проверки пакета проекта, см. `commands`; повтор команды другого - `batch_create`, `command_retry` — с `project = P` (проверки пакета проекта, см. `commands`; повтор команды другого
@ -115,7 +124,7 @@ MCP-сервер хаба (`jira-hub mcp`, stdio) для Claude: инструм
- `batches` — `GET /batches?project=P`; `batch_status`, `batch_wait`, `batch_cancel` — по id, без ограничения; - `batches` — `GET /batches?project=P`; `batch_status`, `batch_wait`, `batch_cancel` — по id, без ограничения;
- `refresh` — `full` — общий полный прогон всех проектов, `keys` — как раньше. - `refresh` — `full` — общий полный прогон всех проектов, `keys` — как раньше.
Без привязки инструменты `issues`, `issue`, `notes_set`, `batch_create`, `command_retry`, `refresh` SHALL отвечать Без привязки инструменты `issues`, `issue`, `comment`, `notes_set`, `batch_create`, `command_retry`, `refresh` SHALL отвечать
«сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)» и в хаб не ходить; `projects`, «сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)» и в хаб не ходить; `projects`,
`project_create`, `agent_status`, `sync_log`, `settings_get`, `settings_set`, `batches`, `batch_status`, `batch_wait`, `project_create`, `agent_status`, `sync_log`, `settings_get`, `settings_set`, `batches`, `batch_status`, `batch_wait`,
`batch_cancel` работают. `batch_cancel` работают.

View File

@ -138,7 +138,8 @@ def fmt_issue(d) -> str:
cmds = [f" #{c['id']} {c['type']} {c['status']} {c.get('finished_at') or ''} {c.get('error') or ''}".rstrip() for c in d.get("commands", [])] cmds = [f" #{c['id']} {c['type']} {c['status']} {c.get('finished_at') or ''} {c.get('error') or ''}".rstrip() for c in d.get("commands", [])]
comments = d.get("comments") or [] comments = d.get("comments") or []
if comments: if comments:
shown = [f" {_v(c.get('author'))} {_v(c.get('created'))}: {_cut(c.get('body'), 300)}" for c in comments[:5]] shown = [f" #{c.get('comment_id')} {_v(c.get('author'))} {_v(c.get('created'))}: {_cut(c.get('body'), 300)}"
for c in comments[:5]]
cmts = [f"комментарии ({len(comments)}):"] + shown + ([f" ещё {len(comments) - 5}"] if len(comments) > 5 else []) cmts = [f"комментарии ({len(comments)}):"] + shown + ([f" ещё {len(comments) - 5}"] if len(comments) > 5 else [])
else: else:
cmts = ["комментариев нет"] cmts = ["комментариев нет"]
@ -160,6 +161,14 @@ def _day(value, text) -> str:
return _v(text) return _v(text)
def fmt_comment(key, c) -> str:
"""Один комментарий целиком (#80): шапка, затем текст с переносами строк; «изменён» — только если правили."""
head = f"{key} #{c.get('comment_id')} {_v(c.get('author'))}, создан {_v(c.get('created'))}"
if c.get("updated") and c.get("updated") != c.get("created"):
head += f", изменён {c['updated']}"
return head + "\n" + _v(c.get("body"))
def fmt_event(e: dict, today) -> str: def fmt_event(e: dict, today) -> str:
"""Строка события ленты: «ЧЧ:ММ автор: что»; дата «ДД.ММ» перед временем, если событие не в сутках today """Строка события ленты: «ЧЧ:ММ автор: что»; дата «ДД.ММ» перед временем, если событие не в сутках today
(today = None — дата всегда).""" (today = None — дата всегда)."""
@ -257,10 +266,23 @@ def issues(sprint: str = "", assignee: str = "", work_kind: str = "", fact_statu
@guard @guard
@scoped(True) @scoped(True)
def issue(key: str) -> str: def issue(key: str) -> str:
"""Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание.""" """Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание. Комментарии — 5
последних с номером, текст до 300 символов; полный текст — инструмент comment по номеру."""
return fmt_issue(_client().get(f"/issues/{key}")) return fmt_issue(_client().get(f"/issues/{key}"))
@mcp.tool()
@guard
@scoped(True)
def comment(key: str, comment_id: str) -> str:
"""Один комментарий задачи целиком: автор, время, полный текст. comment_id — номер из ответа issue («#123»)."""
cid = comment_id.lstrip("#")
for c in _client().get(f"/issues/{key}").get("comments") or []:
if c.get("comment_id") == cid:
return fmt_comment(key, c)
return f"у {key} нет комментария #{cid}"
def _local_if_naive(value: str) -> str: def _local_if_naive(value: str) -> str:
"""Время без смещения из чата — местное время машины MCP (хаб такое отвергает, M-2); неразбираемое — как есть.""" """Время без смещения из чата — местное время машины MCP (хаб такое отвергает, M-2); неразбираемое — как есть."""
if not value: if not value:

View File

@ -278,6 +278,7 @@ def test_fmt_issue_comments():
assert "комментарии (7):" in text assert "комментарии (7):" in text
assert "д" * 299 + "…" in text and "д" * 300 not in text # самый новый — первым, обрезан до 300 assert "д" * 299 + "…" in text and "д" * 300 not in text # самый новый — первым, обрезан до 300
assert "Автор 3" in text and "Автор 2" not in text # 5 последних: 7…3 assert "Автор 3" in text and "Автор 2" not in text # 5 последних: 7…3
assert " #7 Автор 7 2026-10-17T12:00:00.000+0300: ддд" in text and " #3 Автор 3 " in text # номер — для comment (#80)
assert "ещё 2" in text assert "ещё 2" in text
assert text.index("заметки:") < text.index("комментарии (7):") < text.index("команды:") assert text.index("заметки:") < text.index("комментарии (7):") < text.index("команды:")
no_author = mcp_server.fmt_issue(_detail(comments=[{"comment_id": "1", "author": None, "created": None, no_author = mcp_server.fmt_issue(_detail(comments=[{"comment_id": "1", "author": None, "created": None,
@ -285,6 +286,35 @@ def test_fmt_issue_comments():
assert "комментарии (1):" in no_author and "ещё" not in no_author assert "комментарии (1):" in no_author and "ещё" not in no_author
def test_fmt_comment():
"""Один комментарий целиком (#80): переносы строк сохраняются, «изменён» — только если правили."""
body = "первая строка\n" + "т" * 1000
text = mcp_server.fmt_comment("A-1", {"comment_id": "12", "author": "golov", "created": "2026-10-20T10:00:00.000+0300",
"updated": "2026-10-21T09:00:00.000+0300", "body": body})
assert text == ("A-1 #12 golov, создан 2026-10-20T10:00:00.000+0300, изменён 2026-10-21T09:00:00.000+0300\n" + body)
same = mcp_server.fmt_comment("A-1", {"comment_id": "12", "author": None, "created": "2026-10-20T10:00:00.000+0300",
"updated": "2026-10-20T10:00:00.000+0300", "body": ""})
assert same == "A-1 #12 —, создан 2026-10-20T10:00:00.000+0300\n—"
def test_comment_tool(live, monkeypatch):
long = "абв " * 200 + "\nконец"
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "status": "Готово", "labels": ["AWPCA"],
"comments": [{"id": "101", "author": "golov", "body": long,
"created": "2026-10-20T10:00:00.000+0300"},
{"id": "102", "author": "ivanov", "body": "коротко",
"created": "2026-10-21T10:00:00.000+0300"}]}],
"links": []})
assert "#101 golov" in mcp_server.issue("A-1") and long not in mcp_server.issue("A-1")
out = mcp_server.comment("A-1", "101")
assert out.startswith("A-1 #101 golov, создан 2026-10-20T10:00:00.000+0300\n") and out.endswith(long)
assert mcp_server.comment("A-1", "#102").endswith("\nкоротко") # номер можно передать как в issue
assert mcp_server.comment("A-1", "999") == "у A-1 нет комментария #999"
assert mcp_server.comment("A-404", "1").startswith("ошибка хаба 404")
monkeypatch.setattr(mcp_server, "PROJECT", None)
assert mcp_server.comment("A-1", "101") == mcp_server.NOT_BOUND
BUG = {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", BUG = {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана",
"labels": ["AWPCA"]}} "labels": ["AWPCA"]}}