From 68c7f23410d3ef1253d4ca46f4abd67a34f0bfe1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 12:35:22 +0400 Subject: [PATCH] =?UTF-8?q?MCP=20=D0=B2=D0=B8=D0=BA=D0=B8:=20changes,=20di?= =?UTF-8?q?ff,=20=C2=AB=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=BE?= =?UTF-8?q?=C2=BB=20=D0=B2=20page,=20=D0=BA=D0=BE=D1=80=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=D0=B9=20sync=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 2 +- README.md | 9 +- src/sdlc_hub/core/mcp.py | 21 ++ src/sdlc_hub/modules/jira/mcp.py | 20 +- src/sdlc_hub/modules/wiki/mcp.py | 107 +++++++++- .../baseline/mcp_wiki_tools.expected.json | 66 ++++++- tests/test_wiki_mcp.py | 184 +++++++++++++++--- 7 files changed, 349 insertions(+), 60 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1295923..da541cb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -71,7 +71,7 @@ | `attachments` | вложения по запросу: состояние `none`/`fetching`/`ready`/`failed`, постановка `wiki_fetch`, хуки результата; адрес берётся только из `wiki_attachment` | `core.queue`, `core.files` | | `tick` | фоновая синхронизация проектов с корнем раз в `wiki_auto_sync_hours` | `core.db`, `core.settings`, `commands`, `sync`, `search` | | `api` | маршруты `/wiki/tree`, `/wiki/pages/{code}`, `/wiki/pages/{code}/versions`, `/wiki/pages/{code}/diff`, `/wiki/changes` (лента правок), `/wiki/search`, `/wiki/sync`, `/wiki/status`, `/wiki/attachments/{id}[/fetch\|/content]` (клиент) и `POST /agent/wiki/pages`, `POST /agent/wiki/versions` (агент `wiki`) | `core.http`, `core.queue`, `stage`, `attachments`, `search` | -| `mcp` | инструменты сервера `sdlc-wiki`: `projects`, `project_create`, `root_set`, `tree`, `page`, `search`, `attachment`, `sync`, `status`; Markdown и имена вложений недоверенные | `core.mcp`, `core.client` | +| `mcp` | инструменты сервера `sdlc-wiki`: `projects`, `project_create`, `root_set`, `tree`, `page`, `search`, `changes`, `diff`, `attachment`, `sync`, `status`; `sync` отвечает коротко; `page`, `changes`, `diff` читают только копию; Markdown, правки и имена вложений недоверенные | `core.mcp`, `core.client` | ### Контракт модуля diff --git a/README.md b/README.md index 4fed855..7d3aca3 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,16 @@ MCP вики — отдельный сервер `sdlc-wiki` (`sdlc-hub mcp wiki } ``` -Инструменты: `projects`, `project_create`, `root_set`, `tree`, `page`, `search`, `attachment`, `sync`, `status`. Пока у проекта нет корня +Инструменты: `projects`, `project_create`, `root_set`, `tree`, `page`, `search`, `changes`, `diff`, `attachment`, `sync`, `status`. Пока у проекта нет корня вики (`root_set DOC-…` или `wiki_root` в `project_create`), они отвечают «у проекта <метка> нет корня вики». +- `changes [hours] [since] [until] [author]` — правки страниц за период (по умолчанию — с начала суток), по страницам, новые сверху; +- `diff code [version] [offset]` — дифф правки (без `version` — последняя), по частям 20 000 символов с `продолжение: offset=`; +- `page` — шапка с «изменено: ДД.ММ ЧЧ:ММ <автор> (версий: N)»; `sync` — коротко: «поставлено: пакет N», «уже идёт: пакет N», с `wait` — «ок: синхронизировано …» или «ошибка: …»; +- `status` — дополнительно строка «история: <время>» или «история не загружена». + +`page`, `changes`, `diff` команд не ставят: читают копию хаба. + Спринты и бэклог (нужна привязка к проекту; читают `GET /agile` проекта сессии): `sprints` — спринты доски с суммами задач и SP «выполнено/всего»; `sprint [name]` — спринт (по умолчанию активный) с задачами по колонкам доски; `backlog [limit]` — бэклог по рангу (по умолчанию 50 строк). В `issues` есть колонка SP. Команды спринтов и ранга ставятся diff --git a/src/sdlc_hub/core/mcp.py b/src/sdlc_hub/core/mcp.py index 56bb161..4a6e7a1 100644 --- a/src/sdlc_hub/core/mcp.py +++ b/src/sdlc_hub/core/mcp.py @@ -9,6 +9,7 @@ import os import sys import tempfile from collections.abc import Callable +from datetime import datetime, timedelta from pathlib import Path from . import paths, registry @@ -99,6 +100,26 @@ def value(v): return "—" if v in (None, "", []) else str(v) +def local_if_naive(value: str) -> str: + """Время без смещения из чата — местное время машины MCP (хаб такое отвергает, M-2); неразбираемое — как есть.""" + if not value: + return value + try: + d = datetime.fromisoformat(value) + except ValueError: + return value + return d.astimezone().isoformat() if d.tzinfo is None else value + + +def since_of(hours: float | None, since: str, now: datetime) -> str: + """Начало периода: hours → now − hours; без обоих — начало суток now; иначе since как есть.""" + if hours is not None: + return (now - timedelta(hours=hours)).isoformat() + if not since: + return now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat() + return since + + def fmt_agent(a) -> str: state = "онлайн" if a.get("online") else "офлайн" cur = f"выполняет #{a['current_command_id']}" if a.get("current_command_id") else "очередь свободна" diff --git a/src/sdlc_hub/modules/jira/mcp.py b/src/sdlc_hub/modules/jira/mcp.py index 857e09d..0c80ad0 100644 --- a/src/sdlc_hub/modules/jira/mcp.py +++ b/src/sdlc_hub/modules/jira/mcp.py @@ -7,7 +7,7 @@ import json import stat import time import unicodedata -from datetime import datetime, timedelta +from datetime import datetime from pathlib import Path from mcp.server.fastmcp import FastMCP @@ -380,17 +380,6 @@ async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str: return (f"{prefix}{path}\n{att['filename']} ({fmt_size(att.get('size'))}), {core.value(att.get('mime_type'))}, задача {key}") -def _local_if_naive(value: str) -> str: - """Время без смещения из чата — местное время машины MCP (хаб такое отвергает, M-2); неразбираемое — как есть.""" - if not value: - return value - try: - d = datetime.fromisoformat(value) - except ValueError: - return value - return d.astimezone().isoformat() if d.tzinfo is None else value - - @mcp.tool() @core.guard def changes(hours: float | None = None, since: str = "", until: str = "", kinds: str = "", key: str = "") -> str: @@ -405,11 +394,8 @@ def changes(hours: float | None = None, since: str = "", until: str = "", kinds: if refusal is not None: return refusal now = datetime.now().astimezone() - if hours is not None: - since = (now - timedelta(hours=hours)).isoformat() - elif not since: - since = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat() - data = core.client().get("/changes", since=_local_if_naive(since), until=_local_if_naive(until), kinds=kinds, key=key, + since = core.since_of(hours, since, now) + data = core.client().get("/changes", since=core.local_if_naive(since), until=core.local_if_naive(until), kinds=kinds, key=key, project=None if key else core.PROJECT) return fmt_changes(data, now) diff --git a/src/sdlc_hub/modules/wiki/mcp.py b/src/sdlc_hub/modules/wiki/mcp.py index 9aab6f2..e6b38dd 100644 --- a/src/sdlc_hub/modules/wiki/mcp.py +++ b/src/sdlc_hub/modules/wiki/mcp.py @@ -4,6 +4,7 @@ import asyncio import re import time +from datetime import datetime from urllib.parse import quote from mcp.server.fastmcp import FastMCP @@ -58,6 +59,23 @@ def _walk(pages: list, code: str, depth: int) -> list: return [p for p in out if p["depth"] - base <= depth] +def _stamp(at) -> str: + """Время из базы (UTC) → «ДД.ММ ЧЧ:ММ» в местном поясе машины MCP; пусто — «—».""" + return "—" if not at else datetime.fromisoformat(at).astimezone().strftime("%d.%m %H:%M") + + +def _who(a) -> str: + """Автор правки: имя, иначе логин, иначе «—».""" + a = a or {} + return _line(a.get("name") or a.get("login") or None) + + +def _modified(d) -> str: + if not d.get("modified_at"): + return "изменено: история не загружена" + return f"изменено: {_stamp(d['modified_at'])} {_who(d.get('modified_by'))} (версий: {d.get('versions') or 0})" + + @mcp.tool() @core.guard @core.scoped(True) @@ -116,7 +134,7 @@ def page(code: str = "", title: str = "", offset: int = 0) -> str: text, offset = d.get("markdown") or "", max(int(offset), 0) atts = d.get("attachments") or [] head = [f"{_line(d['code'])} — {_line(d.get('title'))}", f"путь: {_line(d.get('path'))}", - f"Eva: {_line(d.get('url'))}", f"синхронизировано: {_line(d.get('synced_at'))}"] + f"Eva: {_line(d.get('url'))}", f"синхронизировано: {_line(d.get('synced_at'))}", _modified(d)] head += ([f"вложения ({len(atts)}):"] + [f" #{a['id']} {_line(a.get('name'))} ({_line(a.get('state'))})" for a in atts] if atts else ["вложений нет"]) if offset >= len(text) and (offset > 0 or not text): @@ -147,6 +165,71 @@ def _store(attachment_id: int, name, data: bytes): return core.store_copy(f"wiki-{attachment_id}", core.safe_name(base, str(attachment_id)), data) +def fmt_wiki_changes(data: dict, now: datetime) -> str: + """Правки по страницам: страницы — по времени последней правки (лента уже новые сверху), под страницей — её правки.""" + lines = [f"правки вики с {_stamp(data['since'])} по {_stamp(data['until'])}", + f"синхронизировано: {_stamp(data.get('synced_at'))}"] + if data.get("history_at") is None: + return "\n".join(lines + ["история ещё не загружена: нужен eva-agent v2 и sync"]) + events = data.get("changes") or [] + if not events: + return "\n".join(lines + ["за период правок нет"]) + groups: dict[str, list] = {} + for c in events: + groups.setdefault(c["page_code"], []).append(c) + for code, items in groups.items(): + lines.append(f"{code} {_line(items[0].get('title'))} — {_line(items[0].get('path'))}") + for c in items: + local = datetime.fromisoformat(c["at"]).astimezone() + when = local.strftime("%H:%M") if local.date() == now.date() else local.strftime("%d.%m %H:%M") + lines.append(f" {when} {_who(c.get('author'))}: +{c['added']} / −{c['removed']} ({_line(c['version'])})") + if data.get("truncated"): + lines.append(f"показаны последние {len(events)} правок, сузьте период") + return "\n".join(lines) + + +@mcp.tool() +@core.guard +@core.scoped(True) +def changes(hours: float | None = None, since: str = "", until: str = "", author: str = "") -> str: + """Правки страниц вики проекта за период: по умолчанию — с начала сегодняшних суток; hours — за последние N часов; + since/until — ISO-8601 (без смещения — местное время); author — подстрока имени или логина без учёта регистра. + Команд не ставит: читает копию хаба.""" + if hours is not None and since: + return "либо hours, либо since" + if hours is not None and hours <= 0: + return "hours — число больше 0" + if (refusal := _gate()) is not None: + return refusal + now = datetime.now().astimezone() + data = core.client().get("/wiki/changes", project=core.PROJECT, since=core.local_if_naive(core.since_of(hours, since, now)), + until=core.local_if_naive(until), author=author) + return fmt_wiki_changes(data, now) + + +@mcp.tool() +@core.guard +@core.scoped(True) +def diff(code: str, version: str = "", offset: int = 0) -> str: + """Дифф правки страницы вики: шапка (код, название, путь, версия «N из M», автор, время, «+A / −R», предыдущая + версия) и unified diff с offset, до 20 000 символов за вызов; если дифф длиннее, последняя строка — «продолжение: + offset=». Без version — последняя правка. Команд не ставит.""" + if (refusal := _gate()) is not None: + return refusal + params = {"project": core.PROJECT, "offset": max(int(offset), 0)} + if version: + params["version"] = version.strip() + d = core.client().get(f"/wiki/pages/{quote(code.strip(), safe='')}/diff", **params) + prev = d.get("prev_version") + head = [f"{_line(d['code'])} — {_line(d.get('title'))}", f"путь: {_line(d.get('path'))}", + f"версия {d['index']} из {d['total']}: {_line(d['version'])}", f"автор: {_who(d.get('author'))}", + f"время: {_stamp(d.get('at'))}", f"+{d['added']} / −{d['removed']}", + f"предыдущая: {_line(prev) if prev else 'нет (первая версия)'}"] + out = "\n".join(head + ["", d.get("diff") or ""]) + nxt = d.get("next_offset") + return out + f"\nпродолжение: offset={nxt}" if nxt is not None else out + + @mcp.tool() @core.guard @core.scoped(True) @@ -180,6 +263,7 @@ def _summary(client) -> str: lines.append(f"ошибка синхронизации: {_line(s['error'])}") b = s.get("batch") lines.append(f"пакет: #{b['id']} {b['status']}" if b else "пакета в работе нет") + lines.append(f"история: {s['history_at']}" if s.get("history_at") else "история не загружена") return "\n".join(lines) @@ -187,20 +271,21 @@ def _summary(client) -> str: @core.guard @core.scoped(True) async def sync(wait: bool = False) -> str: - """Синхронизировать вики проекта с Eva: ставит пакет (или сообщает о текущем) и возвращает его id. wait=true — - дождаться итога (до 300 с).""" + """Синхронизировать вики проекта с Eva: ставит пакет и отвечает «поставлено: пакет N» или «уже идёт: пакет N». + wait=true — ждёт итога до 300 с: «ок: синхронизировано ДД.ММ ЧЧ:ММ», «ошибка: <текст>» или «не дождался за 300 с: + пакет N». Данных копии в ответе нет — их читают page, changes, status.""" refusal = await asyncio.to_thread(_gate, False) if refusal is not None: return refusal client = core.client() try: batch_id = (await asyncio.to_thread(client.post, "/wiki/sync", {"project": core.PROJECT, "author": core.AUTHOR}))["batch_id"] - head = f"синхронизация поставлена: пакет #{batch_id}" + head = f"поставлено: пакет {batch_id}" except HubError as e: if e.error != "sync_in_progress" or not isinstance(e.detail, dict): raise batch_id = e.detail["batch_id"] - head = f"синхронизация уже идёт: пакет #{batch_id}" + head = f"уже идёт: пакет {batch_id}" if not wait: return head deadline = time.monotonic() + SYNC_WAIT @@ -209,11 +294,15 @@ async def sync(wait: bool = False) -> str: if b["status"] in ("done", "cancelled") or time.monotonic() >= deadline: break await asyncio.sleep(POLL_SECONDS) - lines = [f"пакет #{batch_id}: {b['status']}"] - lines += [f" #{c['id']} {c['type']} {c['status']}" + (f": {_line(c['error'])}" if c.get("error") else "") for c in b.get("commands") or []] if b["status"] not in ("done", "cancelled"): - return "\n".join(lines + ["(таймаут ожидания, пакет ещё выполняется)"]) - return "\n".join(lines + [await asyncio.to_thread(_summary, client)]) + return f"не дождался за {SYNC_WAIT:g} с: пакет {batch_id}" + if b["status"] == "cancelled": + return f"ошибка: пакет {batch_id} отменён" + failed = next((c for c in b.get("commands") or [] if c.get("status") == "failed"), None) + if failed is not None: + return f"ошибка: {_line(failed.get('error'))}" + st = await asyncio.to_thread(client.get, "/wiki/status", project=core.PROJECT) + return f"ок: синхронизировано {_stamp(st.get('synced_at'))}" @mcp.tool() diff --git a/tests/fixtures/baseline/mcp_wiki_tools.expected.json b/tests/fixtures/baseline/mcp_wiki_tools.expected.json index 8c3c1de..209e165 100644 --- a/tests/fixtures/baseline/mcp_wiki_tools.expected.json +++ b/tests/fixtures/baseline/mcp_wiki_tools.expected.json @@ -66,6 +66,70 @@ }, "name": "search" }, + { + "description": "Правки страниц вики проекта за период: по умолчанию — с начала сегодняшних суток; hours — за последние N часов;\nsince/until — ISO-8601 (без смещения — местное время); author — подстрока имени или логина без учёта регистра.\nКоманд не ставит: читает копию хаба.", + "inputSchema": { + "properties": { + "author": { + "default": "", + "title": "Author", + "type": "string" + }, + "hours": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hours" + }, + "since": { + "default": "", + "title": "Since", + "type": "string" + }, + "until": { + "default": "", + "title": "Until", + "type": "string" + } + }, + "title": "changesArguments", + "type": "object" + }, + "name": "changes" + }, + { + "description": "Дифф правки страницы вики: шапка (код, название, путь, версия «N из M», автор, время, «+A / −R», предыдущая\nверсия) и unified diff с offset, до 20 000 символов за вызов; если дифф длиннее, последняя строка — «продолжение:\noffset=». Без version — последняя правка. Команд не ставит.", + "inputSchema": { + "properties": { + "code": { + "title": "Code", + "type": "string" + }, + "offset": { + "default": 0, + "title": "Offset", + "type": "integer" + }, + "version": { + "default": "", + "title": "Version", + "type": "string" + } + }, + "required": [ + "code" + ], + "title": "diffArguments", + "type": "object" + }, + "name": "diff" + }, { "description": "Скачать вложение страницы вики и вернуть путь к локальной копии (читайте файл по этому пути). id — номер из\nответа page. Хаб просит агента скачать файл; ожидание до 60 с, не дождался — вызовите инструмент ещё раз: вторая\nкоманда не поставится.", "inputSchema": { @@ -84,7 +148,7 @@ "name": "attachment" }, { - "description": "Синхронизировать вики проекта с Eva: ставит пакет (или сообщает о текущем) и возвращает его id. wait=true —\nдождаться итога (до 300 с).", + "description": "Синхронизировать вики проекта с Eva: ставит пакет и отвечает «поставлено: пакет N» или «уже идёт: пакет N».\nwait=true — ждёт итога до 300 с: «ок: синхронизировано ДД.ММ ЧЧ:ММ», «ошибка: <текст>» или «не дождался за 300 с:\nпакет N». Данных копии в ответе нет — их читают page, changes, status.", "inputSchema": { "properties": { "wait": { diff --git a/tests/test_wiki_mcp.py b/tests/test_wiki_mcp.py index a7c39ab..8beac38 100644 --- a/tests/test_wiki_mcp.py +++ b/tests/test_wiki_mcp.py @@ -3,6 +3,8 @@ import asyncio import pytest +from datetime import datetime, timedelta, timezone + from sdlc_hub.core import mcp as core_mcp from sdlc_hub.core import registry from sdlc_hub.core.client import HubError @@ -73,6 +75,8 @@ def test_unbound_tools_refuse(monkeypatch): assert wmcp.tree() == core_mcp.NOT_BOUND assert wmcp.search("x") == core_mcp.NOT_BOUND assert wmcp.status() == core_mcp.NOT_BOUND + assert wmcp.changes() == core_mcp.NOT_BOUND + assert wmcp.diff("DOC-1") == core_mcp.NOT_BOUND assert wmcp.root_set("DOC-1") == core_mcp.NOT_BOUND assert asyncio.run(wmcp.sync()) == core_mcp.NOT_BOUND assert fake.calls == [] @@ -84,6 +88,7 @@ def test_no_root_message(hub): assert wmcp.tree() == msg assert wmcp.page(code="DOC-1") == msg assert wmcp.search("x") == msg + assert wmcp.changes() == msg and wmcp.diff("DOC-1") == msg assert asyncio.run(wmcp.sync()) == msg assert asyncio.run(wmcp.attachment(1)) == msg assert not any(p.startswith("/wiki/") for p in paths_of(fake)) @@ -216,29 +221,6 @@ def test_status_no_root_still_shows_agent(hub): assert not any(p.startswith("/wiki/") for p in paths_of(fake)) -def test_sync_nowait_and_in_progress(hub): - fake = hub({("POST", "/wiki/sync"): [{"batch_id": 5}, HubError(409, "sync_in_progress", {"batch_id": 5})]}) - assert "#5" in asyncio.run(wmcp.sync()) - assert fake.calls[-1] == ("POST", "/wiki/sync", {"project": "AWPCA", "author": "claude"}) - out = asyncio.run(wmcp.sync()) - assert "уже идёт" in out and "#5" in out - - -def test_sync_wait(hub): - done = {"id": 5, "status": "done", "commands": [{"id": 1, "type": "wiki_sync", "status": "done", "error": None}]} - hub({("POST", "/wiki/sync"): {"batch_id": 5}, - "/batches/5": [{"id": 5, "status": "running", "commands": []}, done], - "/wiki/status": {"root": "DOC-1", "root_title": "К", "synced_at": "s", "pages": 3, "error": None, "batch": None}}) - out = asyncio.run(wmcp.sync(wait=True)) - assert "#5" in out and "done" in out and "страниц: 3" in out - - -def test_sync_wait_timeout(hub, monkeypatch): - monkeypatch.setattr(wmcp, "SYNC_WAIT", 0.05) - hub({("POST", "/wiki/sync"): {"batch_id": 5}, "/batches/5": {"id": 5, "status": "running", "commands": []}}) - assert "ещё выполняется" in asyncio.run(wmcp.sync(wait=True)) - - ATT_NAME = "../../etc/pass:wd.xlsx" @@ -293,7 +275,8 @@ def test_run_wiki_module(): def test_wiki_tool_names_order(): names = [t.name for t in asyncio.run(wmcp.mcp.list_tools())] - assert names == ["tree", "page", "search", "attachment", "sync", "status", "root_set", "projects", "project_create"] + assert names == ["tree", "page", "search", "changes", "diff", "attachment", "sync", "status", "root_set", "projects", + "project_create"] def test_projects_table_and_create(hub): @@ -338,11 +321,150 @@ def test_search_tree_candidates_newlines_collapsed(hub): assert cands[0].startswith("под название «T» подходит") and "DOC-2 T U" in cands and "DOC-3 T V" in cands -def test_sync_wait_error_and_root_title_one_line(hub): - done = {"id": 5, "status": "done", "commands": [{"id": 1, "type": "wiki_sync", "status": "failed", "error": "Eva: 503\nпуть: x"}]} +SOSNOV = {"login": "sosnov", "name": "Дмитрий Соснов"} +MOLCH = {"login": "molchanov", "name": "Илья Молчанов"} +TITLES = {"DOC-3": ("Статусы", "АРМ КА/01_Статусы"), "DOC-2": ("Статистика", "АРМ КА/02_Статистика")} + + +def today_at(h, m): + """ISO UTC момента сегодня в местное h:m (как db.now()).""" + return datetime.now().astimezone().replace(hour=h, minute=m, second=0, microsecond=0).astimezone(timezone.utc).isoformat(timespec="seconds") + + +def loc(iso): + return datetime.fromisoformat(iso).astimezone() + + +def chg(code, version, at, added, removed, author=SOSNOV): + title, path = TITLES[code] + return {"at": at, "page_code": code, "title": title, "path": path, "version": version, "author": author, + "added": added, "removed": removed} + + +def period(**kw): + return {"since": today_at(0, 0), "until": today_at(14, 5), "synced_at": today_at(14, 0), + "history_at": today_at(14, 0), "truncated": False, "changes": [], **kw} + + +def test_since_of(): + now = datetime(2026, 10, 28, 14, 5).astimezone() + assert core_mcp.since_of(2, "", now) == (now - timedelta(hours=2)).isoformat() + assert core_mcp.since_of(None, "", now) == now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat() + assert core_mcp.since_of(None, "2026-10-01T00:00", now) == "2026-10-01T00:00" + + +def test_changes_today_grouped(hub): # сценарий «Что сделали аналитики сегодня» + t1, t2 = today_at(11, 2), today_at(12, 40) + fake = hub({"/wiki/changes": period(changes=[chg("DOC-3", "DOCH-2", t2, 40, 12), chg("DOC-3", "DOCH-1", t1, 5, 0), + chg("DOC-2", "DOCH-9", "2026-10-26T09:00:00+00:00", 1, 1, MOLCH)])}) + lines = wmcp.changes().splitlines() + assert lines[0] == f"правки вики с {loc(today_at(0, 0)):%d.%m %H:%M} по {loc(today_at(14, 5)):%d.%m %H:%M}" + assert lines[1] == f"синхронизировано: {loc(today_at(14, 0)):%d.%m %H:%M}" + assert lines[2:5] == ["DOC-3 Статусы — АРМ КА/01_Статусы", " 12:40 Дмитрий Соснов: +40 / −12 (DOCH-2)", + " 11:02 Дмитрий Соснов: +5 / −0 (DOCH-1)"] + assert lines[5] == "DOC-2 Статистика — АРМ КА/02_Статистика" + assert lines[6] == f" {loc('2026-10-26T09:00:00+00:00'):%d.%m %H:%M} Илья Молчанов: +1 / −1 (DOCH-9)" + params = fake.calls[-1][2] + assert fake.calls[-1][1] == "/wiki/changes" and params["project"] == "AWPCA" + assert params["since"] == datetime.now().astimezone().replace(hour=0, minute=0, second=0, microsecond=0).isoformat() + assert not any(m == "POST" for m, _, _ in fake.calls) # команд не поставлено + + +def test_changes_period_args(hub): + fake = hub({"/wiki/changes": period()}) + assert wmcp.changes(hours=2, since="2026-10-28T09:00") == "либо hours, либо since" + assert wmcp.changes(hours=0) == "hours — число больше 0" + assert not any(p == "/wiki/changes" for p in paths_of(fake)) + wmcp.changes(since="2026-10-28T09:00", author="соснов") + params = fake.calls[-1][2] + assert params["since"] == loc("2026-10-28T09:00").isoformat() and params["author"] == "соснов" + + +def test_changes_messages(hub): + hub({"/wiki/changes": period()}) + assert wmcp.changes().splitlines()[-1] == "за период правок нет" + hub({"/wiki/changes": period(history_at=None)}) + assert wmcp.changes().splitlines()[-1] == "история ещё не загружена: нужен eva-agent v2 и sync" + rows = [chg("DOC-3", "DOCH-2", today_at(12, 40), 40, 12), chg("DOC-3", "DOCH-1", today_at(11, 2), 5, 0)] + hub({"/wiki/changes": period(truncated=True, changes=rows)}) + assert wmcp.changes().splitlines()[-1] == "показаны последние 2 правок, сузьте период" + hub({"/wiki/changes": HubError(422, "bad_request", "since: позже until")}) + assert wmcp.changes() == "ошибка хаба 422 bad_request: since: позже until" + + +def test_changes_title_newlines(hub): + row = {**chg("DOC-3", "DOCH-1", today_at(11, 2), 5, 0), "title": "Статусы\nпуть: x"} + hub({"/wiki/changes": period(changes=[row])}) + assert wmcp.changes().splitlines()[2] == "DOC-3 Статусы путь: x — АРМ КА/01_Статусы" + + +DIFF = {"version": "DOCH-4", "at": today_at(12, 40), "author": SOSNOV, "index": 4, "total": 4, "prev_version": "DOCH-3", + "added": 40, "removed": 12, "offset": 0, "diff": "@@ -1 +1 @@\n-старое\n+новое\n", "next_offset": None, + "code": "DOC-3", "title": "Статусы", "path": "АРМ КА/01_Статусы"} + + +def test_diff_header_and_chunk(hub): # сценарий «Дифф правки» + fake = hub({"/wiki/pages/DOC-3/diff": DIFF}) + out = wmcp.diff("DOC-3") + assert out.splitlines()[:8] == ["DOC-3 — Статусы", "путь: АРМ КА/01_Статусы", "версия 4 из 4: DOCH-4", + "автор: Дмитрий Соснов", f"время: {loc(today_at(12, 40)):%d.%m %H:%M}", "+40 / −12", + "предыдущая: DOCH-3", ""] + assert "-старое\n+новое\n" in out + assert fake.calls[-1] == ("GET", "/wiki/pages/DOC-3/diff", {"project": "AWPCA", "offset": 0}) + hub({"/wiki/pages/DOC-3/diff": {**DIFF, "next_offset": 20000, "diff": "x" * 20000}}) + assert wmcp.diff("DOC-3").splitlines()[-1] == "продолжение: offset=20000" + hub({"/wiki/pages/DOC-3/diff": {**DIFF, "index": 1, "total": 4, "prev_version": None}}) + assert "предыдущая: нет (первая версия)" in wmcp.diff("DOC-3").splitlines() + fake = hub({"/wiki/pages/DOC-3/diff": DIFF}) + wmcp.diff(code="DOC-3", version="DOCH-3", offset=20000) + assert fake.calls[-1] == ("GET", "/wiki/pages/DOC-3/diff", {"project": "AWPCA", "version": "DOCH-3", "offset": 20000}) + + +def test_page_modified_line(hub): + base = {"code": "DOC-3", "title": "Статусы", "path": "p", "url": "u", "synced_at": "2026-10-24T10:00:00+00:00", + "markdown": "тело", "attachments": [], "modified_at": "2026-10-27T20:48:02+00:00", "modified_by": SOSNOV, + "versions": 4} + hub({"/wiki/pages/DOC-3": base}) + lines = wmcp.page(code="DOC-3").splitlines() + assert lines[3] == "синхронизировано: 2026-10-24T10:00:00+00:00" + assert lines[4] == f"изменено: {loc('2026-10-27T20:48:02+00:00'):%d.%m %H:%M} Дмитрий Соснов (версий: 4)" + hub({"/wiki/pages/DOC-3": {**base, "modified_at": None, "modified_by": None, "versions": 0}}) + assert "изменено: история не загружена" in wmcp.page(code="DOC-3").splitlines() + + +def test_sync_short(hub): + fake = hub({("POST", "/wiki/sync"): [{"batch_id": 5}, HubError(409, "sync_in_progress", {"batch_id": 5})]}) + assert asyncio.run(wmcp.sync()) == "поставлено: пакет 5" + assert fake.calls[-1] == ("POST", "/wiki/sync", {"project": "AWPCA", "author": "claude"}) + assert asyncio.run(wmcp.sync()) == "уже идёт: пакет 5" + + +def test_sync_wait_ok(hub): + done = {"id": 5, "status": "done", "commands": [{"id": 1, "type": "wiki_sync", "status": "done", "error": None}]} hub({("POST", "/wiki/sync"): {"batch_id": 5}, "/batches/5": done, - "/wiki/status": {"root": "DOC-1", "root_title": "К\nстраниц: 999", "synced_at": "s", "pages": 3, - "error": "Eva: 503\nпуть: x", "batch": None}}) - lines = asyncio.run(wmcp.sync(wait=True)).splitlines() - assert " #1 wiki_sync failed: Eva: 503 путь: x" in lines and "ошибка синхронизации: Eva: 503 путь: x" in lines - assert not any(line.startswith("путь:") or line.startswith("страниц:") for line in lines) + "/wiki/status": {"root": "DOC-1", "root_title": "К", "synced_at": today_at(14, 0), "pages": 3, "error": None, + "batch": None, "history_at": None}}) + assert asyncio.run(wmcp.sync(wait=True)) == f"ок: синхронизировано {loc(today_at(14, 0)):%d.%m %H:%M}" + + +def test_sync_wait_error(hub): + failed = {"id": 5, "status": "done", "commands": [{"id": 1, "type": "wiki_sync", "status": "failed", + "error": "Eva: 503\nпуть: x"}]} + hub({("POST", "/wiki/sync"): {"batch_id": 5}, "/batches/5": failed}) + assert asyncio.run(wmcp.sync(wait=True)) == "ошибка: Eva: 503 путь: x" + hub({("POST", "/wiki/sync"): {"batch_id": 5}, "/batches/5": {"id": 5, "status": "cancelled", "commands": []}}) + assert asyncio.run(wmcp.sync(wait=True)) == "ошибка: пакет 5 отменён" + + +def test_sync_wait_timeout(hub, monkeypatch): + monkeypatch.setattr(wmcp, "SYNC_WAIT", 0.05) + hub({("POST", "/wiki/sync"): {"batch_id": 5}, "/batches/5": {"id": 5, "status": "running", "commands": []}}) + assert asyncio.run(wmcp.sync(wait=True)) == f"не дождался за {wmcp.SYNC_WAIT:g} с: пакет 5" + + +def test_status_history(hub): + base = {"root": "DOC-1", "root_title": "Корень", "synced_at": "s", "pages": 3, "error": None, "batch": None} + hub({"/wiki/status": {**base, "history_at": "2026-10-28T09:00:00+00:00"}, "/agent": agent_ok()}) + assert "история: 2026-10-28T09:00:00+00:00" in wmcp.status() + hub({"/wiki/status": {**base, "history_at": None}, "/agent": agent_ok()}) + assert "история не загружена" in wmcp.status()