diff --git a/src/jira_hub/commands.py b/src/jira_hub/commands.py index 9ffd44f..baf1b6c 100644 --- a/src/jira_hub/commands.py +++ b/src/jira_hub/commands.py @@ -8,18 +8,31 @@ from . import mirror TEMP_KEY = re.compile(r"^new:\d+$") FIELD_PARAMS = ["summary", "description", "priority", "duedate", "planned_start", "planned_end", "epic_key", "assignee"] +DATE = "YYYY-MM-DD" +FIELD_HINTS = {"duedate": DATE, "planned_start": DATE, "planned_end": DATE, "epic_key": "ключ или new:", + "assignee": "логин Jira"} + +# hints — формат значения для описания MCP batch_create, note — условие на набор параметров (#44) SPEC = { "ensure_issue": {"required": ["temp_key", "issue_type", "summary"], "optional": ["description", "priority", "duedate", "planned_start", "planned_end", "labels", "epic_key", "assignee"], - "keys": ["temp_key", "epic_key"]}, + "keys": ["temp_key", "epic_key"], + "hints": {"temp_key": "new:", "issue_type": "task|epic", "labels": "список", **FIELD_HINTS}}, "ensure_fields": {"required": ["key"], "optional": FIELD_PARAMS + ["labels_add", "labels_remove"], - "keys": ["key", "epic_key"]}, - "ensure_comment": {"required": ["key", "marker", "body"], "optional": [], "keys": ["key"]}, - "ensure_status": {"required": ["key", "target"], "optional": [], "keys": ["key"]}, - "ensure_link": {"required": ["source", "target", "link_type"], "optional": ["replace"], "keys": ["source", "target"]}, - "ensure_sprint": {"required": ["key", "sprint"], "optional": [], "keys": ["key"]}, - "refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"]}, + "keys": ["key", "epic_key"], + "hints": {**FIELD_HINTS, "labels_add": "список", "labels_remove": "список"}, + "note": "хотя бы одно поле"}, + "ensure_comment": {"required": ["key", "marker", "body"], "optional": [], "keys": ["key"], + "hints": {"marker": "метка в начале комментария"}}, + "ensure_status": {"required": ["key", "target"], "optional": [], "keys": ["key"], + "hints": {"target": "статус или список-путь"}}, + "ensure_link": {"required": ["source", "target", "link_type"], "optional": ["replace"], "keys": ["source", "target"], + "hints": {"link_type": "логическое имя из link_types", "replace": "true|false"}}, + "ensure_sprint": {"required": ["key", "sprint"], "optional": [], "keys": ["key"], + "hints": {"sprint": "имя спринта или backlog"}}, + "refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"], "hints": {"keys": "список"}, + "note": "keys или jql"}, } WRITE_TYPES = [t for t in SPEC if t != "refresh"] LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"} @@ -69,6 +82,31 @@ def _is_str_list(value) -> bool: return isinstance(value, list) and all(isinstance(v, str) for v in value) +def _names(spec, names) -> str: + return ", ".join(f"{n} ({spec['hints'][n]})" if n in spec.get("hints", {}) else n for n in names) + + +def describe() -> str: + """Параметры всех типов команд, строка на тип (необязательные — в [ ]): описание MCP batch_create из SPEC (#44).""" + lines = [] + for ctype, spec in SPEC.items(): + line = f"- {ctype}: {_names(spec, spec['required']) or '—'}" + if spec["optional"]: + line += f"; [{_names(spec, spec['optional'])}]" + if spec.get("note"): + line += f" — {spec['note']}" + lines.append(line) + return "\n".join(lines) + + +def _expected(spec) -> str: + """Хвост ошибки «нет …» / «лишние параметры …»: какие параметры тип ожидает (#44).""" + parts = [f"ожидаются {', '.join(spec['required'])}"] if spec["required"] else [] + if spec["optional"]: + parts.append(f"необязательные {', '.join(spec['optional'])}") + return "; ".join(parts) + + def validate(commands) -> list[dict]: if not isinstance(commands, list) or not commands: raise CommandError("пакет без команд") @@ -84,10 +122,11 @@ def validate(commands) -> list[dict]: spec = SPEC[ctype] missing = [f for f in spec["required"] if params.get(f) in (None, "", [])] if missing: - raise CommandError(f"команда {i} ({ctype}): нет {', '.join(missing)}") + raise CommandError(f"команда {i} ({ctype}): нет {', '.join(missing)}; {_expected(spec)}") unknown = set(params) - set(spec["required"]) - set(spec["optional"]) if unknown: - raise CommandError(f"команда {i} ({ctype}): лишние параметры {', '.join(sorted(unknown))}") + raise CommandError(f"команда {i} ({ctype}): лишние параметры {', '.join(sorted(unknown))}; " + f"{_expected(spec)}") bad = [e for e in (_type_error(ctype, n, v) for n, v in params.items()) if e] if bad: raise CommandError(f"команда {i} ({ctype}): " + "; ".join(bad)) diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index 2fdcbd8..548a788 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -9,6 +9,7 @@ import time from mcp.server.fastmcp import FastMCP from .client import HubClient, HubError, HubUnavailable +from .commands import describe OFFLINE = "хаб не запущен: jira-hub serve" POLL_SECONDS = 5 @@ -159,13 +160,22 @@ def notes_set(key: str, fact_status: str | None = None, work_kind: str | None = return "заметки " + key + ": " + "; ".join(f"{k}: {note.get(k)}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k)) +def _doc(text: str): + """Описание инструмента, собранное в коде: FastMCP берёт его из __doc__ при регистрации.""" + def deco(fn): + fn.__doc__ = text + return fn + return deco + + @mcp.tool() @guard +@_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам. +Параметры по типам (необязательные — в [ ]): +""" + describe() + """ +ensure_issue передавайте с labels, включающими базовую метку проекта (AWPCA): хаб и агент метки сами не добавляют, +задача без неё выпадет из базовой JQL и из зеркала.""") def batch_create(title: str, commands: list[dict]) -> str: - """Поставить пакет команд. Каждая команда — {"type": ensure_issue|ensure_fields|ensure_comment|ensure_status| - ensure_link|ensure_sprint|refresh, "params": {...}} по контракту specs/commands. Хвостовой refresh хаб добавит сам. - ensure_issue передавайте с labels, включающими базовую метку проекта (AWPCA): хаб и агент метки сами не добавляют, - задача без неё выпадет из базовой JQL и из зеркала.""" b = _client().post("/batches", {"title": title, "author": AUTHOR, "commands": commands}) return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин" diff --git a/tests/test_commands.py b/tests/test_commands.py index 62e15ef..45f0783 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -37,6 +37,29 @@ def test_validate_errors(bad, msg): commands.validate(bad) +def test_missing_param_error_names_expected(): + """Клиент со старым описанием исправляет пакет с одной попытки (#44): ensure_status {key, status}.""" + with pytest.raises(CommandError) as e: + commands.validate([{"type": "ensure_status", "params": {"key": "A-1", "status": "Готово"}}]) + assert str(e.value) == "команда 1 (ensure_status): нет target; ожидаются key, target" + + +def test_unknown_param_error_names_expected(): + with pytest.raises(CommandError) as e: + commands.validate([{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2", "board": "1"}}]) + assert str(e.value) == "команда 1 (ensure_sprint): лишние параметры board; ожидаются key, sprint" + with pytest.raises(CommandError) as e: + commands.validate([{"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные", + "force": True}}]) + assert str(e.value) == ("команда 1 (ensure_link): лишние параметры force; " + "ожидаются source, target, link_type; необязательные replace") + + +def test_spec_hints_name_known_params(): + for ctype, spec in commands.SPEC.items(): + assert set(spec.get("hints", {})) <= set(spec["required"]) | set(spec["optional"]), ctype + + def test_substitute_and_estimate(): p = commands.substitute("ensure_link", {"source": "new:60", "target": "A-1", "link_type": "связанные"}, {"new:60": "A-9"}) assert p["source"] == "A-9" diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 429929e..f365f4b 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -6,7 +6,7 @@ from types import SimpleNamespace import pytest -from jira_hub import api, mcp_server, settings +from jira_hub import api, commands, mcp_server, settings from jira_hub.client import HubClient @@ -204,6 +204,23 @@ def test_batch_create_mentions_labels(): assert "labels" in doc and "AWPCA" in doc +def test_batch_create_description_lists_params_from_spec(): + """Клиент MCP не видит specs/commands: описание инструмента перечисляет параметры каждого типа из SPEC (#44).""" + tools = {t.name: t for t in asyncio.run(mcp_server.mcp.list_tools())} + desc = tools["batch_create"].description + for ctype, spec in commands.SPEC.items(): + line = next(ln for ln in desc.splitlines() if ln.startswith(f"- {ctype}:")) + required, _, optional = line.removeprefix(f"- {ctype}:").partition("; [") + for name in spec["required"]: + assert re.search(rf"\b{name}\b", required), (ctype, name) + for name in spec["optional"]: + assert re.search(rf"\b{name}\b", optional), (ctype, name) + for name, hint in spec.get("hints", {}).items(): + assert f"{name} ({hint})" in line, (ctype, name) + assert "target (статус или список-путь)" in desc and "sprint (имя спринта или backlog)" in desc + assert "labels" in desc and "AWPCA" in desc + + def test_fmt_agent_update_lines(): base = {"online": True, "agent_version": "6", "current_command_id": None, "rate_limited_24h": 0, "redelivered": [], "warning": None, "last_seen_at": "2026-10-14T20:00:00+00:00", "settings_version": 3}