diff --git a/README.md b/README.md index 3b6fdae..22ccc2c 100644 --- a/README.md +++ b/README.md @@ -68,17 +68,18 @@ claude mcp add -s user jira-hub -- uv run --project /Users/andrey.epifancev/proj Без привязки (user scope, без `--project`) работают только служебные инструменты (`projects`, `project_create`, `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)». Метка, которой нет в хабе, — «проект <метка> не заведён в хабе: project_create или исправьте --project в .mcp.json». В привязанной сессии `issues`, `batches` и `notes_set` видят только свой проект (`notes_set` — свои задачи и временные ключи `new:`), -`issue` открывает любую задачу по ключу, пакеты и команды ставятся с проектом: хаб сам дописывает метку проекта в +`issue` и `comment` открывают любую задачу по ключу, пакеты и команды ставятся с проектом: хаб сам дописывает метку проекта в `ensure_issue` и отвергает чужую метку. | Инструмент | Параметры | Результат | |---|---|---| | `issues` | фильтры как у `GET /issues`, `limit` | таблица задач проекта сессии | -| `issue` | `key` | поля, связи, заметки, последние команды, комментарии, 5 последних изменений («изменения (N)») | +| `issue` | `key` | поля, связи, заметки, последние команды, 5 последних комментариев (номер «#», текст до 300 символов), 5 последних изменений («изменения (N)») | +| `comment` | `key`, `comment_id` (номер из `issue`) | один комментарий целиком: автор, время, полный текст | | `changes` | `hours`, `since`, `until` (ISO; без смещения — местное время), `kinds` (`status`, `description`, `dates`, `comment`, `other` или `all`), `key` | лента изменений по задачам: что закрылось, правки описания, сроки, новые комментарии; по умолчанию — с начала суток; без привязки — только с `key` | | `notes_set` | `key`, поля заметок | обновлённые заметки | | `batch_create` | `title`, `commands` (у `ensure_attachment` вместо `file` можно `path` — абсолютный путь к локальному файлу до 10 МБ) | `id`, оценка длительности, число команд | diff --git a/openspec/specs/mcp/spec.md b/openspec/specs/mcp/spec.md index 5a498b9..b9fe6e4 100644 --- a/openspec/specs/mcp/spec.md +++ b/openspec/specs/mcp/spec.md @@ -22,7 +22,8 @@ MCP-сервер хаба (`jira-hub mcp`, stdio) для Claude: инструм | `projects` | — | таблица: метка, название, доска, тип доски («неизвестен», пока агент не определил), число своих задач; в привязанной сессии текущий проект отмечен | | `project_create` | `label`, `name`, `board_id` (необязательно) | `POST /projects` с автором `claude`: проект, id пакета обновления и фрагмент `.mcp.json` для репозитория проекта (см. ниже); 409/422 — текст ошибки | | `issues` | фильтры как у `GET /issues` (кроме `project` — его задаёт привязка), `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт, статус по факту | -| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды, комментарии: «комментарии (N):» и 5 последних (автор, время, текст до 300 символов с «…»), «ещё M», если их больше 5; без комментариев — «комментариев нет» | +| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды, комментарии: «комментарии (N):» и 5 последних (номер «#», автор, время, текст до 300 символов с «…»), «ещё M», если их больше 5; без комментариев — «комментариев нет» | +| `comment` | `key`, `comment_id` (номер из ответа `issue`, «#» в начале допускается) | один комментарий целиком по `GET /issues/{key}`: строка « # <автор>, создан » (и «, изменён », если `updated` есть и отличается от `created`), под ней полный текст с переносами строк; нет такого номера — «у нет комментария #» | | `notes_set` | `key`, подмножество полей заметок | обновлённые заметки | | `batch_create` | `title`, `commands` (список объектов по контракту `commands`; у `ensure_attachment` вместо `file` можно `path`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации | | `batch_status` | `id` | статусы команд, ошибки, оценка остатка | @@ -98,7 +99,15 @@ MCP-сервер хаба (`jira-hub mcp`, stdio) для Claude: инструм #### Scenario: Задача с семью комментариями - **WHEN** `issue {key: "AURORA-9001"}`, у задачи 7 комментариев, у самого нового текст 500 символов -- **THEN** ответ содержит «комментарии (7):», 5 последних комментариев (самый новый — первым, обрезан до 300 символов с «…») и «ещё 2» +- **THEN** ответ содержит «комментарии (7):», 5 последних комментариев с номерами «#» (самый новый — первым, обрезан до 300 символов с «…») и «ещё 2» + +#### Scenario: Комментарий целиком +- **WHEN** у задачи `AURORA-9001` комментарий `#101` длиной 800 символов с переносами строк, вызван `comment {key: "AURORA-9001", comment_id: "101"}` +- **THEN** ответ — «AURORA-9001 #101 <автор>, создан » и полный текст комментария без обрезки, переносы строк сохранены + +#### Scenario: Нет такого комментария +- **WHEN** `comment {key: "AURORA-9001", comment_id: "999"}`, у задачи нет комментария 999 +- **THEN** ответ «у AURORA-9001 нет комментария #999» ### Requirement: Привязка сессии к проекту `jira-hub mcp --project <метка>` SHALL привязывать сервер MCP к проекту. Привязка SHALL проверяться при каждом вызове @@ -107,7 +116,7 @@ MCP-сервер хаба (`jira-hub mcp`, stdio) для Claude: инструм В привязанной к проекту P сессии: - `issues` — `GET /issues?project=P` с остальными фильтрами; -- `issue` — любая задача по ключу; +- `issue`, `comment` — любая задача по ключу; - `notes_set` — только своя задача P (метка P; соседи — нет) или временный ключ `new:` (его задача создаётся пакетом проекта), иначе ответ « не в проекте P»; - `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, без ограничения; - `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`, `project_create`, `agent_status`, `sync_log`, `settings_get`, `settings_set`, `batches`, `batch_status`, `batch_wait`, `batch_cancel` работают. diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index be5bc4e..879d747 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -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", [])] comments = d.get("comments") or [] 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 []) else: cmts = ["комментариев нет"] @@ -160,6 +161,14 @@ def _day(value, text) -> str: 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: """Строка события ленты: «ЧЧ:ММ автор: что»; дата «ДД.ММ» перед временем, если событие не в сутках today (today = None — дата всегда).""" @@ -257,10 +266,23 @@ def issues(sprint: str = "", assignee: str = "", work_kind: str = "", fact_statu @guard @scoped(True) def issue(key: str) -> str: - """Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание.""" + """Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание. Комментарии — 5 + последних с номером, текст до 300 символов; полный текст — инструмент comment по номеру.""" 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: """Время без смещения из чата — местное время машины MCP (хаб такое отвергает, M-2); неразбираемое — как есть.""" if not value: diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 715daf0..a2a4ad8 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -278,6 +278,7 @@ def test_fmt_issue_comments(): assert "комментарии (7):" in text assert "д" * 299 + "…" in text and "д" * 300 not in text # самый новый — первым, обрезан до 300 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 text.index("заметки:") < text.index("комментарии (7):") < text.index("команды:") 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 +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 не распознана", "labels": ["AWPCA"]}}