MCP вики: changes, diff, «изменено» в page, короткий sync (#140)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 12:35:22 +04:00
parent 060270b854
commit 68c7f23410
7 changed files with 349 additions and 60 deletions

View File

@ -71,7 +71,7 @@
| `attachments` | вложения по запросу: состояние `none`/`fetching`/`ready`/`failed`, постановка `wiki_fetch`, хуки результата; адрес берётся только из `wiki_attachment` | `core.queue`, `core.files` | | `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` | | `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` | | `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` |
### Контракт модуля ### Контракт модуля

View File

@ -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`), они отвечают «у проекта <метка> нет корня вики». вики (`root_set DOC-…` или `wiki_root` в `project_create`), они отвечают «у проекта <метка> нет корня вики».
- `changes [hours] [since] [until] [author]` — правки страниц за период (по умолчанию — с начала суток), по страницам, новые сверху;
- `diff code [version] [offset]` — дифф правки (без `version` — последняя), по частям 20 000 символов с `продолжение: offset=<N>`;
- `page` — шапка с «изменено: ДД.ММ ЧЧ:ММ <автор> (версий: N)»; `sync` — коротко: «поставлено: пакет N», «уже идёт: пакет N», с `wait` — «ок: синхронизировано …» или «ошибка: …»;
- `status` — дополнительно строка «история: <время>» или «история не загружена».
`page`, `changes`, `diff` команд не ставят: читают копию хаба.
Спринты и бэклог (нужна привязка к проекту; читают `GET /agile` проекта сессии): `sprints` — спринты доски с суммами Спринты и бэклог (нужна привязка к проекту; читают `GET /agile` проекта сессии): `sprints` — спринты доски с суммами
задач и SP «выполнено/всего»; `sprint [name]` — спринт (по умолчанию активный) с задачами по колонкам доски; задач и SP «выполнено/всего»; `sprint [name]` — спринт (по умолчанию активный) с задачами по колонкам доски;
`backlog [limit]` — бэклог по рангу (по умолчанию 50 строк). В `issues` есть колонка SP. Команды спринтов и ранга ставятся `backlog [limit]` — бэклог по рангу (по умолчанию 50 строк). В `issues` есть колонка SP. Команды спринтов и ранга ставятся

View File

@ -9,6 +9,7 @@ import os
import sys import sys
import tempfile import tempfile
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from . import paths, registry from . import paths, registry
@ -99,6 +100,26 @@ def value(v):
return "—" if v in (None, "", []) else str(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: def fmt_agent(a) -> str:
state = "онлайн" if a.get("online") else "офлайн" state = "онлайн" if a.get("online") else "офлайн"
cur = f"выполняет #{a['current_command_id']}" if a.get("current_command_id") else "очередь свободна" cur = f"выполняет #{a['current_command_id']}" if a.get("current_command_id") else "очередь свободна"

View File

@ -7,7 +7,7 @@ import json
import stat import stat
import time import time
import unicodedata import unicodedata
from datetime import datetime, timedelta from datetime import datetime
from pathlib import Path from pathlib import Path
from mcp.server.fastmcp import FastMCP 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}") 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() @mcp.tool()
@core.guard @core.guard
def changes(hours: float | None = None, since: str = "", until: str = "", kinds: str = "", key: str = "") -> str: 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: if refusal is not None:
return refusal return refusal
now = datetime.now().astimezone() now = datetime.now().astimezone()
if hours is not None: since = core.since_of(hours, since, now)
since = (now - timedelta(hours=hours)).isoformat() data = core.client().get("/changes", since=core.local_if_naive(since), until=core.local_if_naive(until), kinds=kinds, key=key,
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,
project=None if key else core.PROJECT) project=None if key else core.PROJECT)
return fmt_changes(data, now) return fmt_changes(data, now)

View File

@ -4,6 +4,7 @@
import asyncio import asyncio
import re import re
import time import time
from datetime import datetime
from urllib.parse import quote from urllib.parse import quote
from mcp.server.fastmcp import FastMCP 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] 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() @mcp.tool()
@core.guard @core.guard
@core.scoped(True) @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) text, offset = d.get("markdown") or "", max(int(offset), 0)
atts = d.get("attachments") or [] atts = d.get("attachments") or []
head = [f"{_line(d['code'])} — {_line(d.get('title'))}", f"путь: {_line(d.get('path'))}", 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] head += ([f"вложения ({len(atts)}):"] + [f" #{a['id']} {_line(a.get('name'))} ({_line(a.get('state'))})" for a in atts]
if atts else ["вложений нет"]) if atts else ["вложений нет"])
if offset >= len(text) and (offset > 0 or not text): 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) 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=<N>». Без 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() @mcp.tool()
@core.guard @core.guard
@core.scoped(True) @core.scoped(True)
@ -180,6 +263,7 @@ def _summary(client) -> str:
lines.append(f"ошибка синхронизации: {_line(s['error'])}") lines.append(f"ошибка синхронизации: {_line(s['error'])}")
b = s.get("batch") b = s.get("batch")
lines.append(f"пакет: #{b['id']} {b['status']}" if b else "пакета в работе нет") 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) return "\n".join(lines)
@ -187,20 +271,21 @@ def _summary(client) -> str:
@core.guard @core.guard
@core.scoped(True) @core.scoped(True)
async def sync(wait: bool = False) -> str: async def sync(wait: bool = False) -> str:
"""Синхронизировать вики проекта с Eva: ставит пакет (или сообщает о текущем) и возвращает его id. wait=true — """Синхронизировать вики проекта с Eva: ставит пакет и отвечает «поставлено: пакет N» или «уже идёт: пакет N».
дождаться итога (до 300 с).""" wait=true — ждёт итога до 300 с: «ок: синхронизировано ДД.ММ ЧЧ:ММ», «ошибка: <текст>» или «не дождался за 300 с:
пакет N». Данных копии в ответе нет — их читают page, changes, status."""
refusal = await asyncio.to_thread(_gate, False) refusal = await asyncio.to_thread(_gate, False)
if refusal is not None: if refusal is not None:
return refusal return refusal
client = core.client() client = core.client()
try: try:
batch_id = (await asyncio.to_thread(client.post, "/wiki/sync", {"project": core.PROJECT, "author": core.AUTHOR}))["batch_id"] 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: except HubError as e:
if e.error != "sync_in_progress" or not isinstance(e.detail, dict): if e.error != "sync_in_progress" or not isinstance(e.detail, dict):
raise raise
batch_id = e.detail["batch_id"] batch_id = e.detail["batch_id"]
head = f"синхронизация уже идёт: пакет #{batch_id}" head = f"уже идёт: пакет {batch_id}"
if not wait: if not wait:
return head return head
deadline = time.monotonic() + SYNC_WAIT 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: if b["status"] in ("done", "cancelled") or time.monotonic() >= deadline:
break break
await asyncio.sleep(POLL_SECONDS) 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"): if b["status"] not in ("done", "cancelled"):
return "\n".join(lines + ["(таймаут ожидания, пакет ещё выполняется)"]) return f"не дождался за {SYNC_WAIT:g} с: пакет {batch_id}"
return "\n".join(lines + [await asyncio.to_thread(_summary, client)]) 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() @mcp.tool()

View File

@ -66,6 +66,70 @@
}, },
"name": "search" "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=<N>». Без 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команда не поставится.", "description": "Скачать вложение страницы вики и вернуть путь к локальной копии (читайте файл по этому пути). id — номер из\nответа page. Хаб просит агента скачать файл; ожидание до 60 с, не дождался — вызовите инструмент ещё раз: вторая\nкоманда не поставится.",
"inputSchema": { "inputSchema": {
@ -84,7 +148,7 @@
"name": "attachment" "name": "attachment"
}, },
{ {
"description": "Синхронизировать вики проекта с Eva: ставит пакет (или сообщает о текущем) и возвращает его id. wait=true —\nдождаться итога (до 300 с).", "description": "Синхронизировать вики проекта с Eva: ставит пакет и отвечает «поставлено: пакет N» или «уже идёт: пакет N».\nwait=true — ждёт итога до 300 с: «ок: синхронизировано ДД.ММ ЧЧ:ММ», «ошибка: <текст>» или «не дождался за 300 с:\nпакет N». Данных копии в ответе нет — их читают page, changes, status.",
"inputSchema": { "inputSchema": {
"properties": { "properties": {
"wait": { "wait": {

View File

@ -3,6 +3,8 @@ import asyncio
import pytest import pytest
from datetime import datetime, timedelta, timezone
from sdlc_hub.core import mcp as core_mcp from sdlc_hub.core import mcp as core_mcp
from sdlc_hub.core import registry from sdlc_hub.core import registry
from sdlc_hub.core.client import HubError 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.tree() == core_mcp.NOT_BOUND
assert wmcp.search("x") == core_mcp.NOT_BOUND assert wmcp.search("x") == core_mcp.NOT_BOUND
assert wmcp.status() == 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 wmcp.root_set("DOC-1") == core_mcp.NOT_BOUND
assert asyncio.run(wmcp.sync()) == core_mcp.NOT_BOUND assert asyncio.run(wmcp.sync()) == core_mcp.NOT_BOUND
assert fake.calls == [] assert fake.calls == []
@ -84,6 +88,7 @@ def test_no_root_message(hub):
assert wmcp.tree() == msg assert wmcp.tree() == msg
assert wmcp.page(code="DOC-1") == msg assert wmcp.page(code="DOC-1") == msg
assert wmcp.search("x") == 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.sync()) == msg
assert asyncio.run(wmcp.attachment(1)) == msg assert asyncio.run(wmcp.attachment(1)) == msg
assert not any(p.startswith("/wiki/") for p in paths_of(fake)) 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)) 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" ATT_NAME = "../../etc/pass:wd.xlsx"
@ -293,7 +275,8 @@ def test_run_wiki_module():
def test_wiki_tool_names_order(): def test_wiki_tool_names_order():
names = [t.name for t in asyncio.run(wmcp.mcp.list_tools())] 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): 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 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): SOSNOV = {"login": "sosnov", "name": "Дмитрий Соснов"}
done = {"id": 5, "status": "done", "commands": [{"id": 1, "type": "wiki_sync", "status": "failed", "error": "Eva: 503\nпуть: x"}]} 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, hub({("POST", "/wiki/sync"): {"batch_id": 5}, "/batches/5": done,
"/wiki/status": {"root": "DOC-1", "root_title": "К\nстраниц: 999", "synced_at": "s", "pages": 3, "/wiki/status": {"root": "DOC-1", "root_title": "К", "synced_at": today_at(14, 0), "pages": 3, "error": None,
"error": "Eva: 503\nпуть: x", "batch": None}}) "batch": None, "history_at": None}})
lines = asyncio.run(wmcp.sync(wait=True)).splitlines() assert asyncio.run(wmcp.sync(wait=True)) == f"ок: синхронизировано {loc(today_at(14, 0)):%d.%m %H:%M}"
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)
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()