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` |
| `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` |
### Контракт модуля

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`), они отвечают «у проекта <метка> нет корня вики».
- `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` — спринты доски с суммами
задач и SP «выполнено/всего»; `sprint [name]` — спринт (по умолчанию активный) с задачами по колонкам доски;
`backlog [limit]` — бэклог по рангу (по умолчанию 50 строк). В `issues` есть колонка SP. Команды спринтов и ранга ставятся

View File

@ -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 "очередь свободна"

View File

@ -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)

View File

@ -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=<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()
@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()

View File

@ -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=<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команда не поставится.",
"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": {

View File

@ -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()