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:
parent
4bd8a1f988
commit
352d8bba5e
@ -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`, оценка длительности, число команд |
|
||||||
|
|||||||
@ -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` работают.
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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"]}}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user