diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ede38e9..854bb6f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -49,7 +49,7 @@ | `hooks` | `CommandHooks` для очереди ядра: проверки постановки (`bug` без настройки, файл вложения, проект пакета, `board_id` у `ensure_sprint` (по проекту задачи) и у `ensure_sprint_meta`/`ensure_sprint_state` (по проекту пакета)), паузы, `deliver_params` (`size`, `boards`, `history`), отчёты (`attachment_file`, `sync_run`) | `core.queue`, `core.files`, `core.projects`, `core.settings`, `settings`, `projects`, `commands`, `mirror`, `history`, `refresh`, `attachments`, `fetch` | | `agile` | чтение для `GET /agile`: доска, спринты, бэклог, выполнено по последней колонке | `core.db`, `history` | | `api` | маршруты модуля `MODULE.routes`: `/issues`, `/board`, `/agile`, `/changes`, `/attachments`, `/refresh`, `/sync-runs` и др. | `core.db`, `core.files`, `core.queue`, `core.settings`, `core.http`, `core.errors`, `attachments`, `projects`, `mirror`, `history`, `fetch`, `refresh` | -| `mcp` | инструменты MCP: `issues`, `issue`, `batch_create`, `batch_*`, `comment`, `attachment`, `changes`, `refresh`, `settings_*` и др.; общие `projects`/`project_create` подключает `register_core_tools` | `core.mcp`, `core.client`, `commands`, `attachments` | +| `mcp` | инструменты MCP: `issues` (колонка SP), `issue`, `sprints`, `sprint`, `backlog` (читают `GET /agile`), `batch_create`, `batch_*`, `comment`, `attachment`, `changes`, `refresh`, `settings_*` и др.; общие `projects`/`project_create` подключает `register_core_tools` | `core.mcp`, `core.client`, `commands`, `attachments` | | `importer`, `cli` | разовый импорт CSV-пакета в заметки (`sdlc-hub import FOLDER`) | `mirror`, `core.db`, `core.settings` | ### Модуль `wiki` (`modules/wiki/`) diff --git a/README.md b/README.md index c68fe80..4dc19b4 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,17 @@ MCP вики — отдельный сервер `sdlc-wiki` (`sdlc-hub mcp wiki Инструменты: `projects`, `project_create`, `root_set`, `tree`, `page`, `search`, `attachment`, `sync`, `status`. Пока у проекта нет корня вики (`root_set DOC-…` или `wiki_root` в `project_create`), они отвечают «у проекта <метка> нет корня вики». +Спринты и бэклог (нужна привязка к проекту; читают `GET /agile` проекта сессии): `sprints` — спринты доски с суммами +задач и SP «выполнено/всего»; `sprint [name]` — спринт (по умолчанию активный) с задачами по колонкам доски; +`backlog [limit]` — бэклог по рангу (по умолчанию 50 строк). В `issues` есть колонка SP. Команды спринтов и ранга ставятся +через `batch_create`, например смена состояния спринта с переносом открытых задач (хвост пакета хаб заменит на `refresh` по JQL проекта): + +```json +{"title": "Закрыть спринт 2", "commands": [ + {"type": "ensure_sprint_meta", "params": {"sprint": "S2", "goal": "Цель", "start_date": "2026-10-14", "end_date": "2026-10-28"}}, + {"type": "ensure_sprint_state", "params": {"sprint": "S2", "state": "closed", "move_open_to": "S3"}}]} +``` + Сессия MCP работает в разрезе одного проекта-метки. Привязка — `--project <метка>` в `.mcp.json` репозитория проекта (метка сравнивается точно, с учётом регистра): diff --git a/src/sdlc_hub/modules/jira/mcp.py b/src/sdlc_hub/modules/jira/mcp.py index c07bb17..b4fd06e 100644 --- a/src/sdlc_hub/modules/jira/mcp.py +++ b/src/sdlc_hub/modules/jira/mcp.py @@ -26,10 +26,126 @@ fmt_agent = core.fmt_agent def fmt_issues(issues) -> str: if not issues: return "задач нет" - lines = ["ключ | тема | статус | срок | исполнитель | спринт"] + lines = ["ключ | тема | статус | срок | исполнитель | спринт | SP"] for i in issues: lines.append(" | ".join([i["key"], core.cut(i.get("summary")), core.value(i.get("status")), core.value(i.get("duedate")), - core.value(i.get("assignee")), core.value(i.get("sprint"))])) + core.value(i.get("assignee")), core.value(i.get("sprint")), fmt_sp(i.get("story_points"))])) + return "\n".join(lines) + + +def fmt_sp(v) -> str: + """Оценка в story points: пусто — «—», целое без «.0».""" + return "—" if v is None else f"{v:g}" + + +def fmt_day(value) -> str: + """Время Jira (±HHMM, ±HH:MM, Z) → «ДД.ММ» в местном поясе; пусто или неразбираемо — «—».""" + if not isinstance(value, str) or not value: + return "—" + text = value.strip() + if text.endswith(("Z", "z")): + text = text[:-1] + "+00:00" + if len(text) > 5 and text[-5] in "+-" and text[-4:].isdigit(): + text = text[:-2] + ":" + text[-2:] + try: + at = datetime.fromisoformat(text) + except ValueError: + return "—" + if at.tzinfo is not None: + at = at.astimezone() + return at.strftime("%d.%m") + + +NO_BOARD = "у проекта нет доски" +NO_DATA = "данных доски нет — нужен агент v15 и обновление из Jira" +KANBAN = "у проекта канбан-доска, спринтов нет" +SPRINT_STATES = {"active": "активный", "future": "будущий", "closed": "закрыт"} +CLOSED_SHOWN = 10 + + +def _agile_refusal(data: dict, sprints: bool) -> str | None: + board = data.get("board") + if board is None: + return NO_BOARD + if board.get("fetched_at") is None: + return NO_DATA + if sprints and board.get("type") == "kanban": + return KANBAN + return None + + +def _sprint_dates(s: dict) -> str: + return f"{fmt_day(s.get('start_date'))}–{fmt_day(s.get('end_date'))}" + + +def fmt_sprints(data: dict) -> str: + refusal = _agile_refusal(data, True) + if refusal: + return refusal + items = data.get("sprints") or [] + if not items: + return "спринтов нет" + lines = ["спринт | состояние | даты | задачи | SP | без оценки"] + closed = 0 + for s in items: + if s["state"] == "closed": + closed += 1 + if closed > CLOSED_SHOWN: + continue + lines.append(f"{s['name']} | {SPRINT_STATES.get(s['state'], s['state'])} | {_sprint_dates(s)} | {s['count_done']}/{s['count']} | " + f"{fmt_sp(s['points_done'])}/{fmt_sp(s['points'])} | {s['unestimated']}") + if closed > CLOSED_SHOWN: + lines.append(f"ещё {closed - CLOSED_SHOWN} закрытых") + return "\n".join(lines) + + +def fmt_sprint(data: dict, name: str) -> str: + refusal = _agile_refusal(data, True) + if refusal: + return refusal + items = data.get("sprints") or [] + if name: + s = next((x for x in items if x["name"] == name), None) + if s is None: + return f"спринта «{name}» нет на доске" + else: + s = next((x for x in items if x["state"] == "active"), None) + if s is None: + return "активного спринта нет" + lines = [f"{s['name']} — {SPRINT_STATES.get(s['state'], s['state'])}; {_sprint_dates(s)}; цель: {core.value(s.get('goal'))}", + f"задачи {s['count_done']}/{s['count']}; SP {fmt_sp(s['points_done'])}/{fmt_sp(s['points'])}; без оценки: {s['unestimated']}"] + + def row(i): + return f" {i['key']} {core.cut(i.get('summary'), 80)} — {core.value(i.get('assignee'))}, SP {fmt_sp(i.get('story_points'))}" + rest = list(s.get("issues") or []) + for col in data["board"].get("columns") or []: + names = set(col.get("statuses") or []) + mine = [i for i in rest if i.get("status") in names] + rest = [i for i in rest if i.get("status") not in names] + lines.append(f"{col['name']} ({len(mine)}):") + lines.extend(row(i) for i in mine) + if rest: + lines.append(f"Без колонки ({len(rest)}):") + lines.extend(row(i) for i in rest) + return "\n".join(lines) + + +def fmt_backlog(data: dict, limit: int) -> str: + refusal = _agile_refusal(data, False) + if refusal: + return refusal + rows = data.get("backlog") + if rows is None: + return "у доски нет бэклога" + if not rows: + return "бэклог пуст" + shown = rows[:max(1, limit)] + lines = ["# | ключ | тема | статус | исполнитель | SP"] + for n, i in enumerate(shown, 1): + lines.append(" | ".join([str(n), i["key"], core.cut(i.get("summary")), core.value(i.get("status")), + core.value(i.get("assignee")), fmt_sp(i.get("story_points"))])) + if len(rows) > len(shown): + lines.append(f"ещё {len(rows) - len(shown)}") return "\n".join(lines) @@ -381,7 +497,7 @@ def _doc(text: str): @_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам. Параметры по типам (необязательные — в [ ]): """ + describe() + """ -Проект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб. +Проект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint, ensure_sprint_meta и ensure_sprint_state выставляет хаб; ensure_sprint_meta и ensure_sprint_state — только в сессии с проектом, хвост такого пакета — refresh по JQL проекта; оценка — story_points в ensure_fields. ensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит файл в хаб и подставит file, filename по умолчанию — имя файла.""") def batch_create(title: str, commands: list[dict]) -> str: @@ -393,6 +509,30 @@ def batch_create(title: str, commands: list[dict]) -> str: return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин" +@mcp.tool() +@core.guard +@core.scoped(True) +def sprints() -> str: + """Спринты доски проекта: состояние, даты, задачи и SP «выполнено/всего», без оценки; закрытые — 10 последних""" + return fmt_sprints(core.client().get("/agile", project=core.PROJECT)) + + +@mcp.tool() +@core.guard +@core.scoped(True) +def sprint(name: str = "") -> str: + """Спринт (по умолчанию активный): шапка с суммами и задачи по колонкам доски""" + return fmt_sprint(core.client().get("/agile", project=core.PROJECT), name) + + +@mcp.tool() +@core.guard +@core.scoped(True) +def backlog(limit: int = 50) -> str: + """Бэклог проекта по рангу: позиция, ключ, тема, статус, исполнитель, SP; limit — сколько строк (по умолчанию 50)""" + return fmt_backlog(core.client().get("/agile", project=core.PROJECT), limit) + + @mcp.tool() @core.guard @core.scoped(False) diff --git a/tests/fixtures/baseline/mcp_tools.expected.json b/tests/fixtures/baseline/mcp_tools.expected.json index 77a7337..d70e36d 100644 --- a/tests/fixtures/baseline/mcp_tools.expected.json +++ b/tests/fixtures/baseline/mcp_tools.expected.json @@ -228,7 +228,7 @@ "name": "notes_set" }, { - "description": "Поставить пакет команд. Каждая команда — {\"type\": <тип>, \"params\": {...}}. Хвостовой refresh хаб добавит сам.\nПараметры по типам (необязательные — в [ ]):\n- ensure_issue: temp_key (new:), issue_type (task|epic|bug), summary; [description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), labels (список), epic_key (ключ или new:), assignee (логин Jira)]\n- ensure_fields: key; [summary, description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), epic_key (ключ или new:), assignee (логин Jira), labels_add (список), labels_remove (список), story_points (число ≥ 0 или null)] — хотя бы одно поле\n- ensure_comment: key, marker (метка в начале комментария), body\n- ensure_status: key, target (статус или список-путь)\n- ensure_link: source, target, link_type (логическое имя из link_types); [replace (true|false)]\n- ensure_sprint: key, sprint (имя спринта или backlog)\n- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), filename (имя вложения)\n- fetch_attachment: key, attachment_id (id вложения из issue (цифры))\n- refresh: —; [keys (список), jql] — keys или jql\n- ensure_sprint_meta: sprint (имя спринта); [rename_to (новое имя), start_date (YYYY-MM-DD), end_date (YYYY-MM-DD), goal (цель спринта)]\n- ensure_sprint_state: sprint, state (active|closed); [move_open_to (имя спринта или backlog; обязателен при closed)]\n- ensure_rank: key; [before (ключ задачи, выше которой), after (ключ задачи, ниже которой)] — ровно одно из before, after\nПроект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб.\nensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит\nфайл в хаб и подставит file, filename по умолчанию — имя файла.", + "description": "Поставить пакет команд. Каждая команда — {\"type\": <тип>, \"params\": {...}}. Хвостовой refresh хаб добавит сам.\nПараметры по типам (необязательные — в [ ]):\n- ensure_issue: temp_key (new:), issue_type (task|epic|bug), summary; [description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), labels (список), epic_key (ключ или new:), assignee (логин Jira)]\n- ensure_fields: key; [summary, description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), epic_key (ключ или new:), assignee (логин Jira), labels_add (список), labels_remove (список), story_points (число ≥ 0 или null)] — хотя бы одно поле\n- ensure_comment: key, marker (метка в начале комментария), body\n- ensure_status: key, target (статус или список-путь)\n- ensure_link: source, target, link_type (логическое имя из link_types); [replace (true|false)]\n- ensure_sprint: key, sprint (имя спринта или backlog)\n- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), filename (имя вложения)\n- fetch_attachment: key, attachment_id (id вложения из issue (цифры))\n- refresh: —; [keys (список), jql] — keys или jql\n- ensure_sprint_meta: sprint (имя спринта); [rename_to (новое имя), start_date (YYYY-MM-DD), end_date (YYYY-MM-DD), goal (цель спринта)]\n- ensure_sprint_state: sprint, state (active|closed); [move_open_to (имя спринта или backlog; обязателен при closed)]\n- ensure_rank: key; [before (ключ задачи, выше которой), after (ключ задачи, ниже которой)] — ровно одно из before, after\nПроект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint, ensure_sprint_meta и ensure_sprint_state выставляет хаб; ensure_sprint_meta и ensure_sprint_state — только в сессии с проектом, хвост такого пакета — refresh по JQL проекта; оценка — story_points в ensure_fields.\nensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит\nфайл в хаб и подставит file, filename по умолчанию — имя файла.", "inputSchema": { "properties": { "commands": { @@ -253,6 +253,45 @@ }, "name": "batch_create" }, + { + "description": "Спринты доски проекта: состояние, даты, задачи и SP «выполнено/всего», без оценки; закрытые — 10 последних", + "inputSchema": { + "properties": {}, + "title": "sprintsArguments", + "type": "object" + }, + "name": "sprints" + }, + { + "description": "Спринт (по умолчанию активный): шапка с суммами и задачи по колонкам доски", + "inputSchema": { + "properties": { + "name": { + "default": "", + "title": "Name", + "type": "string" + } + }, + "title": "sprintArguments", + "type": "object" + }, + "name": "sprint" + }, + { + "description": "Бэклог проекта по рангу: позиция, ключ, тема, статус, исполнитель, SP; limit — сколько строк (по умолчанию 50)", + "inputSchema": { + "properties": { + "limit": { + "default": 50, + "title": "Limit", + "type": "integer" + } + }, + "title": "backlogArguments", + "type": "object" + }, + "name": "backlog" + }, { "description": "Состояние пакета и его команд.", "inputSchema": { diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 42ee818..9e2c76b 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -85,8 +85,8 @@ def test_formatters(): "sprint": None, "fact_status": "В работе"}]) assert "x" * 79 + "…" in text and "x" * 80 not in text and "—" in text head, row = text.splitlines() - assert head == "ключ | тема | статус | срок | исполнитель | спринт" # статуса по факту нет (fact-status-removal) - assert len(row.split(" | ")) == 6 and "В работе" not in row + assert head == "ключ | тема | статус | срок | исполнитель | спринт | SP" # статуса по факту нет (fact-status-removal) + assert len(row.split(" | ")) == 7 and "В работе" not in row assert "очередь" in jmcp.fmt_agent({"online": False, "agent_version": None, "current_command_id": None, "rate_limited_24h": 0, "redelivered": [], "warning": None, "last_seen_at": None, "settings_version": None}) @@ -221,7 +221,7 @@ def test_fmt_batch_warns_about_claimed(): def test_batch_create_mentions_labels(): doc = jmcp.batch_create.__doc__ or "" assert "метку проекта сессии хаб дописывает в ensure_issue сам" in doc - assert "метки других проектов запрещены" in doc and "board_id для ensure_sprint выставляет хаб" in doc + assert "метки других проектов запрещены" in doc and "board_id для ensure_sprint, ensure_sprint_meta и ensure_sprint_state выставляет хаб" in doc assert "AWPCA" not in doc @@ -859,3 +859,95 @@ def test_attachment_existing_copy_same_size(downloads, monkeypatch): asyncio.run(jmcp.attachment(ATT_KEY, "31337")) assert path.read_bytes() == b"OLD" * 3 assert path.parent.stat().st_mtime > 1_000_000 + 100 # M-6: папка «освежена» — уборка её не заденет + + +# --- спринты, спринт, бэклог, колонка SP (#108, Task 7) --------------------------------------------------------- + +def _sp(id, name, state, **over): + return {"id": id, "name": name, "state": state, "start_date": "2026-10-14T09:00:00.000+0300", "end_date": "2026-10-28T18:00:00.000+0300", + "complete_date": None, "goal": None, "count": 0, "count_done": 0, "unestimated": 0, "points": 0, "points_done": 0, "issues": [], **over} + + +BOARD = {"id": 4216, "type": "scrum", "columns": [{"name": "К выполнению", "statuses": ["Backlog"]}, {"name": "Готово", "statuses": ["Готово"]}], + "kanban_backlog": None, "fetched_at": "2026-10-24T10:00:00+00:00"} + + +def test_fmt_issues_sp_column(): + out = jmcp.fmt_issues([{"key": "A-1", "story_points": 3.0}, {"key": "A-2", "story_points": 2.5}, {"key": "A-3", "story_points": None}]) + assert out.splitlines()[0].endswith("| SP") and [l.rsplit("| ", 1)[1] for l in out.splitlines()[1:]] == ["3", "2.5", "—"] + + +def test_fmt_sprints_order_and_sums(): # сценарий «Спринты проекта» + data = {"board": BOARD, "backlog": [], "board_issues": None, "sprints": + [_sp(3, "AWPCA_Sprint_3_Corrections", "active", count=37, count_done=25, points=40, points_done=25), _sp(4, "AWPCA_Sprint_4", "future")] + + [_sp(100 - n, f"S{n}", "closed") for n in range(12)]} + lines = jmcp.fmt_sprints(data).splitlines() + assert lines[1].startswith("AWPCA_Sprint_3_Corrections | активный | ") and "| 25/37 | 25/40 |" in lines[1] + assert lines[2].startswith("AWPCA_Sprint_4 | будущий") + assert len([l for l in lines if "| закрыт |" in l]) == 10 and lines[-1] == "ещё 2 закрытых" + + +@pytest.mark.parametrize("data, text", [ + ({"board": None, "sprints": [], "backlog": None, "board_issues": None}, "у проекта нет доски"), + ({"board": {**BOARD, "type": "kanban"}, "sprints": [], "backlog": None, "board_issues": []}, "у проекта канбан-доска, спринтов нет"), + ({"board": {**BOARD, "fetched_at": None, "columns": None}, "sprints": [], "backlog": [], "board_issues": None}, "данных доски нет — нужен агент v15 и обновление из Jira"), +]) +def test_fmt_sprints_refusals(data, text): + assert jmcp.fmt_sprints(data) == text + + +def test_fmt_sprint_by_columns(): + def it(key, status, summary="т", sp=None, assignee=None): + return {"key": key, "summary": summary, "status": status, "assignee": assignee, "story_points": sp} + issues = [it("A-1", "Backlog", "д" * 100, 2, "Иванов"), it("A-2", "Готово", sp=2.5), it("A-3", "Странный")] + data = {"board": BOARD, "backlog": [], "board_issues": None, "sprints": [ + _sp(5, "S5", "active", goal="Цель", count=3, count_done=1, points=4.5, points_done=2.5, unestimated=1, issues=issues), + _sp(4, "S4", "future")]} + out = jmcp.fmt_sprint(data, "").splitlines() + assert out[0].startswith("S5 — активный; ") and out[0].endswith("; цель: Цель") + assert out[1] == "задачи 1/3; SP 2.5/4.5; без оценки: 1" + assert out[2:] == ["К выполнению (1):", out[3], "Готово (1):", + " A-2 т — —, SP 2.5", "Без колонки (1):", " A-3 т — —, SP —"] + assert out[3].startswith(" A-1 ") and out[3].endswith(" — Иванов, SP 2") and len(out[3].split(" — ")[0]) <= len(" A-1 ") + 80 + assert jmcp.fmt_sprint(data, "S9") == "спринта «S9» нет на доске" + assert jmcp.fmt_sprint(data, "S4").splitlines()[0].startswith("S4 — будущий; ") + data["sprints"] = [_sp(4, "S4", "future")] + assert jmcp.fmt_sprint(data, "") == "активного спринта нет" + assert jmcp.fmt_sprint({"board": {**BOARD, "type": "kanban"}, "sprints": [], "backlog": None, "board_issues": []}, "") \ + == "у проекта канбан-доска, спринтов нет" + + +def test_fmt_backlog_limit(): # сценарий «Бэклог с лимитом» + rows = [{"key": f"A-{n}", "summary": "т", "status": "Backlog", "assignee": None, "story_points": None} for n in range(188)] + out = jmcp.fmt_backlog({"board": BOARD, "sprints": [], "backlog": rows, "board_issues": None}, 2).splitlines() + assert out[1].startswith("1 | A-0 |") and out[2].startswith("2 | A-1 |") and out[-1] == "ещё 186" and len(out) == 4 + assert jmcp.fmt_backlog({"board": BOARD, "sprints": [], "backlog": [], "board_issues": None}, 5) == "бэклог пуст" + assert jmcp.fmt_backlog({"board": BOARD, "sprints": [], "backlog": None, "board_issues": None}, 5) == "у доски нет бэклога" + + +def test_fmt_day_offsets(): + assert jmcp.fmt_day("2026-10-14T09:00:00.000+0300") == jmcp.fmt_day("2026-10-14T09:00:00.000+03:00") != "—" + assert jmcp.fmt_day("2026-10-14T09:00:00.000Z") != "—" + assert jmcp.fmt_day(None) == "—" and jmcp.fmt_day("мусор") == "—" + + +def test_fmt_sp(): + assert [jmcp.fmt_sp(v) for v in (None, 3.0, 2.5, 0)] == ["—", "3", "2.5", "0"] + + +def test_agile_tools_live(live): + live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема", "status": "Backlog", "labels": ["AWPCA"], + "rank": "0|a", "story_points": 2, "sprint_ids": [102]}], "links": [], + "boards": [{"id": 4216, "type": "scrum", "columns": BOARD["columns"], "kanban_backlog": None, "sprints": [_sp(102, "S2", "active")]}]}) + assert "S2 | активный" in jmcp.sprints() and "A-1 Тема" in jmcp.sprint() and "бэклог пуст" == jmcp.backlog() + + +def test_agile_tools_unbound(live, monkeypatch): + monkeypatch.setattr(core_mcp, "PROJECT", None) + assert jmcp.sprints() == jmcp.sprint() == jmcp.backlog() == jmcp.issues() # тот же отказ, что у прочих + + +def test_batch_create_description_lists_sprint_types(): + doc = jmcp.batch_create.__doc__ + for word in ("ensure_sprint_meta", "ensure_sprint_state", "ensure_rank", "story_points", "refresh по JQL проекта"): + assert word in doc