wiki: MCP-сервер jira-hub-wiki (#91)
fmt_agent, safe_name и store_copy вынесены в core/mcp.py; сервер jira их переэкспортирует, тексты вывода не менялись. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c650b8fa08
commit
2a5a33f9c6
@ -5,11 +5,13 @@ import asyncio
|
||||
import functools
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from . import registry
|
||||
from . import paths, registry
|
||||
from .client import HubClient, HubError, HubUnavailable
|
||||
|
||||
OFFLINE = "хаб не запущен: jira-hub serve"
|
||||
@ -100,6 +102,67 @@ def value(v):
|
||||
return "—" if v in (None, "", []) else str(v)
|
||||
|
||||
|
||||
def fmt_agent(a) -> str:
|
||||
state = "онлайн" if a.get("online") else "офлайн"
|
||||
cur = f"выполняет #{a['current_command_id']}" if a.get("current_command_id") else "очередь свободна"
|
||||
out = [f"агент {state} (версия {value(a.get('agent_version'))}, настройки v{value(a.get('settings_version'))}, последний контакт {value(a.get('last_seen_at'))}); {cur}; "
|
||||
f"429/503 за сутки: {a.get('rate_limited_24h', 0)}"]
|
||||
if a.get("warning"):
|
||||
out.append("внимание: " + a["warning"])
|
||||
for s in a.get("redelivered", []):
|
||||
out.append(f"выдана повторно: #{s['id']} {s['type']}, выдач {s['deliveries']}, последняя {s['claimed_at']}")
|
||||
rel = a.get("release")
|
||||
out.append(f"публикация: v{rel['version']} от {value(rel.get('published_at'))}" if rel else "публикации нет")
|
||||
us = a.get("update_state")
|
||||
if us:
|
||||
out.append(f"обновление: {us.get('state')} v{value(us.get('from_version'))} → v{value(us.get('to_version'))}")
|
||||
lv = a.get("launcher_version")
|
||||
if a.get("agent_reported") is False: # хаб перезапущен, claim ещё не было (решение 8); старый хаб поля не шлёт
|
||||
out.append("запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)")
|
||||
else:
|
||||
out.append(f"запускатель: v{lv}" if lv is not None else "без запускателя")
|
||||
if a.get("update_hint"):
|
||||
out.append(a["update_hint"])
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def safe_name(filename: str, attachment_id: str) -> str:
|
||||
"""Имя файла копии: «/ \\ :» и управляющие символы (< 32, 127) → «_»; не длиннее 255 байт UTF-8; пусто, «.», «..» → attachment-<id>."""
|
||||
name = "".join("_" if ch in "/\\:" or ord(ch) < 32 or ord(ch) == 127 else ch for ch in filename or "")
|
||||
if len(name.encode()) > 255: # предел имени в ФС — 255 байт: режем основу, расширение сохраняем
|
||||
stem, dot, ext = name.rpartition(".")
|
||||
if not dot or len(ext.encode()) > 32: # «расширение» из одного длинного хвоста — просто режем всё имя
|
||||
stem, ext = name, ""
|
||||
else:
|
||||
ext = "." + ext
|
||||
stem = stem.encode()[:255 - len(ext.encode())].decode("utf-8", "ignore")
|
||||
name = stem + ext
|
||||
return name if name.strip() and name.strip() not in (".", "..") else f"attachment-{attachment_id}"
|
||||
|
||||
|
||||
def store_copy(attachment_id: str, name: str, data: bytes) -> Path:
|
||||
"""Копия вложения в <каталог данных>/downloads/<id>/<имя>: каталоги 0700, запись через временный файл и
|
||||
os.replace; файл того же размера уже на месте — не трогаем."""
|
||||
root = paths.db_path().parent / "downloads"
|
||||
folder = root / attachment_id
|
||||
for d in (root, folder):
|
||||
d.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
d.chmod(0o700)
|
||||
target = folder / name
|
||||
if target.is_file() and target.stat().st_size == len(data):
|
||||
os.utime(folder) # копия снова нужна — уборка по mtime папки её не заденет
|
||||
return target
|
||||
fd, tmp = tempfile.mkstemp(dir=folder, prefix=".part-")
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(data)
|
||||
os.replace(tmp, target)
|
||||
except BaseException:
|
||||
Path(tmp).unlink(missing_ok=True)
|
||||
raise
|
||||
return target
|
||||
|
||||
|
||||
def fmt_projects(items, current: str | None, columns: list[tuple[str, Callable[[dict], str]]]) -> str:
|
||||
"""Таблица проектов: метка, название и колонки модуля (заголовок, функция строки проекта)."""
|
||||
if not items:
|
||||
|
||||
@ -4,9 +4,7 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
@ -14,7 +12,6 @@ from pathlib import Path
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from ...core import mcp as core
|
||||
from ...core import paths
|
||||
from ...core.client import HubError
|
||||
from ...core.files import MAX_SIZE
|
||||
from .attachments import fmt_size
|
||||
@ -23,6 +20,7 @@ from .commands import describe, is_temp
|
||||
POLL_SECONDS = 5
|
||||
BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file"
|
||||
mcp = FastMCP("jira-hub")
|
||||
fmt_agent = core.fmt_agent
|
||||
|
||||
|
||||
def fmt_issues(issues) -> str:
|
||||
@ -63,18 +61,7 @@ def _files_line(c: dict, atts: list) -> str:
|
||||
return "файлы: " + ", ".join(parts) if parts else ""
|
||||
|
||||
|
||||
def safe_name(filename: str, attachment_id: str) -> str:
|
||||
"""Имя файла копии: «/ \\ :» и управляющие символы (< 32, 127) → «_»; не длиннее 255 байт UTF-8; пусто, «.», «..» → attachment-<id>."""
|
||||
name = "".join("_" if ch in "/\\:" or ord(ch) < 32 or ord(ch) == 127 else ch for ch in filename or "")
|
||||
if len(name.encode()) > 255: # предел имени в ФС — 255 байт: режем основу, расширение сохраняем
|
||||
stem, dot, ext = name.rpartition(".")
|
||||
if not dot or len(ext.encode()) > 32: # «расширение» из одного длинного хвоста — просто режем всё имя
|
||||
stem, ext = name, ""
|
||||
else:
|
||||
ext = "." + ext
|
||||
stem = stem.encode()[:255 - len(ext.encode())].decode("utf-8", "ignore")
|
||||
name = stem + ext
|
||||
return name if name.strip() and name.strip() not in (".", "..") else f"attachment-{attachment_id}"
|
||||
safe_name = core.safe_name
|
||||
|
||||
|
||||
def fmt_issue(d) -> str:
|
||||
@ -188,30 +175,6 @@ def fmt_batch(b) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def fmt_agent(a) -> str:
|
||||
state = "онлайн" if a.get("online") else "офлайн"
|
||||
cur = f"выполняет #{a['current_command_id']}" if a.get("current_command_id") else "очередь свободна"
|
||||
out = [f"агент {state} (версия {core.value(a.get('agent_version'))}, настройки v{core.value(a.get('settings_version'))}, последний контакт {core.value(a.get('last_seen_at'))}); {cur}; "
|
||||
f"429/503 за сутки: {a.get('rate_limited_24h', 0)}"]
|
||||
if a.get("warning"):
|
||||
out.append("внимание: " + a["warning"])
|
||||
for s in a.get("redelivered", []):
|
||||
out.append(f"выдана повторно: #{s['id']} {s['type']}, выдач {s['deliveries']}, последняя {s['claimed_at']}")
|
||||
rel = a.get("release")
|
||||
out.append(f"публикация: v{rel['version']} от {core.value(rel.get('published_at'))}" if rel else "публикации нет")
|
||||
us = a.get("update_state")
|
||||
if us:
|
||||
out.append(f"обновление: {us.get('state')} v{core.value(us.get('from_version'))} → v{core.value(us.get('to_version'))}")
|
||||
lv = a.get("launcher_version")
|
||||
if a.get("agent_reported") is False: # хаб перезапущен, claim ещё не было (решение 8); старый хаб поля не шлёт
|
||||
out.append("запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)")
|
||||
else:
|
||||
out.append(f"запускатель: v{lv}" if lv is not None else "без запускателя")
|
||||
if a.get("update_hint"):
|
||||
out.append(a["update_hint"])
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
# --- инструменты -----------------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
@ -249,27 +212,7 @@ def comment(key: str, comment_id: str) -> str:
|
||||
return f"у {key} нет комментария #{cid}"
|
||||
|
||||
|
||||
def _store_copy(attachment_id: str, name: str, data: bytes) -> Path:
|
||||
"""Копия вложения в <каталог данных>/downloads/<id>/<имя>: каталоги 0700, запись через временный файл и
|
||||
os.replace; файл того же размера уже на месте — не трогаем."""
|
||||
root = paths.db_path().parent / "downloads"
|
||||
folder = root / attachment_id
|
||||
for d in (root, folder):
|
||||
d.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
d.chmod(0o700)
|
||||
target = folder / name
|
||||
if target.is_file() and target.stat().st_size == len(data):
|
||||
os.utime(folder) # копия снова нужна — уборка по mtime папки её не заденет
|
||||
return target
|
||||
fd, tmp = tempfile.mkstemp(dir=folder, prefix=".part-")
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(data)
|
||||
os.replace(tmp, target)
|
||||
except BaseException:
|
||||
Path(tmp).unlink(missing_ok=True)
|
||||
raise
|
||||
return target
|
||||
_store_copy = core.store_copy
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@ -517,7 +460,7 @@ async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool
|
||||
def agent_status() -> str:
|
||||
"""Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия,
|
||||
состояние самообновления и запускатель."""
|
||||
return fmt_agent(core.client().get("/agent"))
|
||||
return core.fmt_agent(core.client().get("/agent"))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
|
||||
@ -2,6 +2,12 @@
|
||||
from ...core.modules import Module
|
||||
from . import api, attachments, commands, projects, schema, settings, tick
|
||||
|
||||
def mcp_server():
|
||||
"""MCP-сервер модуля; FastMCP тянется лениво — только когда сервер запускают."""
|
||||
from . import mcp
|
||||
return mcp.mcp
|
||||
|
||||
|
||||
MODULE = Module(
|
||||
name="wiki",
|
||||
schema=schema.SQL,
|
||||
@ -13,6 +19,7 @@ MODULE = Module(
|
||||
routes=api.ROUTES,
|
||||
file_refs=attachments.file_refs,
|
||||
tick=(tick.housekeeping_tick, tick.auto_sync_tick),
|
||||
mcp_server=mcp_server,
|
||||
project_fields=projects.FIELDS,
|
||||
project_view=projects.view,
|
||||
on_project_created=projects.on_created,
|
||||
|
||||
261
src/jira_hub/modules/wiki/mcp.py
Normal file
261
src/jira_hub/modules/wiki/mcp.py
Normal file
@ -0,0 +1,261 @@
|
||||
"""MCP-инструменты модуля wiki (stdio, FastMCP, сервер jira-hub-wiki) — инструменты сессии Claude поверх HTTP API хаба
|
||||
(specs/mcp/spec.md «MCP вики»). Охрана, привязка к проекту и projects/project_create — в core/mcp.py. Markdown страниц и
|
||||
имена вложений приходят из Eva и считаются недоверенными: страница выдаётся текстом, имя файла сводится к basename."""
|
||||
import asyncio
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from ...core import mcp as core
|
||||
from ...core.client import HubError
|
||||
from . import projects as wiki_projects
|
||||
|
||||
POLL_SECONDS = 2
|
||||
ATTACHMENT_WAIT = 60
|
||||
SYNC_WAIT = 300
|
||||
CHUNK = 20000
|
||||
CANDIDATES = 10
|
||||
mcp = FastMCP("jira-hub-wiki")
|
||||
|
||||
|
||||
def _project() -> dict:
|
||||
return next(p for p in core.client().get("/projects")["projects"] if p["label"] == core.PROJECT)
|
||||
|
||||
|
||||
def _no_root(label) -> str:
|
||||
return f"у проекта {label} нет корня вики: root_set DOC-…"
|
||||
|
||||
|
||||
def _gate(need_sync: bool = True) -> str | None:
|
||||
"""Отказ вместо запроса к вики: нет корня или (need_sync) нет ни одной синхронизации. None — можно работать."""
|
||||
p = _project()
|
||||
if not p.get("wiki_root"):
|
||||
return _no_root(p["label"])
|
||||
if need_sync and not p.get("wiki_synced_at"):
|
||||
return "вики проекта ещё не синхронизирована: sync"
|
||||
return None
|
||||
|
||||
|
||||
def _walk(pages: list, code: str, depth: int) -> list:
|
||||
"""Страницы поддерева code в порядке обхода до depth уровней ниже него; code не найден — None."""
|
||||
start = next((i for i, p in enumerate(pages) if p["code"] == code), None)
|
||||
if start is None:
|
||||
return None
|
||||
base = pages[start]["depth"]
|
||||
out = [pages[start]]
|
||||
for p in pages[start + 1:]:
|
||||
if p["depth"] <= base:
|
||||
break
|
||||
out.append(p)
|
||||
return [p for p in out if p["depth"] - base <= depth]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@core.guard
|
||||
@core.scoped(True)
|
||||
def tree(code: str = "", depth: int = 2) -> str:
|
||||
"""Дерево вики проекта: от корня или от страницы code, depth уровней ниже неё (по умолчанию 2). Строка — отступ, код,
|
||||
название, размер Markdown в символах."""
|
||||
if (refusal := _gate()) is not None:
|
||||
return refusal
|
||||
pages = core.client().get("/wiki/tree", project=core.PROJECT)["pages"]
|
||||
if not pages:
|
||||
return "страниц нет"
|
||||
depth = max(int(depth), 0)
|
||||
if code:
|
||||
shown = _walk(pages, code.strip(), depth)
|
||||
if shown is None:
|
||||
return f"нет страницы {code}; дерево без кода — tree"
|
||||
else:
|
||||
base = min(p["depth"] for p in pages)
|
||||
shown = [p for p in pages if p["depth"] - base <= depth]
|
||||
base = shown[0]["depth"]
|
||||
return "\n".join(f"{' ' * (p['depth'] - base)}{p['code']} {core.value(p.get('title'))} ({core.value(p.get('size'))} симв.)"
|
||||
for p in shown)
|
||||
|
||||
|
||||
def _candidates(pages: list, title: str) -> str:
|
||||
low = title.casefold()
|
||||
found = [p for p in pages if low in (p.get("title") or "").casefold()]
|
||||
if not found:
|
||||
return f"нет страницы «{title}»; дерево — tree, поиск по тексту — search"
|
||||
lines = [f"{p['code']} {p.get('title')}" for p in found[:CANDIDATES]]
|
||||
more = [f"…и ещё {len(found) - CANDIDATES}"] if len(found) > CANDIDATES else []
|
||||
return "под название «%s» подходит несколько страниц, повторите page с code:\n" % title + "\n".join(lines + more)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@core.guard
|
||||
@core.scoped(True)
|
||||
def page(code: str = "", title: str = "", offset: int = 0) -> str:
|
||||
"""Страница вики: шапка (код, путь в дереве, адрес в Eva, дата синхронизации, вложения с id) и Markdown с символа
|
||||
offset, до 20 000 символов за вызов; если текст длиннее, последняя строка — «продолжение: offset=<N>». Страница
|
||||
задаётся кодом (DOC-…) или названием: точное название без учёта регистра открывает страницу, иначе будут показаны
|
||||
до 10 кандидатов с кодами."""
|
||||
if (refusal := _gate()) is not None:
|
||||
return refusal
|
||||
client = core.client()
|
||||
code, title = (code or "").strip(), (title or "").strip()
|
||||
if not code:
|
||||
if not title:
|
||||
return "нужен code или title"
|
||||
pages = client.get("/wiki/tree", project=core.PROJECT)["pages"]
|
||||
exact = [p for p in pages if (p.get("title") or "").casefold() == title.casefold()]
|
||||
if len(exact) != 1:
|
||||
return _candidates(exact or pages, title)
|
||||
code = exact[0]["code"]
|
||||
d = client.get(f"/wiki/pages/{quote(code, safe='')}", project=core.PROJECT)
|
||||
text, offset = d.get("markdown") or "", max(int(offset), 0)
|
||||
atts = d.get("attachments") or []
|
||||
head = [f"{d['code']} — {core.value(d.get('title'))}", f"путь: {core.value(d.get('path'))}",
|
||||
f"Eva: {core.value(d.get('url'))}", f"синхронизировано: {core.value(d.get('synced_at'))}"]
|
||||
head += ([f"вложения ({len(atts)}):"] + [f" #{a['id']} {a.get('name')} ({core.value(a.get('state'))})" for a in atts]
|
||||
if atts else ["вложений нет"])
|
||||
if offset >= len(text) and (offset > 0 or not text):
|
||||
body = f"offset={offset} за концом текста: длина {len(text)} символов" if offset > 0 else "страница пуста"
|
||||
return "\n".join(head + ["", body])
|
||||
end = offset + CHUNK
|
||||
out = "\n".join(head + ["", text[offset:end]])
|
||||
return out + f"\nпродолжение: offset={end}" if end < len(text) else out
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@core.guard
|
||||
@core.scoped(True)
|
||||
def search(query: str, limit: int = 10) -> str:
|
||||
"""Полнотекстовый поиск по страницам вики проекта: код, название, путь и фрагмент (limit — 1…50, по умолчанию 10)."""
|
||||
if (refusal := _gate()) is not None:
|
||||
return refusal
|
||||
items = core.client().get("/wiki/search", project=core.PROJECT, q=query, limit=limit)["results"]
|
||||
if not items:
|
||||
return "ничего не найдено"
|
||||
return "\n".join(f"{r['code']} {core.value(r.get('title'))} — {core.value(r.get('path'))}\n {core.value(r.get('snippet'))}"
|
||||
for r in items)
|
||||
|
||||
|
||||
def _store(attachment_id: int, name, data: bytes):
|
||||
"""Копия вложения в downloads/wiki-<id>/<basename>: имя из Eva недоверенное — берётся последний компонент пути."""
|
||||
base = (name or "").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
return core.store_copy(f"wiki-{attachment_id}", core.safe_name(base, str(attachment_id)), data)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@core.guard
|
||||
@core.scoped(True)
|
||||
async def attachment(id: int) -> str:
|
||||
"""Скачать вложение страницы вики и вернуть путь к локальной копии (читайте файл по этому пути). id — номер из
|
||||
ответа page. Хаб просит агента скачать файл; ожидание до 60 с, не дождался — вызовите инструмент ещё раз: вторая
|
||||
команда не поставится."""
|
||||
refusal = await asyncio.to_thread(_gate)
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
client = core.client()
|
||||
st = await asyncio.to_thread(client.post, f"/wiki/attachments/{id}/fetch", {"author": core.AUTHOR})
|
||||
deadline = time.monotonic() + ATTACHMENT_WAIT
|
||||
while st.get("state") == "fetching" and time.monotonic() < deadline:
|
||||
await asyncio.sleep(POLL_SECONDS)
|
||||
st = await asyncio.to_thread(client.get, f"/wiki/attachments/{id}")
|
||||
if st.get("state") == "fetching":
|
||||
return f"скачивание ещё идёт (вложение #{id}); вызовите attachment ещё раз — вторая команда не поставится"
|
||||
if st.get("state") != "ready":
|
||||
return f"не удалось скачать: {core.value(st.get('error'))}"
|
||||
data = await asyncio.to_thread(client.get_bytes, f"/wiki/attachments/{id}/content")
|
||||
path = await asyncio.to_thread(_store, id, st.get("name"), data)
|
||||
return f"{path}\n{core.value(st.get('name'))} ({len(data)} байт), страница {core.value(st.get('page_code'))}"
|
||||
|
||||
|
||||
def _summary(client) -> str:
|
||||
s = client.get("/wiki/status", project=core.PROJECT)
|
||||
lines = [f"корень: {core.value(s.get('root'))} {core.value(s.get('root_title'))}; страниц: {core.value(s.get('pages'))}; "
|
||||
f"последняя синхронизация: {core.value(s.get('synced_at'))}"]
|
||||
if s.get("error"):
|
||||
lines.append(f"ошибка синхронизации: {s['error']}")
|
||||
b = s.get("batch")
|
||||
lines.append(f"пакет: #{b['id']} {b['status']}" if b else "пакета в работе нет")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@core.guard
|
||||
@core.scoped(True)
|
||||
async def sync(wait: bool = False) -> str:
|
||||
"""Синхронизировать вики проекта с Eva: ставит пакет (или сообщает о текущем) и возвращает его id. wait=true —
|
||||
дождаться итога (до 300 с)."""
|
||||
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}"
|
||||
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}"
|
||||
if not wait:
|
||||
return head
|
||||
deadline = time.monotonic() + SYNC_WAIT
|
||||
while True:
|
||||
b = await asyncio.to_thread(client.get, f"/batches/{batch_id}")
|
||||
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": {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)])
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@core.guard
|
||||
@core.scoped(True)
|
||||
def status() -> str:
|
||||
"""Состояние вики проекта: корень, число страниц, последняя синхронизация, ошибка, пакет в работе; затем агент wiki
|
||||
(онлайн/офлайн, версия, публикация, обновление, запускатель)."""
|
||||
client = core.client()
|
||||
p = _project()
|
||||
head = _summary(client) if p.get("wiki_root") else _no_root(p["label"])
|
||||
return head + "\n" + core.fmt_agent(client.get("/agent", module="wiki"))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@core.guard
|
||||
@core.scoped(True)
|
||||
def root_set(root: str) -> str:
|
||||
"""Задать корень вики проекта: код DOC-… или ссылка на документ Eva. Хаб ставит синхронизацию нового корня; прежняя
|
||||
копия остаётся до её успеха."""
|
||||
try:
|
||||
code = wiki_projects.parse_root(root)
|
||||
except ValueError as e:
|
||||
return f"корень не задан: {e}; пример: root_set DOC-123"
|
||||
if code is None:
|
||||
return "корень не задан: укажите код DOC-… или ссылку на документ Eva"
|
||||
out = core.client().patch(f"/projects/{core.PROJECT}", {"wiki_root": code, "author": core.AUTHOR})
|
||||
ids = out.get("batch_ids") or []
|
||||
return (f"корень вики проекта {core.PROJECT}: {code}; синхронизация поставлена: пакет #{ids[0]}" if ids
|
||||
else f"корень вики проекта {core.PROJECT}: {code}; пакета синхронизации не поставлено (корень не изменился)")
|
||||
|
||||
|
||||
@core.guard
|
||||
def project_create(label: str, name: str, wiki_root: str = "") -> str:
|
||||
"""Завести проект (метка, название, необязательно корень вики: код DOC-… или ссылка Eva). С корнем хаб ставит
|
||||
синхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером jira-hub-wiki."""
|
||||
try:
|
||||
code = wiki_projects.parse_root(wiki_root)
|
||||
except ValueError as e:
|
||||
return f"проект не заведён: {e}"
|
||||
return core.create_project(label, name, {} if code is None else {"wiki_root": code}, "wiki")
|
||||
|
||||
|
||||
PROJECT_COLUMNS = [("корень вики", lambda p: core.value(p.get("wiki_root"))),
|
||||
("страниц", lambda p: core.value(p.get("wiki_pages"))),
|
||||
("синхронизация", lambda p: core.value(p.get("wiki_synced_at")))]
|
||||
|
||||
core.register_core_tools(
|
||||
mcp,
|
||||
projects_doc="Проекты хаба: метка, название, корень вики, страниц, последняя синхронизация; текущий проект сессии отмечен.",
|
||||
columns=PROJECT_COLUMNS,
|
||||
project_create=project_create)
|
||||
163
tests/fixtures/baseline/mcp_wiki_tools.expected.json
vendored
Normal file
163
tests/fixtures/baseline/mcp_wiki_tools.expected.json
vendored
Normal file
@ -0,0 +1,163 @@
|
||||
[
|
||||
{
|
||||
"description": "Дерево вики проекта: от корня или от страницы code, depth уровней ниже неё (по умолчанию 2). Строка — отступ, код,\nназвание, размер Markdown в символах.",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"default": "",
|
||||
"title": "Code",
|
||||
"type": "string"
|
||||
},
|
||||
"depth": {
|
||||
"default": 2,
|
||||
"title": "Depth",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"title": "treeArguments",
|
||||
"type": "object"
|
||||
},
|
||||
"name": "tree"
|
||||
},
|
||||
{
|
||||
"description": "Страница вики: шапка (код, путь в дереве, адрес в Eva, дата синхронизации, вложения с id) и Markdown с символа\noffset, до 20 000 символов за вызов; если текст длиннее, последняя строка — «продолжение: offset=<N>». Страница\nзадаётся кодом (DOC-…) или названием: точное название без учёта регистра открывает страницу, иначе будут показаны\nдо 10 кандидатов с кодами.",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"default": "",
|
||||
"title": "Code",
|
||||
"type": "string"
|
||||
},
|
||||
"offset": {
|
||||
"default": 0,
|
||||
"title": "Offset",
|
||||
"type": "integer"
|
||||
},
|
||||
"title": {
|
||||
"default": "",
|
||||
"title": "Title",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "pageArguments",
|
||||
"type": "object"
|
||||
},
|
||||
"name": "page"
|
||||
},
|
||||
{
|
||||
"description": "Полнотекстовый поиск по страницам вики проекта: код, название, путь и фрагмент (limit — 1…50, по умолчанию 10).",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"limit": {
|
||||
"default": 10,
|
||||
"title": "Limit",
|
||||
"type": "integer"
|
||||
},
|
||||
"query": {
|
||||
"title": "Query",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"query"
|
||||
],
|
||||
"title": "searchArguments",
|
||||
"type": "object"
|
||||
},
|
||||
"name": "search"
|
||||
},
|
||||
{
|
||||
"description": "Скачать вложение страницы вики и вернуть путь к локальной копии (читайте файл по этому пути). id — номер из\nответа page. Хаб просит агента скачать файл; ожидание до 60 с, не дождался — вызовите инструмент ещё раз: вторая\nкоманда не поставится.",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"title": "Id",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"title": "attachmentArguments",
|
||||
"type": "object"
|
||||
},
|
||||
"name": "attachment"
|
||||
},
|
||||
{
|
||||
"description": "Синхронизировать вики проекта с Eva: ставит пакет (или сообщает о текущем) и возвращает его id. wait=true —\nдождаться итога (до 300 с).",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"wait": {
|
||||
"default": false,
|
||||
"title": "Wait",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"title": "syncArguments",
|
||||
"type": "object"
|
||||
},
|
||||
"name": "sync"
|
||||
},
|
||||
{
|
||||
"description": "Состояние вики проекта: корень, число страниц, последняя синхронизация, ошибка, пакет в работе; затем агент wiki\n(онлайн/офлайн, версия, публикация, обновление, запускатель).",
|
||||
"inputSchema": {
|
||||
"properties": {},
|
||||
"title": "statusArguments",
|
||||
"type": "object"
|
||||
},
|
||||
"name": "status"
|
||||
},
|
||||
{
|
||||
"description": "Задать корень вики проекта: код DOC-… или ссылка на документ Eva. Хаб ставит синхронизацию нового корня; прежняя\nкопия остаётся до её успеха.",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"root": {
|
||||
"title": "Root",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"root"
|
||||
],
|
||||
"title": "root_setArguments",
|
||||
"type": "object"
|
||||
},
|
||||
"name": "root_set"
|
||||
},
|
||||
{
|
||||
"description": "Проекты хаба: метка, название, корень вики, страниц, последняя синхронизация; текущий проект сессии отмечен.",
|
||||
"inputSchema": {
|
||||
"properties": {},
|
||||
"title": "projectsArguments",
|
||||
"type": "object"
|
||||
},
|
||||
"name": "projects"
|
||||
},
|
||||
{
|
||||
"description": "Завести проект (метка, название, необязательно корень вики: код DOC-… или ссылка Eva). С корнем хаб ставит\nсинхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером jira-hub-wiki.",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"label": {
|
||||
"title": "Label",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"wiki_root": {
|
||||
"default": "",
|
||||
"title": "Wiki Root",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"label",
|
||||
"name"
|
||||
],
|
||||
"title": "project_createArguments",
|
||||
"type": "object"
|
||||
},
|
||||
"name": "project_create"
|
||||
}
|
||||
]
|
||||
@ -26,6 +26,13 @@ def test_mcp_tools():
|
||||
for t in tools])
|
||||
|
||||
|
||||
def test_mcp_wiki_tools():
|
||||
from jira_hub.modules.wiki import mcp as wiki_mcp
|
||||
tools = asyncio.run(wiki_mcp.mcp.list_tools())
|
||||
_check("mcp_wiki_tools", [{"name": t.name, "description": t.description, "inputSchema": t.inputSchema}
|
||||
for t in tools])
|
||||
|
||||
|
||||
def test_routes():
|
||||
rows = [[method, pattern, scope, fn.__name__] for method, pattern, fn, scope in api.ROUTES]
|
||||
_check("routes", sorted(rows, key=lambda r: (r[1], r[0])))
|
||||
|
||||
306
tests/test_wiki_mcp.py
Normal file
306
tests/test_wiki_mcp.py
Normal file
@ -0,0 +1,306 @@
|
||||
"""MCP вики (wiki-module, spec mcp «MCP вики»): клиент хаба подменяется, живой хаб не нужен."""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import mcp as core_mcp
|
||||
from jira_hub.core import registry
|
||||
from jira_hub.core.client import HubError
|
||||
from jira_hub.modules.wiki import mcp as wmcp
|
||||
|
||||
PROJECTS = {"projects": [
|
||||
{"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-1", "wiki_root_title": "Корень", "wiki_pages": 3,
|
||||
"wiki_synced_at": "2026-10-24T10:00:00+00:00"},
|
||||
{"label": "sfm_kedr", "name": "СФМ", "wiki_root": None, "wiki_pages": None, "wiki_synced_at": None},
|
||||
{"label": "fresh", "name": "Новый", "wiki_root": "DOC-9", "wiki_pages": 0, "wiki_synced_at": None}]}
|
||||
TREE = {"pages": [
|
||||
{"code": "DOC-1", "title": "Корень", "depth": 0, "parent_code": None, "size": 100},
|
||||
{"code": "DOC-2", "title": "Статусы", "depth": 1, "parent_code": "DOC-1", "size": 200},
|
||||
{"code": "DOC-3", "title": "Статистика", "depth": 1, "parent_code": "DOC-1", "size": 300},
|
||||
{"code": "DOC-4", "title": "Глубокая", "depth": 2, "parent_code": "DOC-3", "size": 50},
|
||||
{"code": "DOC-5", "title": "Ещё глубже", "depth": 3, "parent_code": "DOC-4", "size": 10}]}
|
||||
|
||||
|
||||
class Fake:
|
||||
"""Клиент хаба: ответы по пути; callable получает (method, params/body)."""
|
||||
def __init__(self, routes):
|
||||
self.routes, self.calls = routes, []
|
||||
|
||||
def _answer(self, method, path, arg):
|
||||
self.calls.append((method, path, arg))
|
||||
r = self.routes[(method, path)] if (method, path) in self.routes else self.routes[path]
|
||||
if isinstance(r, list):
|
||||
r = r.pop(0) if len(r) > 1 else r[0]
|
||||
if isinstance(r, Exception):
|
||||
raise r
|
||||
return r(arg) if callable(r) else r
|
||||
|
||||
def get(self, path, **params):
|
||||
return self._answer("GET", path, params)
|
||||
|
||||
def post(self, path, body=None):
|
||||
return self._answer("POST", path, body)
|
||||
|
||||
def patch(self, path, body):
|
||||
return self._answer("PATCH", path, body)
|
||||
|
||||
def get_bytes(self, path):
|
||||
return self._answer("GET", path, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hub(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
|
||||
monkeypatch.setattr(wmcp, "POLL_SECONDS", 0.01)
|
||||
monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
|
||||
|
||||
def make(routes, project="AWPCA"):
|
||||
monkeypatch.setattr(core_mcp, "PROJECT", project)
|
||||
fake = Fake({"/projects": PROJECTS, **routes})
|
||||
monkeypatch.setattr(core_mcp, "client", lambda: fake)
|
||||
return fake
|
||||
return make
|
||||
|
||||
|
||||
def paths_of(fake):
|
||||
return [p for _, p, _ in fake.calls]
|
||||
|
||||
|
||||
def test_unbound_tools_refuse(monkeypatch):
|
||||
monkeypatch.setattr(core_mcp, "PROJECT", None)
|
||||
fake = Fake({})
|
||||
monkeypatch.setattr(core_mcp, "client", lambda: fake)
|
||||
assert wmcp.tree() == core_mcp.NOT_BOUND
|
||||
assert wmcp.search("x") == core_mcp.NOT_BOUND
|
||||
assert wmcp.status() == 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 == []
|
||||
|
||||
|
||||
def test_no_root_message(hub):
|
||||
fake = hub({}, project="sfm_kedr")
|
||||
msg = "у проекта sfm_kedr нет корня вики: root_set DOC-…"
|
||||
assert wmcp.tree() == msg
|
||||
assert wmcp.page(code="DOC-1") == msg
|
||||
assert wmcp.search("x") == 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))
|
||||
|
||||
|
||||
def test_not_synced_message(hub):
|
||||
fake = hub({}, project="fresh")
|
||||
msg = "вики проекта ещё не синхронизирована: sync"
|
||||
assert wmcp.tree() == msg and wmcp.search("x") == msg and wmcp.page(code="DOC-1") == msg
|
||||
assert not any(p.startswith("/wiki/") for p in paths_of(fake))
|
||||
|
||||
|
||||
def test_page_chunks(hub):
|
||||
text = "я" * 45000
|
||||
page = {"code": "DOC-3", "title": "Статистика", "path": "корень/стат", "url": "https://eva/doc3",
|
||||
"synced_at": "2026-10-24T10:00:00+00:00", "markdown": text,
|
||||
"attachments": [{"id": 7, "name": "m.xlsx", "state": "none", "size": 5}]}
|
||||
hub({"/wiki/pages/DOC-3": page})
|
||||
first = wmcp.page(code="DOC-3")
|
||||
assert first.endswith("\nпродолжение: offset=20000")
|
||||
assert "я" * 20000 in first and "я" * 20001 not in first
|
||||
assert "DOC-3" in first and "корень/стат" in first and "https://eva/doc3" in first and "#7 m.xlsx" in first
|
||||
last = wmcp.page(code="DOC-3", offset=40000)
|
||||
assert "продолжение" not in last and last.endswith("я" * 5000)
|
||||
mid = wmcp.page(code="DOC-3", offset=20000)
|
||||
assert mid.endswith("продолжение: offset=40000")
|
||||
|
||||
|
||||
def test_page_offset_past_end(hub):
|
||||
hub({"/wiki/pages/DOC-3": {"code": "DOC-3", "title": "T", "path": "p", "url": "u", "synced_at": "s",
|
||||
"markdown": "abc", "attachments": []}})
|
||||
out = wmcp.page(code="DOC-3", offset=10)
|
||||
assert "за концом" in out and "длина 3" in out and "продолжение" not in out
|
||||
assert "за концом" in wmcp.page(code="DOC-3", offset=3) and "длина 3" in out
|
||||
|
||||
|
||||
def test_page_by_title_candidates(hub):
|
||||
fake = hub({"/wiki/tree": TREE})
|
||||
out = wmcp.page(title="Стат")
|
||||
assert "DOC-2 Статусы" in out and "DOC-3 Статистика" in out and "DOC-1" not in out
|
||||
assert not any(p.startswith("/wiki/pages") for p in paths_of(fake))
|
||||
|
||||
|
||||
def test_page_by_title_exact_case_insensitive(hub):
|
||||
page = {"code": "DOC-3", "title": "Статистика", "path": "p", "url": "u", "synced_at": "s", "markdown": "тело",
|
||||
"attachments": []}
|
||||
fake = hub({"/wiki/tree": TREE, "/wiki/pages/DOC-3": page})
|
||||
out = wmcp.page(title="статистика")
|
||||
assert "тело" in out and "/wiki/pages/DOC-3" in paths_of(fake)
|
||||
|
||||
|
||||
def test_page_by_title_ambiguous_and_missing(hub):
|
||||
tree = {"pages": TREE["pages"] + [{"code": "DOC-8", "title": "статистика", "depth": 1, "parent_code": "DOC-1", "size": 1}]}
|
||||
hub({"/wiki/tree": tree})
|
||||
out = wmcp.page(title="Статистика")
|
||||
assert "DOC-3 Статистика" in out and "DOC-8 статистика" in out and "тело" not in out
|
||||
assert "нет страницы" in wmcp.page(title="Нет такой")
|
||||
assert "code или title" in wmcp.page()
|
||||
|
||||
|
||||
def test_page_candidates_capped(hub):
|
||||
tree = {"pages": [{"code": f"DOC-{i}", "title": f"Раздел {i}", "depth": 1, "parent_code": None, "size": 1} for i in range(30)]}
|
||||
hub({"/wiki/tree": tree})
|
||||
out = wmcp.page(title="Раздел")
|
||||
assert len([l for l in out.splitlines() if l.startswith("DOC-")]) == 10
|
||||
|
||||
|
||||
def test_tree_depth_and_subtree(hub):
|
||||
fake = hub({"/wiki/tree": TREE})
|
||||
out = wmcp.tree()
|
||||
lines = out.splitlines()
|
||||
assert lines[0].startswith("DOC-1 Корень") and lines[1].startswith(" DOC-2 Статусы")
|
||||
assert "DOC-4" in out and "DOC-5" not in out # depth=2 по умолчанию: корень + два уровня
|
||||
sub = wmcp.tree(code="DOC-3", depth=1)
|
||||
assert sub.splitlines()[0].startswith("DOC-3") and "DOC-4" in sub and "DOC-5" not in sub and "DOC-2" not in sub
|
||||
assert "нет страницы DOC-9" in wmcp.tree(code="DOC-9")
|
||||
assert fake.calls[-1][2] == {"project": "AWPCA"}
|
||||
|
||||
|
||||
def test_search(hub):
|
||||
fake = hub({"/wiki/search": {"results": [{"code": "DOC-2", "title": "Статусы", "path": "к/с", "snippet": "…согласован…"}]}})
|
||||
out = wmcp.search("статус согласован", limit=5)
|
||||
assert "DOC-2" in out and "Статусы" in out and "к/с" in out and "…согласован…" in out
|
||||
assert fake.calls[-1] == ("GET", "/wiki/search", {"project": "AWPCA", "q": "статус согласован", "limit": 5})
|
||||
hub({"/wiki/search": {"results": []}})
|
||||
assert wmcp.search("zzz") == "ничего не найдено"
|
||||
|
||||
|
||||
def test_root_set(hub):
|
||||
fake = hub({("PATCH", "/projects/sfm_kedr"): {"project": {"label": "sfm_kedr", "wiki_root": "DOC-42"}, "batch_ids": [77]}},
|
||||
project="sfm_kedr")
|
||||
out = wmcp.root_set("https://eva.example/docs/doc-42")
|
||||
assert "DOC-42" in out and "#77" in out
|
||||
assert fake.calls[-1] == ("PATCH", "/projects/sfm_kedr", {"wiki_root": "DOC-42", "author": "claude"})
|
||||
|
||||
|
||||
def test_root_set_invalid_refused_without_request(hub):
|
||||
fake = hub({}, project="sfm_kedr")
|
||||
for bad in ("", " ", "страница", "DOCX-1", 5):
|
||||
out = wmcp.root_set(bad)
|
||||
assert "DOC-" in out and "ошибка хаба" not in out, bad
|
||||
assert not any(m == "PATCH" for m, _, _ in fake.calls)
|
||||
|
||||
|
||||
def test_root_set_same_root_no_batch(hub):
|
||||
hub({("PATCH", "/projects/AWPCA"): {"project": {}, "batch_ids": []}})
|
||||
out = wmcp.root_set("DOC-1")
|
||||
assert "DOC-1" in out and "пакета синхронизации не поставлено" in out
|
||||
|
||||
|
||||
def agent_ok():
|
||||
return {"online": True, "agent_version": "1", "settings_version": 2, "last_seen_at": "t", "rate_limited_24h": 0,
|
||||
"release": None, "update_state": None, "agent_reported": True, "launcher_version": 1}
|
||||
|
||||
|
||||
def test_status(hub):
|
||||
fake = hub({"/wiki/status": {"root": "DOC-1", "root_title": "Корень", "synced_at": "2026-10-24T10:00:00+00:00", "pages": 3,
|
||||
"error": "сбой чтения", "batch": {"id": 12, "status": "queued"}},
|
||||
"/agent": agent_ok()})
|
||||
out = wmcp.status()
|
||||
assert "DOC-1" in out and "Корень" in out and "сбой чтения" in out and "#12" in out
|
||||
assert "агент онлайн" in out and "запускатель: v1" in out
|
||||
assert ("GET", "/agent", {"module": "wiki"}) in fake.calls
|
||||
|
||||
|
||||
def test_status_no_root_still_shows_agent(hub):
|
||||
fake = hub({"/agent": agent_ok()}, project="sfm_kedr")
|
||||
out = wmcp.status()
|
||||
assert "у проекта sfm_kedr нет корня вики" in out and "агент онлайн" in out
|
||||
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}]}
|
||||
fake = 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"
|
||||
|
||||
|
||||
def att(state, **kw):
|
||||
return {"id": 7, "name": ATT_NAME, "page_code": "DOC-3", "url": "u", "state": state, "size": 3, "error": None, **kw}
|
||||
|
||||
|
||||
def test_attachment_ready_copy(hub, tmp_path):
|
||||
fake = hub({("POST", "/wiki/attachments/7/fetch"): att("fetching"),
|
||||
"/wiki/attachments/7": [att("fetching"), att("ready")],
|
||||
"/wiki/attachments/7/content": b"xyz"})
|
||||
out = asyncio.run(wmcp.attachment(7))
|
||||
target = tmp_path / "home" / "downloads" / "wiki-7"
|
||||
files = list(target.iterdir())
|
||||
assert len(files) == 1 and files[0].read_bytes() == b"xyz"
|
||||
assert files[0].name == "pass_wd.xlsx" and str(files[0]) in out
|
||||
assert target.stat().st_mode & 0o777 == 0o700
|
||||
assert ("POST", "/wiki/attachments/7/fetch", {"author": "claude"}) in fake.calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,expected", [("..", "attachment-7"), ("a/b/c.txt", "c.txt"), ("C:\\x\\y.doc", "y.doc"),
|
||||
("", "attachment-7"), (None, "attachment-7")])
|
||||
def test_attachment_name_basename(hub, tmp_path, name, expected):
|
||||
hub({("POST", "/wiki/attachments/7/fetch"): att("ready", name=name), "/wiki/attachments/7/content": b"x"})
|
||||
asyncio.run(wmcp.attachment(7))
|
||||
assert [p.name for p in (tmp_path / "home" / "downloads" / "wiki-7").iterdir()] == [expected]
|
||||
|
||||
|
||||
def test_attachment_timeout_and_failed(hub, monkeypatch):
|
||||
monkeypatch.setattr(wmcp, "ATTACHMENT_WAIT", 0.05)
|
||||
hub({("POST", "/wiki/attachments/7/fetch"): att("fetching"), "/wiki/attachments/7": att("fetching")})
|
||||
out = asyncio.run(wmcp.attachment(7))
|
||||
assert "ещё идёт" in out and "ещё раз" in out
|
||||
hub({("POST", "/wiki/attachments/7/fetch"): att("failed", error="HTTP 404")})
|
||||
assert "не удалось скачать" in asyncio.run(wmcp.attachment(7)) and "HTTP 404" in asyncio.run(wmcp.attachment(7))
|
||||
|
||||
|
||||
def test_attachment_unknown(hub):
|
||||
hub({("POST", "/wiki/attachments/99/fetch"): HubError(404, "not_found", "вложение 99")})
|
||||
assert asyncio.run(wmcp.attachment(99)).startswith("ошибка хаба 404")
|
||||
|
||||
|
||||
def test_jira_server_unchanged():
|
||||
from test_baseline import test_mcp_tools
|
||||
test_mcp_tools()
|
||||
|
||||
|
||||
def test_run_wiki_module():
|
||||
m = registry.module("wiki")
|
||||
assert m.mcp_server().name == "jira-hub-wiki"
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
def test_projects_table_and_create(hub):
|
||||
fake = hub({"/projects": PROJECTS, ("POST", "/projects"): {"project": {"label": "n", "name": "Н"}, "batch_id": 3}})
|
||||
out = asyncio.run(wmcp.mcp.call_tool("projects", {}))
|
||||
text = str(out)
|
||||
assert "AWPCA" in text and "DOC-1" in text and "(текущий)" in text
|
||||
created = wmcp.project_create("n", "Н", "DOC-5")
|
||||
assert "jira-hub-wiki" in created
|
||||
assert fake.calls[-1][2]["wiki_root"] == "DOC-5"
|
||||
Loading…
x
Reference in New Issue
Block a user