MCP вики: changes, diff, «изменено» в page, короткий sync (#140)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
060270b854
commit
68c7f23410
@ -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` |
|
||||
|
||||
### Контракт модуля
|
||||
|
||||
|
||||
@ -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. Команды спринтов и ранга ставятся
|
||||
|
||||
@ -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 "очередь свободна"
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user