Модульная перестройка хаба: ядро + модуль jira (#87) #88

Merged
aep merged 23 commits from feat/87-modules into main 2026-10-23 22:42:15 +03:00
10 changed files with 495 additions and 419 deletions
Showing only changes of commit 6d1e595875 - Show all commits

View File

@ -1,11 +1,10 @@
"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), import (CSV-пакет), mcp (stdio), tokens, """Команды jira-hub: init (настройки из config.example.json), serve (HTTP), mcp [модуль] (stdio), tokens,
agent publish (версия агента для самообновления).""" agent publish (версия агента для самообновления) и подкоманды модулей (jira: import)."""
import argparse import argparse
import json import json
import sys import sys
from . import db, paths, registry, settings from . import db, paths, registry, settings
from ..modules.jira import importer
def main(argv=None) -> int: def main(argv=None) -> int:
@ -17,10 +16,12 @@ def main(argv=None) -> int:
p = sub.add_parser("serve", help="запустить HTTP API") p = sub.add_parser("serve", help="запустить HTTP API")
p.add_argument("--host", default="0.0.0.0") p.add_argument("--host", default="0.0.0.0")
p.add_argument("--port", type=int) p.add_argument("--port", type=int)
p = sub.add_parser("import", help="импорт CSV-пакета admin/jira-sync в заметки") p = sub.add_parser("mcp", help="MCP-сервер модуля по stdio (по умолчанию jira)")
p.add_argument("folder") p.add_argument("module", nargs="?", default="jira", help="имя модуля; неизвестное — код 2 (проверка в mcp.run)")
p = sub.add_parser("mcp", help="MCP-сервер по stdio")
p.add_argument("--project", help="метка проекта, к которому привязана сессия") p.add_argument("--project", help="метка проекта, к которому привязана сессия")
for m in registry.modules():
if m.cli_register:
m.cli_register(sub)
sub.add_parser("tokens", help="показать токены агента и клиента") sub.add_parser("tokens", help="показать токены агента и клиента")
p = sub.add_parser("agent", help="версия агента для самообновления") p = sub.add_parser("agent", help="версия агента для самообновления")
agent_sub = p.add_subparsers(dest="agent_cmd", required=True) agent_sub = p.add_subparsers(dest="agent_cmd", required=True)
@ -47,9 +48,13 @@ def main(argv=None) -> int:
api.serve(hub, args.host, port) api.serve(hub, args.host, port)
return 0 return 0
if args.cmd == "mcp": if args.cmd == "mcp":
from . import mcp_server from . import mcp
mcp_server.run(args.project) return mcp.run(args.module, args.project)
return 0 for m in registry.modules():
if m.cli_run:
rc = m.cli_run(args)
if rc is not None:
return rc
conn = db.connect(paths.db_path()) conn = db.connect(paths.db_path())
settings.ensure_defaults(conn) settings.ensure_defaults(conn)
@ -69,9 +74,6 @@ def main(argv=None) -> int:
print(line) print(line)
_print_tokens(conn) _print_tokens(conn)
return 0 return 0
if args.cmd == "import":
print(json.dumps(importer.import_package(conn, args.folder), ensure_ascii=False, indent=1))
return 0
if args.cmd == "tokens": if args.cmd == "tokens":
_print_tokens(conn) _print_tokens(conn)
return 0 return 0

146
src/jira_hub/core/mcp.py Normal file
View File

@ -0,0 +1,146 @@
"""Ядро MCP хаба: общие инструменты (projects, project_create), охрана и привязка к проекту сессии, запуск сервера модуля
по stdio (specs/mcp/spec.md). Автор команд из MCP — «claude». Если хаб выключен, инструмент возвращает подсказку, а не
исключение. Инструменты модулей лежат в modules/<имя>/mcp.py и подключают общие через register_core_tools."""
import asyncio
import functools
import inspect
import json
import sys
from pathlib import Path
from . import registry
from .client import HubClient, HubError, HubUnavailable
OFFLINE = "хаб не запущен: jira-hub serve"
AUTHOR = "claude"
NOT_BOUND = "сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)"
HUB_ROOT = Path(__file__).resolve().parents[3] # корень репозитория хаба, из которого загружен пакет (решение 19)
PROJECT: str | None = None # привязка сессии, задаётся run(module_name, project)
def unknown_project(label) -> str:
return f"проект {label} не заведён в хабе: project_create или исправьте --project в .mcp.json"
def mcp_json(label: str) -> dict:
return {"mcpServers": {"jira-hub": {"command": "uv", "args": ["run", "--project", str(HUB_ROOT), "jira-hub", "mcp",
"--project", label]}}}
def client() -> HubClient:
return HubClient.from_home()
def _hub_error(e: HubError) -> str:
return f"ошибка хаба {e.status} {e.error}: {e.detail or ''}".rstrip(": ")
def guard(fn):
"""Обёртка инструмента: сбои хаба превращаются в текст. Поддерживает и обычные, и async-функции."""
if inspect.iscoroutinefunction(fn):
@functools.wraps(fn)
async def awrapper(*args, **kwargs):
try:
return await fn(*args, **kwargs)
except HubUnavailable:
return OFFLINE
except HubError as e:
return _hub_error(e)
return awrapper
@functools.wraps(fn)
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except HubUnavailable:
return OFFLINE
except HubError as e:
return _hub_error(e)
return wrapper
def unbound_or_unknown(required: bool) -> str | None:
"""None — инструмент можно вызывать. Без привязки: required → NOT_BOUND без запросов к хабу. С привязкой: метка
сверяется с GET /projects при каждом вызове (сбой хаба поднимается к guard и становится OFFLINE)."""
if PROJECT is None:
return NOT_BOUND if required else None
labels = [p["label"] for p in client().get("/projects")["projects"]]
return None if PROJECT in labels else unknown_project(PROJECT)
def scoped(required: bool):
"""Привязка инструмента к проекту сессии (spec mcp). Ставится под guard: тот превращает сбой хаба в текст."""
def deco(fn):
if inspect.iscoroutinefunction(fn):
@functools.wraps(fn)
async def awrapper(*args, **kwargs):
refusal = await asyncio.to_thread(unbound_or_unknown, required)
return refusal if refusal is not None else await fn(*args, **kwargs)
return awrapper
@functools.wraps(fn)
def wrapper(*args, **kwargs):
refusal = unbound_or_unknown(required)
return refusal if refusal is not None else fn(*args, **kwargs)
return wrapper
return deco
def cut(text, n=80):
text = (text or "").replace("\n", " ")
return text if len(text) <= n else text[: n - 1] + "…"
def value(v):
return "—" if v in (None, "", []) else str(v)
def fmt_projects(items, current: str | None) -> str:
if not items:
return "проектов нет"
# долг hub-modules: проекты → модуль jira (change wiki)
lines = ["метка | название | доска | тип доски | задач"]
for p in items:
lines.append(" | ".join([p["label"], p["name"], value(p.get("board_id")), p.get("board_type") or "неизвестен",
str(p.get("issues_count", 0))]) + (" (текущий)" if p["label"] == current else ""))
return "\n".join(lines)
@guard
def projects() -> str:
"""Проекты хаба: метка, название, доска, тип доски, число задач; текущий проект сессии отмечен."""
return fmt_projects(client().get("/projects")["projects"], PROJECT)
@guard
def project_create(label: str, name: str, board_id: int | None = None) -> str: # долг hub-modules: проекты → модуль jira (change wiki)
"""Завести проект (метка Jira, название, необязательно id доски). Хаб ставит пакет обновления; в ответе — фрагмент
.mcp.json для репозитория проекта."""
body = {"label": label, "name": name, "author": AUTHOR}
if board_id is not None:
body["board_id"] = board_id
out = client().post("/projects", body)
p = out["project"]
return (f"проект {p['label']} «{p['name']}» заведён, пакет обновления #{out['batch_id']}\n"
"Положите в корень репозитория проекта .mcp.json (слейте с имеющимся) и переподключите MCP: "
"/mcp → reconnect, одобрите сервер проекта.\n"
+ json.dumps(mcp_json(p["label"]), ensure_ascii=False, indent=2))
def register_core_tools(mcp) -> None:
"""Общие инструменты на сервере модуля: идут последними (порядок списка инструментов — часть контракта)."""
mcp.tool()(projects)
mcp.tool()(project_create)
def run(module_name: str, project: str | None = None) -> int:
"""Запустить MCP-сервер модуля по stdio. Неизвестный модуль: список модулей в stderr, код 2, сервер не стартует."""
global PROJECT
m = registry.module(module_name)
if m is None or m.mcp_server is None:
names = ", ".join(x.name for x in registry.modules() if x.mcp_server)
print(f"неизвестный модуль {module_name}; модули: {names}", file=sys.stderr)
return 2
PROJECT = project
m.mcp_server().run(transport="stdio")
return 0

View File

@ -1,6 +1,13 @@
"""Модуль jira: зеркало задач Jira, очередь команд агенту, вложения, журнал изменений.""" """Модуль jira: зеркало задач Jira, очередь команд агенту, вложения, журнал изменений."""
from ...core.modules import Module from ...core.modules import Module
from . import api, hooks, refresh, schema, settings from . import api, cli, hooks, refresh, schema, settings
def mcp_server():
"""FastMCP модуля: импорт лениво, пока сервер не нужен."""
from . import mcp
return mcp.mcp
MODULE = Module( MODULE = Module(
name="jira", name="jira",
@ -15,4 +22,7 @@ MODULE = Module(
routes=api.ROUTES, routes=api.ROUTES,
tick=(refresh.auto_refresh_tick, api.downloads_tick), tick=(refresh.auto_refresh_tick, api.downloads_tick),
on_project_created=api.on_project_created, on_project_created=api.on_project_created,
mcp_server=mcp_server,
cli_register=cli.cli_register,
cli_run=cli.cli_run,
) )

View File

@ -0,0 +1,23 @@
"""Подкоманды модуля jira для jira-hub: import (CSV-пакет admin/jira-sync в заметки)."""
import json
from ...core import db, paths, settings
from . import importer
def cli_register(sub) -> None:
p = sub.add_parser("import", help="импорт CSV-пакета admin/jira-sync в заметки")
p.add_argument("folder")
def cli_run(args) -> int | None:
"""None — подкоманда не модуля."""
if args.cmd != "import":
return None
conn = db.connect(paths.db_path())
settings.ensure_defaults(conn)
try:
print(json.dumps(importer.import_package(conn, args.folder), ensure_ascii=False, indent=1))
finally:
conn.close()
return 0

View File

@ -1,9 +1,8 @@
"""MCP-сервер хаба (stdio, FastMCP) — инструменты сессии Claude поверх HTTP API (specs/mcp/spec.md). """MCP-инструменты модуля jira (stdio, FastMCP) — инструменты сессии Claude поверх HTTP API (specs/mcp/spec.md).
Автор команд из MCP — «claude». Если хаб выключен, инструмент возвращает подсказку, а не исключение.""" Общие охрана, привязка к проекту и инструменты projects/project_create — в core/mcp.py; состояние (клиент хаба,
привязка сессии) читается через core в момент вызова."""
import asyncio import asyncio
import base64 import base64
import functools
import inspect
import json import json
import os import os
import stat import stat
@ -14,125 +13,32 @@ from pathlib import Path
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from . import paths from ...core import mcp as core
from ..modules.jira.attachments import fmt_size from ...core import paths
from .client import HubClient, HubError, HubUnavailable from ...core.client import HubError
from ..modules.jira.commands import describe, is_temp from ...core.files import MAX_SIZE
from .files import MAX_SIZE from .attachments import fmt_size
from .commands import describe, is_temp
OFFLINE = "хаб не запущен: jira-hub serve"
POLL_SECONDS = 5 POLL_SECONDS = 5
AUTHOR = "claude"
BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file" BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file"
NOT_BOUND = "сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)"
HUB_ROOT = Path(__file__).resolve().parents[3] # корень репозитория хаба, из которого загружен пакет (решение 19)
PROJECT: str | None = None # привязка сессии, задаётся run(project)
mcp = FastMCP("jira-hub") mcp = FastMCP("jira-hub")
def unknown_project(label) -> str:
return f"проект {label} не заведён в хабе: project_create или исправьте --project в .mcp.json"
def mcp_json(label: str) -> dict:
return {"mcpServers": {"jira-hub": {"command": "uv", "args": ["run", "--project", str(HUB_ROOT), "jira-hub", "mcp",
"--project", label]}}}
def _client() -> HubClient:
return HubClient.from_home()
def _hub_error(e: HubError) -> str:
return f"ошибка хаба {e.status} {e.error}: {e.detail or ''}".rstrip(": ")
def guard(fn):
"""Обёртка инструмента: сбои хаба превращаются в текст. Поддерживает и обычные, и async-функции."""
if inspect.iscoroutinefunction(fn):
@functools.wraps(fn)
async def awrapper(*args, **kwargs):
try:
return await fn(*args, **kwargs)
except HubUnavailable:
return OFFLINE
except HubError as e:
return _hub_error(e)
return awrapper
@functools.wraps(fn)
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except HubUnavailable:
return OFFLINE
except HubError as e:
return _hub_error(e)
return wrapper
def _unbound_or_unknown(required: bool) -> str | None:
"""None — инструмент можно вызывать. Без привязки: required → NOT_BOUND без запросов к хабу. С привязкой: метка
сверяется с GET /projects при каждом вызове (сбой хаба поднимается к guard и становится OFFLINE)."""
if PROJECT is None:
return NOT_BOUND if required else None
labels = [p["label"] for p in _client().get("/projects")["projects"]]
return None if PROJECT in labels else unknown_project(PROJECT)
def scoped(required: bool):
"""Привязка инструмента к проекту сессии (spec mcp). Ставится под guard: тот превращает сбой хаба в текст."""
def deco(fn):
if inspect.iscoroutinefunction(fn):
@functools.wraps(fn)
async def awrapper(*args, **kwargs):
refusal = await asyncio.to_thread(_unbound_or_unknown, required)
return refusal if refusal is not None else await fn(*args, **kwargs)
return awrapper
@functools.wraps(fn)
def wrapper(*args, **kwargs):
refusal = _unbound_or_unknown(required)
return refusal if refusal is not None else fn(*args, **kwargs)
return wrapper
return deco
# --- форматирование --------------------------------------------------------------
def _cut(text, n=80):
text = (text or "").replace("\n", " ")
return text if len(text) <= n else text[: n - 1] + "…"
def _v(value):
return "—" if value in (None, "", []) else str(value)
def fmt_projects(items, current: str | None) -> str:
if not items:
return "проектов нет"
lines = ["метка | название | доска | тип доски | задач"]
for p in items:
lines.append(" | ".join([p["label"], p["name"], _v(p.get("board_id")), p.get("board_type") or "неизвестен",
str(p.get("issues_count", 0))]) + (" (текущий)" if p["label"] == current else ""))
return "\n".join(lines)
def fmt_issues(issues) -> str: def fmt_issues(issues) -> str:
if not issues: if not issues:
return "задач нет" return "задач нет"
lines = ["ключ | тема | статус | срок | исполнитель | спринт | статус по факту"] lines = ["ключ | тема | статус | срок | исполнитель | спринт | статус по факту"]
for i in issues: for i in issues:
lines.append(" | ".join([i["key"], _cut(i.get("summary")), _v(i.get("status")), _v(i.get("duedate")), lines.append(" | ".join([i["key"], core.cut(i.get("summary")), core.value(i.get("status")), core.value(i.get("duedate")),
_v(i.get("assignee")), _v(i.get("sprint")), _v(i.get("fact_status"))])) core.value(i.get("assignee")), core.value(i.get("sprint")), core.value(i.get("fact_status"))]))
return "\n".join(lines) return "\n".join(lines)
def fmt_attachment_line(a: dict, d: dict) -> str: def fmt_attachment_line(a: dict, d: dict) -> str:
"""Строка вложения в issue: «#id имя (размер) — автор, создано» и через запятую, где оно упомянуто и скачано.""" """Строка вложения в issue: «#id имя (размер) — автор, создано» и через запятую, где оно упомянуто и скачано."""
aid = str(a["attachment_id"]) aid = str(a["attachment_id"])
line = f" #{aid} {a['filename']} ({fmt_size(a.get('size'))}) — {_v(a.get('author'))}, {_v(a.get('created'))}" line = f" #{aid} {a['filename']} ({fmt_size(a.get('size'))}) — {core.value(a.get('author'))}, {core.value(a.get('created'))}"
marks = [] marks = []
if aid in (d.get("description_attachment_ids") or []): if aid in (d.get("description_attachment_ids") or []):
marks.append("в описании") marks.append("в описании")
@ -173,10 +79,10 @@ def safe_name(filename: str, attachment_id: str) -> str:
def fmt_issue(d) -> str: def fmt_issue(d) -> str:
head = [f"{d['key']} — {d.get('summary')}", head = [f"{d['key']} — {d.get('summary')}",
f"тип: {_v(d.get('issue_type'))}; статус: {_v(d.get('status'))}; приоритет: {_v(d.get('priority'))}; " f"тип: {core.value(d.get('issue_type'))}; статус: {core.value(d.get('status'))}; приоритет: {core.value(d.get('priority'))}; "
f"исполнитель: {_v(d.get('assignee'))}; срок: {_v(d.get('duedate'))}; план: {_v(d.get('planned_start'))}…{_v(d.get('planned_end'))}", f"исполнитель: {core.value(d.get('assignee'))}; срок: {core.value(d.get('duedate'))}; план: {core.value(d.get('planned_start'))}…{core.value(d.get('planned_end'))}",
f"эпик: {_v(d.get('epic_key'))}; спринт: {_v(d.get('sprint'))}; метки: {', '.join(d.get('labels') or []) or '—'}; " f"эпик: {core.value(d.get('epic_key'))}; спринт: {core.value(d.get('sprint'))}; метки: {', '.join(d.get('labels') or []) or '—'}; "
f"сосед: {'да' if d.get('neighbor') else 'нет'}; обновлено в Jira: {_v(d.get('jira_updated'))}; прочитано: {_v(d.get('fetched_at'))}"] f"сосед: {'да' if d.get('neighbor') else 'нет'}; обновлено в Jira: {core.value(d.get('jira_updated'))}; прочитано: {core.value(d.get('fetched_at'))}"]
links = [f" → {l['target_key']} ({l['link_type']})" for l in d.get("links_out", [])] + \ links = [f" → {l['target_key']} ({l['link_type']})" for l in d.get("links_out", [])] + \
[f" ← {l['source_key']} ({l['link_type']})" for l in d.get("links_in", [])] [f" ← {l['source_key']} ({l['link_type']})" for l in d.get("links_in", [])]
note = d.get("note") or {} note = d.get("note") or {}
@ -186,7 +92,7 @@ def fmt_issue(d) -> str:
if comments: if comments:
shown = [] shown = []
for c in comments[:5]: for c in comments[:5]:
shown.append(f" #{c.get('comment_id')} {_v(c.get('author'))} {_v(c.get('created'))}: {_cut(c.get('body'), 300)}") shown.append(f" #{c.get('comment_id')} {core.value(c.get('author'))} {core.value(c.get('created'))}: {core.cut(c.get('body'), 300)}")
files_line = _files_line(c, d.get("attachments") or []) files_line = _files_line(c, d.get("attachments") or [])
if files_line: if files_line:
shown.append(" " + files_line) shown.append(" " + files_line)
@ -210,16 +116,16 @@ def _day(value, text) -> str:
"""Дата для ленты: из значения вида ГГГГ-ММ-ДД — «ДД.ММ», иначе строка журнала как есть, пусто — «—».""" """Дата для ленты: из значения вида ГГГГ-ММ-ДД — «ДД.ММ», иначе строка журнала как есть, пусто — «—»."""
if isinstance(value, str) and len(value) >= 10 and value[4] == "-" and value[7] == "-" and value[:4].isdigit(): if isinstance(value, str) and len(value) >= 10 and value[4] == "-" and value[7] == "-" and value[:4].isdigit():
return f"{value[8:10]}.{value[5:7]}" return f"{value[8:10]}.{value[5:7]}"
return _v(text) return core.value(text)
def fmt_comment(key, c, atts=None) -> str: def fmt_comment(key, c, atts=None) -> str:
"""Один комментарий целиком (#80): шапка, затем текст с переносами строк; «изменён» — только если правили. """Один комментарий целиком (#80): шапка, затем текст с переносами строк; «изменён» — только если правили.
atts — вложения задачи: ссылки комментария на них выводятся блоком «вложения:».""" atts — вложения задачи: ссылки комментария на них выводятся блоком «вложения:»."""
head = f"{key} #{c.get('comment_id')} {_v(c.get('author'))}, создан {_v(c.get('created'))}" head = f"{key} #{c.get('comment_id')} {core.value(c.get('author'))}, создан {core.value(c.get('created'))}"
if c.get("updated") and c.get("updated") != c.get("created"): if c.get("updated") and c.get("updated") != c.get("created"):
head += f", изменён {c['updated']}" head += f", изменён {c['updated']}"
out = head + "\n" + _v(c.get("body")) out = head + "\n" + core.value(c.get("body"))
by_id = {str(a["attachment_id"]): a for a in atts or []} by_id = {str(a["attachment_id"]): a for a in atts or []}
refs = [by_id[i] for i in (str(x) for x in c.get("attachment_ids") or []) if i in by_id] refs = [by_id[i] for i in (str(x) for x in c.get("attachment_ids") or []) if i in by_id]
if refs: if refs:
@ -237,17 +143,17 @@ def fmt_event(e: dict, today) -> str:
when = "—" when = "—"
kind, frm, to = e.get("kind"), e.get("from"), e.get("to") kind, frm, to = e.get("kind"), e.get("from"), e.get("to")
if kind == "comment": if kind == "comment":
what = "комментарий: " + _cut(to, 200) what = "комментарий: " + core.cut(to, 200)
elif kind == "status": elif kind == "status":
what = f"статус «{_v(frm)}» → «{_v(to)}»" what = f"статус «{core.value(frm)}» → «{core.value(to)}»"
elif kind == "description": elif kind == "description":
what = "описание изменено" what = "описание изменено"
elif kind == "dates": elif kind == "dates":
what = (f"{DATE_LABELS.get(e.get('field'), e.get('field'))} {_day(e.get('from_value'), frm)} → " what = (f"{DATE_LABELS.get(e.get('field'), e.get('field'))} {_day(e.get('from_value'), frm)} → "
f"{_day(e.get('to_value'), to)}") f"{_day(e.get('to_value'), to)}")
else: else:
what = f"{e.get('field')}: «{_cut(frm) if frm else '—'}» → «{_cut(to) if to else '—'}»" what = f"{e.get('field')}: «{core.cut(frm) if frm else '—'}» → «{core.cut(to) if to else '—'}»"
return f"{when} {_v(e.get('author'))}: {what}" return f"{when} {core.value(e.get('author'))}: {what}"
def fmt_changes(data: dict, now: datetime) -> str: def fmt_changes(data: dict, now: datetime) -> str:
@ -262,7 +168,7 @@ def fmt_changes(data: dict, now: datetime) -> str:
for e in events: for e in events:
groups.setdefault(e["key"], []).append(e) groups.setdefault(e["key"], []).append(e)
for key, items in groups.items(): for key, items in groups.items():
lines.append(f"{key} {_cut(items[0].get('summary')) or '—'} — {_v(items[0].get('status'))}") lines.append(f"{key} {core.cut(items[0].get('summary')) or '—'} — {core.value(items[0].get('status'))}")
lines += [" " + fmt_event(e, now.date()) for e in items] lines += [" " + fmt_event(e, now.date()) for e in items]
if data.get("truncated"): if data.get("truncated"):
lines.append(f"показаны последние {len(events)} событий, сузьте период") lines.append(f"показаны последние {len(events)} событий, сузьте период")
@ -285,17 +191,17 @@ def fmt_batch(b) -> str:
def fmt_agent(a) -> str: def fmt_agent(a) -> str:
state = "онлайн" if a.get("online") else "офлайн" state = "онлайн" if a.get("online") else "офлайн"
cur = f"выполняет #{a['current_command_id']}" if a.get("current_command_id") else "очередь свободна" cur = f"выполняет #{a['current_command_id']}" if a.get("current_command_id") else "очередь свободна"
out = [f"агент {state} (версия {_v(a.get('agent_version'))}, настройки v{_v(a.get('settings_version'))}, последний контакт {_v(a.get('last_seen_at'))}); {cur}; " 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)}"] f"429/503 за сутки: {a.get('rate_limited_24h', 0)}"]
if a.get("warning"): if a.get("warning"):
out.append("внимание: " + a["warning"]) out.append("внимание: " + a["warning"])
for s in a.get("redelivered", []): for s in a.get("redelivered", []):
out.append(f"выдана повторно: #{s['id']} {s['type']}, выдач {s['deliveries']}, последняя {s['claimed_at']}") out.append(f"выдана повторно: #{s['id']} {s['type']}, выдач {s['deliveries']}, последняя {s['claimed_at']}")
rel = a.get("release") rel = a.get("release")
out.append(f"публикация: v{rel['version']} от {_v(rel.get('published_at'))}" if rel else "публикации нет") out.append(f"публикация: v{rel['version']} от {core.value(rel.get('published_at'))}" if rel else "публикации нет")
us = a.get("update_state") us = a.get("update_state")
if us: if us:
out.append(f"обновление: {us.get('state')} v{_v(us.get('from_version'))} → v{_v(us.get('to_version'))}") 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") lv = a.get("launcher_version")
if a.get("agent_reported") is False: # хаб перезапущен, claim ещё не было (решение 8); старый хаб поля не шлёт if a.get("agent_reported") is False: # хаб перезапущен, claim ещё не было (решение 8); старый хаб поля не шлёт
out.append("запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)") out.append("запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)")
@ -309,34 +215,34 @@ def fmt_agent(a) -> str:
# --- инструменты ----------------------------------------------------------------- # --- инструменты -----------------------------------------------------------------
@mcp.tool() @mcp.tool()
@guard @core.guard
@scoped(True) @core.scoped(True)
def issues(sprint: str = "", assignee: str = "", work_kind: str = "", fact_status: str = "", status: str = "", def issues(sprint: str = "", assignee: str = "", work_kind: str = "", fact_status: str = "", status: str = "",
label: str = "", epic: str = "", q: str = "", neighbor: str = "", limit: int = 50) -> str: label: str = "", epic: str = "", q: str = "", neighbor: str = "", limit: int = 50) -> str:
"""Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус по факту, статус Jira, метка, эпик, """Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус по факту, статус Jira, метка, эпик,
подстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица.""" подстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица."""
data = _client().get("/issues", project=PROJECT, sprint=sprint, assignee=assignee, work_kind=work_kind, fact_status=fact_status, data = core.client().get("/issues", project=core.PROJECT, sprint=sprint, assignee=assignee, work_kind=work_kind, fact_status=fact_status,
status=status, label=label, epic=epic, q=q, neighbor=neighbor, limit=limit) status=status, label=label, epic=epic, q=q, neighbor=neighbor, limit=limit)
return fmt_issues(data["issues"]) return fmt_issues(data["issues"])
@mcp.tool() @mcp.tool()
@guard @core.guard
@scoped(True) @core.scoped(True)
def issue(key: str) -> str: def issue(key: str) -> str:
"""Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание. Комментарии — 5 """Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание. Комментарии — 5
последних с номером, текст до 300 символов; полный текст — инструмент comment по номеру. Вложения — списком последних с номером, текст до 300 символов; полный текст — инструмент comment по номеру. Вложения — списком
(номер, имя, размер, где упомянуты, скачано ли); содержимое — инструмент attachment.""" (номер, имя, размер, где упомянуты, скачано ли); содержимое — инструмент attachment."""
return fmt_issue(_client().get(f"/issues/{key}")) return fmt_issue(core.client().get(f"/issues/{key}"))
@mcp.tool() @mcp.tool()
@guard @core.guard
@scoped(True) @core.scoped(True)
def comment(key: str, comment_id: str) -> str: def comment(key: str, comment_id: str) -> str:
"""Один комментарий задачи целиком: автор, время, полный текст. comment_id — номер из ответа issue («#123»).""" """Один комментарий задачи целиком: автор, время, полный текст. comment_id — номер из ответа issue («#123»)."""
cid = comment_id.lstrip("#") cid = comment_id.lstrip("#")
d = _client().get(f"/issues/{key}") d = core.client().get(f"/issues/{key}")
for c in d.get("comments") or []: for c in d.get("comments") or []:
if c.get("comment_id") == cid: if c.get("comment_id") == cid:
return fmt_comment(key, c, d.get("attachments")) return fmt_comment(key, c, d.get("attachments"))
@ -367,15 +273,15 @@ def _store_copy(attachment_id: str, name: str, data: bytes) -> Path:
@mcp.tool() @mcp.tool()
@guard @core.guard
@scoped(True) @core.scoped(True)
async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str: async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str:
"""Скачать вложение задачи и вернуть путь к локальной копии (читайте файл по этому пути). attachment — номер из """Скачать вложение задачи и вернуть путь к локальной копии (читайте файл по этому пути). attachment — номер из
issue/comment («#31337» или «31337») либо точное имя файла; при нескольких одноимённых берётся самое новое. issue/comment («#31337» или «31337») либо точное имя файла; при нескольких одноимённых берётся самое новое.
Хаб просит агента скачать файл и сохраняет копию в downloads/ каталога данных; ожидание — до wait_seconds (1…600, Хаб просит агента скачать файл и сохраняет копию в downloads/ каталога данных; ожидание — до wait_seconds (1…600,
по умолчанию 120), не дождался — вызовите инструмент ещё раз: вторая команда не поставится. Файлы больше 10 МБ по умолчанию 120), не дождался — вызовите инструмент ещё раз: вторая команда не поставится. Файлы больше 10 МБ
скачиваются только в Jira.""" скачиваются только в Jira."""
client = _client() client = core.client()
wait = min(max(int(wait_seconds), 1), 600) wait = min(max(int(wait_seconds), 1), 600)
d = await asyncio.to_thread(client.get, f"/issues/{key}") d = await asyncio.to_thread(client.get, f"/issues/{key}")
atts = d.get("attachments") or [] atts = d.get("attachments") or []
@ -391,7 +297,7 @@ async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str:
att = found[0] att = found[0]
aid = str(att["attachment_id"]) aid = str(att["attachment_id"])
try: try:
st = await asyncio.to_thread(client.post, f"/attachments/{aid}/fetch", {"author": AUTHOR}) st = await asyncio.to_thread(client.post, f"/attachments/{aid}/fetch", {"author": core.AUTHOR})
except HubError as e: except HubError as e:
if e.status == 413: if e.status == 413:
return f"файл больше 10 МБ ({fmt_size(att.get('size'))}) — скачайте в Jira: задача {key}, вложение «{att['filename']}»" return f"файл больше 10 МБ ({fmt_size(att.get('size'))}) — скачайте в Jira: задача {key}, вложение «{att['filename']}»"
@ -405,10 +311,10 @@ async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str:
return (f"скачивание ещё идёт (команда #{st.get('command_id')}, агент {'онлайн' if online else 'офлайн'}); " return (f"скачивание ещё идёт (команда #{st.get('command_id')}, агент {'онлайн' if online else 'офлайн'}); "
"вызовите attachment ещё раз — вторая команда не поставится") "вызовите attachment ещё раз — вторая команда не поставится")
if st.get("state") != "ready": if st.get("state") != "ready":
return f"не удалось скачать: {_v(st.get('error'))}" return f"не удалось скачать: {core.value(st.get('error'))}"
data = await asyncio.to_thread(client.get_bytes, f"/attachments/{aid}/content") data = await asyncio.to_thread(client.get_bytes, f"/attachments/{aid}/content")
path = await asyncio.to_thread(_store_copy, aid, safe_name(att["filename"], aid), data) path = await asyncio.to_thread(_store_copy, aid, safe_name(att["filename"], aid), data)
return (f"{prefix}{path}\n{att['filename']} ({fmt_size(att.get('size'))}), {_v(att.get('mime_type'))}, задача {key}") return (f"{prefix}{path}\n{att['filename']} ({fmt_size(att.get('size'))}), {core.value(att.get('mime_type'))}, задача {key}")
def _local_if_naive(value: str) -> str: def _local_if_naive(value: str) -> str:
@ -423,7 +329,7 @@ def _local_if_naive(value: str) -> str:
@mcp.tool() @mcp.tool()
@guard @core.guard
def changes(hours: float | None = None, since: str = "", until: str = "", kinds: str = "", key: str = "") -> str: def changes(hours: float | None = None, since: str = "", until: str = "", kinds: str = "", key: str = "") -> str:
"""Лента изменений задач: смена статуса, правка описания, сроки, новые комментарии. По умолчанию — с начала """Лента изменений задач: смена статуса, правка описания, сроки, новые комментарии. По умолчанию — с начала
сегодняшнего дня; hours — за последние N часов; since/until — ISO-8601 (без смещения — местное время); kinds — через запятую из сегодняшнего дня; hours — за последние N часов; since/until — ISO-8601 (без смещения — местное время); kinds — через запятую из
@ -432,7 +338,7 @@ def changes(hours: float | None = None, since: str = "", until: str = "", kinds:
return "либо hours, либо since" return "либо hours, либо since"
if hours is not None and hours <= 0: if hours is not None and hours <= 0:
return "hours — число больше 0" return "hours — число больше 0"
refusal = _unbound_or_unknown(required=not key) # с key — любая задача, как issue refusal = core.unbound_or_unknown(required=not key) # с key — любая задача, как issue
if refusal is not None: if refusal is not None:
return refusal return refusal
now = datetime.now().astimezone() now = datetime.now().astimezone()
@ -440,24 +346,24 @@ def changes(hours: float | None = None, since: str = "", until: str = "", kinds:
since = (now - timedelta(hours=hours)).isoformat() since = (now - timedelta(hours=hours)).isoformat()
elif not since: elif not since:
since = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat() since = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
data = _client().get("/changes", since=_local_if_naive(since), until=_local_if_naive(until), kinds=kinds, key=key, data = core.client().get("/changes", since=_local_if_naive(since), until=_local_if_naive(until), kinds=kinds, key=key,
project=None if key else PROJECT) project=None if key else core.PROJECT)
return fmt_changes(data, now) return fmt_changes(data, now)
@mcp.tool() @mcp.tool()
@guard @core.guard
@scoped(True) @core.scoped(True)
def notes_set(key: str, fact_status: str | None = None, work_kind: str | None = None, done_text: str | None = None, def notes_set(key: str, fact_status: str | None = None, work_kind: str | None = None, done_text: str | None = None,
rest_text: str | None = None, owner_note: str | None = None) -> str: rest_text: str | None = None, owner_note: str | None = None) -> str:
"""Правка служебных заметок задачи (только переданные поля).""" """Правка служебных заметок задачи (только переданные поля)."""
fields = {k: v for k, v in dict(fact_status=fact_status, work_kind=work_kind, done_text=done_text, fields = {k: v for k, v in dict(fact_status=fact_status, work_kind=work_kind, done_text=done_text,
rest_text=rest_text, owner_note=owner_note).items() if v is not None} rest_text=rest_text, owner_note=owner_note).items() if v is not None}
fields["updated_by"] = AUTHOR fields["updated_by"] = core.AUTHOR
if not is_temp(key): # новая задача пакета проекта — своя по построению if not is_temp(key): # новая задача пакета проекта — своя по построению
if PROJECT not in (_client().get(f"/issues/{key}").get("labels") or []): if core.PROJECT not in (core.client().get(f"/issues/{key}").get("labels") or []):
return f"{key} не в проекте {PROJECT}" return f"{key} не в проекте {core.PROJECT}"
note = _client().put(f"/issues/{key}/note", fields) note = core.client().put(f"/issues/{key}/note", fields)
return "заметки " + key + ": " + "; ".join(f"{k}: {note.get(k)}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k)) return "заметки " + key + ": " + "; ".join(f"{k}: {note.get(k)}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k))
@ -527,8 +433,8 @@ def _doc(text: str):
@mcp.tool() @mcp.tool()
@guard @core.guard
@scoped(True) @core.scoped(True)
@_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам. @_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам.
Параметры по типам (необязательные — в [ ]): Параметры по типам (необязательные — в [ ]):
""" + describe() + """ """ + describe() + """
@ -536,29 +442,29 @@ def _doc(text: str):
ensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит ensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит
файл в хаб и подставит file, filename по умолчанию — имя файла.""") файл в хаб и подставит file, filename по умолчанию — имя файла.""")
def batch_create(title: str, commands: list[dict]) -> str: def batch_create(title: str, commands: list[dict]) -> str:
client = _client() client = core.client()
prepared = _attach_files(client, commands) prepared = _attach_files(client, commands)
if isinstance(prepared, str): if isinstance(prepared, str):
return prepared return prepared
b = client.post("/batches", {"title": title, "author": AUTHOR, "project": PROJECT, "commands": prepared}) b = client.post("/batches", {"title": title, "author": core.AUTHOR, "project": core.PROJECT, "commands": prepared})
return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин" return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин"
@mcp.tool() @mcp.tool()
@guard @core.guard
@scoped(False) @core.scoped(False)
def batch_status(id: int) -> str: def batch_status(id: int) -> str:
"""Состояние пакета и его команд.""" """Состояние пакета и его команд."""
return fmt_batch(_client().get(f"/batches/{id}")) return fmt_batch(core.client().get(f"/batches/{id}"))
@mcp.tool() @mcp.tool()
@guard @core.guard
@scoped(False) @core.scoped(False)
async def batch_wait(id: int, timeout_minutes: int = 10) -> str: async def batch_wait(id: int, timeout_minutes: int = 10) -> str:
"""Ждать завершения пакета (до 60 мин), вернуть итог по командам. Ожидание не блокирует event loop сервера.""" """Ждать завершения пакета (до 60 мин), вернуть итог по командам. Ожидание не блокирует event loop сервера."""
deadline = time.monotonic() + min(max(int(timeout_minutes), 1), 60) * 60 deadline = time.monotonic() + min(max(int(timeout_minutes), 1), 60) * 60
client = _client() client = core.client()
while True: while True:
b = await asyncio.to_thread(client.get, f"/batches/{id}") b = await asyncio.to_thread(client.get, f"/batches/{id}")
if b["status"] in ("done", "cancelled") or time.monotonic() >= deadline: if b["status"] in ("done", "cancelled") or time.monotonic() >= deadline:
@ -567,114 +473,88 @@ async def batch_wait(id: int, timeout_minutes: int = 10) -> str:
@mcp.tool() @mcp.tool()
@guard @core.guard
@scoped(False) @core.scoped(False)
def batches(limit: int = 10) -> str: def batches(limit: int = 10) -> str:
"""Последние пакеты со статусом.""" """Последние пакеты со статусом."""
items = _client().get("/batches", project=PROJECT, limit=limit)["batches"] items = core.client().get("/batches", project=core.PROJECT, limit=limit)["batches"]
return "\n".join(fmt_batch(b) for b in items) or "пакетов нет" return "\n".join(fmt_batch(b) for b in items) or "пакетов нет"
@mcp.tool() @mcp.tool()
@guard @core.guard
@scoped(True) @core.scoped(True)
def command_retry(id: int) -> str: def command_retry(id: int) -> str:
"""Повторить команду — новый пакет с теми же параметрами.""" """Повторить команду — новый пакет с теми же параметрами."""
b = _client().post(f"/commands/{id}/retry", {"author": AUTHOR, "project": PROJECT}) b = core.client().post(f"/commands/{id}/retry", {"author": core.AUTHOR, "project": core.PROJECT})
return f"пакет #{b['id']} «Повтор команды {id}»: {b['count']} команд" return f"пакет #{b['id']} «Повтор команды {id}»: {b['count']} команд"
@mcp.tool() @mcp.tool()
@guard @core.guard
@scoped(False) @core.scoped(False)
def batch_cancel(id: int) -> str: def batch_cancel(id: int) -> str:
"""Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает — """Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает —
в ответе подсказка (POST /commands/N/cancel с force).""" в ответе подсказка (POST /commands/N/cancel с force)."""
return fmt_batch(_client().post(f"/batches/{id}/cancel", {})) return fmt_batch(core.client().post(f"/batches/{id}/cancel", {}))
@mcp.tool() @mcp.tool()
@guard @core.guard
@scoped(True) @core.scoped(True)
async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool = False) -> str: async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool = False) -> str:
"""Обновить зеркало из Jira: по ключам или полностью (базовая JQL). По умолчанию только ставит пакет; """Обновить зеркало из Jira: по ключам или полностью (базовая JQL). По умолчанию только ставит пакет;
wait=true — дождаться результата (до 30 мин).""" wait=true — дождаться результата (до 30 мин)."""
body = {"full": True} if full or not keys else {"keys": keys} body = {"full": True} if full or not keys else {"keys": keys}
body["author"] = AUTHOR body["author"] = core.AUTHOR
b = await asyncio.to_thread(_client().post, "/refresh", body) b = await asyncio.to_thread(core.client().post, "/refresh", body)
return await batch_wait(b["id"], 30) if wait else f"пакет #{b['id']} «Обновление» поставлен" return await batch_wait(b["id"], 30) if wait else f"пакет #{b['id']} «Обновление» поставлен"
@mcp.tool() @mcp.tool()
@guard @core.guard
@scoped(False) @core.scoped(False)
def agent_status() -> str: def agent_status() -> str:
"""Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия, """Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия,
состояние самообновления и запускатель.""" состояние самообновления и запускатель."""
return fmt_agent(_client().get("/agent")) return fmt_agent(core.client().get("/agent"))
@mcp.tool() @mcp.tool()
@guard @core.guard
@scoped(False) @core.scoped(False)
def sync_log(limit: int = 10) -> str: def sync_log(limit: int = 10) -> str:
"""Журнал обновлений зеркала.""" """Журнал обновлений зеркала."""
runs = _client().get("/sync-runs", limit=limit)["runs"] runs = core.client().get("/sync-runs", limit=limit)["runs"]
def journal(r): def journal(r):
at = r.get("history_at") at = r.get("history_at")
return "журнал: принят " + datetime.fromisoformat(at).astimezone().strftime("%d.%m %H:%M") if at else "журнал: нет" return "журнал: принят " + datetime.fromisoformat(at).astimezone().strftime("%d.%m %H:%M") if at else "журнал: нет"
return "\n".join(f"#{r['id']} {r['kind']} {_cut(r.get('query'), 60)}: {r['started_at']} → {_v(r.get('finished_at'))}, " return "\n".join(f"#{r['id']} {r['kind']} {core.cut(r.get('query'), 60)}: {r['started_at']} → {core.value(r.get('finished_at'))}, "
f"задач {r['issues_count']}, связей {r['links_count']}, вызовов {r['jira_calls']}, 429: {r['rate_limited']}, " f"задач {r['issues_count']}, связей {r['links_count']}, вызовов {r['jira_calls']}, 429: {r['rate_limited']}, "
f"{journal(r)}" for r in runs) or "обновлений не было" f"{journal(r)}" for r in runs) or "обновлений не было"
@mcp.tool() @mcp.tool()
@guard @core.guard
@scoped(False) @core.scoped(False)
def settings_get(key: str = "") -> str: def settings_get(key: str = "") -> str:
"""Настройки хаба (все или одна по ключу).""" """Настройки хаба (все или одна по ключу)."""
data = _client().get("/settings") data = core.client().get("/settings")
return json.dumps(data.get(key) if key else data, ensure_ascii=False, indent=1) return json.dumps(data.get(key) if key else data, ensure_ascii=False, indent=1)
@mcp.tool() @mcp.tool()
@guard @core.guard
@scoped(False) @core.scoped(False)
def settings_set(key: str, value: str) -> str: def settings_set(key: str, value: str) -> str:
"""Записать настройку: value — JSON. Поднимает settings_version.""" """Записать настройку: value — JSON. Поднимает settings_version."""
try: try:
parsed = json.loads(value) parsed = json.loads(value)
except ValueError as e: except ValueError as e:
return f"значение не JSON: {e}" return f"значение не JSON: {e}"
out = _client().put("/settings", {key: parsed}) out = core.client().put("/settings", {key: parsed})
return f"settings_version = {out['settings_version']}" return f"settings_version = {out['settings_version']}"
@mcp.tool() core.register_core_tools(mcp)
@guard
def projects() -> str:
"""Проекты хаба: метка, название, доска, тип доски, число задач; текущий проект сессии отмечен."""
return fmt_projects(_client().get("/projects")["projects"], PROJECT)
@mcp.tool()
@guard
def project_create(label: str, name: str, board_id: int | None = None) -> str:
"""Завести проект (метка Jira, название, необязательно id доски). Хаб ставит пакет обновления; в ответе — фрагмент
.mcp.json для репозитория проекта."""
body = {"label": label, "name": name, "author": AUTHOR}
if board_id is not None:
body["board_id"] = board_id
out = _client().post("/projects", body)
p = out["project"]
return (f"проект {p['label']} «{p['name']}» заведён, пакет обновления #{out['batch_id']}\n"
"Положите в корень репозитория проекта .mcp.json (слейте с имеющимся) и переподключите MCP: "
"/mcp → reconnect, одобрите сервер проекта.\n"
+ json.dumps(mcp_json(p["label"]), ensure_ascii=False, indent=2))
def run(project: str | None = None) -> None:
global PROJECT
PROJECT = project
mcp.run(transport="stdio")

View File

@ -5,7 +5,8 @@ import json
import os import os
from pathlib import Path from pathlib import Path
from jira_hub.core import api, db, mcp_server, settings from jira_hub.core import api, db, settings
from jira_hub.modules.jira import mcp as jira_mcp
FIXTURES = Path(__file__).parent / "fixtures" / "baseline" FIXTURES = Path(__file__).parent / "fixtures" / "baseline"
@ -20,7 +21,7 @@ def _check(name, snapshot):
def test_mcp_tools(): def test_mcp_tools():
tools = asyncio.run(mcp_server.mcp.list_tools()) tools = asyncio.run(jira_mcp.mcp.list_tools())
_check("mcp_tools", [{"name": t.name, "description": t.description, "inputSchema": t.inputSchema} _check("mcp_tools", [{"name": t.name, "description": t.description, "inputSchema": t.inputSchema}
for t in tools]) for t in tools])

View File

@ -66,10 +66,22 @@ def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys):
assert sorted(p.name for p in paths.release_dir().iterdir()) == ["agent.py", "manifest.json"] assert sorted(p.name for p in paths.release_dir().iterdir()) == ["agent.py", "manifest.json"]
def test_mcp_project_option(monkeypatch): def test_mcp_default_module(monkeypatch):
from jira_hub.core import mcp_server from jira_hub.core import mcp as core_mcp
from jira_hub.modules.jira import mcp as jira_mcp
seen = [] seen = []
monkeypatch.setattr(mcp_server, "run", lambda project=None: seen.append(project)) monkeypatch.setattr(core_mcp, "PROJECT", None)
monkeypatch.setattr(jira_mcp.mcp, "run", lambda transport="stdio": seen.append((transport, core_mcp.PROJECT)))
assert cli.main(["mcp", "--project", "AWPCA"]) == 0 assert cli.main(["mcp", "--project", "AWPCA"]) == 0
assert cli.main(["mcp"]) == 0 assert cli.main(["mcp", "jira", "--project", "AWPCA"]) == 0
assert seen == ["AWPCA", None] assert seen == [("stdio", "AWPCA"), ("stdio", "AWPCA")]
def test_mcp_unknown_module(monkeypatch, capsys):
from jira_hub.modules.jira import mcp as jira_mcp
seen = []
monkeypatch.setattr(jira_mcp.mcp, "run", lambda transport="stdio": seen.append(transport))
assert cli.main(["mcp", "nope"]) == 2
err = capsys.readouterr().err
assert "неизвестный модуль nope" in err and "jira" in err
assert seen == []

View File

@ -8,7 +8,7 @@ import jira_hub
def test_fresh_imports(): def test_fresh_imports():
r = subprocess.run( r = subprocess.run(
[sys.executable, "-c", "import jira_hub.core.api, jira_hub.core.mcp_server, jira_hub.cli"], [sys.executable, "-c", "import jira_hub.core.api, jira_hub.core.mcp, jira_hub.modules.jira.mcp, jira_hub.cli"],
capture_output=True, text=True) capture_output=True, text=True)
assert r.returncode == 0, r.stderr assert r.returncode == 0, r.stderr
@ -19,5 +19,5 @@ def test_web_app_path():
def test_hub_root(): def test_hub_root():
from jira_hub.core import mcp_server from jira_hub.core import mcp
assert (mcp_server.HUB_ROOT / "pyproject.toml").is_file() assert (mcp.HUB_ROOT / "pyproject.toml").is_file()

View File

@ -9,7 +9,9 @@ from types import SimpleNamespace
import pytest import pytest
from jira_hub.core import api, files, mcp_server, settings from jira_hub.core import api, files, settings
from jira_hub.core import mcp as core_mcp
from jira_hub.modules.jira import mcp as jmcp
from jira_hub.modules.jira import commands from jira_hub.modules.jira import commands
from jira_hub.core.client import HubClient from jira_hub.core.client import HubClient
@ -25,61 +27,61 @@ def live(tmp_path, monkeypatch, add_project):
srv = api.make_server(h, "127.0.0.1", 0) srv = api.make_server(h, "127.0.0.1", 0)
threading.Thread(target=srv.serve_forever, daemon=True).start() threading.Thread(target=srv.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{srv.server_port}" base = f"http://127.0.0.1:{srv.server_port}"
monkeypatch.setattr(mcp_server, "_client", lambda: HubClient(base, tok["client"])) monkeypatch.setattr(core_mcp, "client", lambda: HubClient(base, tok["client"]))
monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05) monkeypatch.setattr(jmcp, "POLL_SECONDS", 0.05)
monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
yield {"agent": HubClient(base, tok["agent"]), "client": HubClient(base, tok["client"])} yield {"agent": HubClient(base, tok["agent"]), "client": HubClient(base, tok["client"])}
srv.shutdown() srv.shutdown()
srv.server_close() srv.server_close()
def test_offline_message(monkeypatch): def test_offline_message(monkeypatch):
monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1)) monkeypatch.setattr(core_mcp, "client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1))
assert mcp_server.issues() == mcp_server.OFFLINE assert jmcp.issues() == core_mcp.OFFLINE
assert mcp_server.agent_status() == mcp_server.OFFLINE assert jmcp.agent_status() == core_mcp.OFFLINE
def test_tools_roundtrip(live): def test_tools_roundtrip(live):
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема раз", "status": "Готово", "duedate": "2026-10-23", live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема раз", "status": "Готово", "duedate": "2026-10-23",
"assignee": "ivanov", "sprint": "Спринт 2", "labels": ["AWPCA"]}], "links": []}) "assignee": "ivanov", "sprint": "Спринт 2", "labels": ["AWPCA"]}], "links": []})
out = mcp_server.issues(sprint="Спринт 2") out = jmcp.issues(sprint="Спринт 2")
assert "A-1" in out and "Тема раз" in out and "ivanov" in out assert "A-1" in out and "Тема раз" in out and "ivanov" in out
assert "В работе" in mcp_server.notes_set("A-1", fact_status="В работе") assert "В работе" in jmcp.notes_set("A-1", fact_status="В работе")
detail = mcp_server.issue("A-1") detail = jmcp.issue("A-1")
assert "fact_status: В работе" in detail and "Готово" in detail assert "fact_status: В работе" in detail and "Готово" in detail
created = mcp_server.batch_create("срок", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-24"}}]) created = jmcp.batch_create("срок", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-24"}}])
batch_id = int(created.split("#")[1].split()[0]) batch_id = int(created.split("#")[1].split()[0])
assert "2 команд" in created and "мин" in created assert "2 команд" in created and "мин" in created
bad = mcp_server.batch_create("x", [{"type": "ensure_fields", "params": {"key": "A-1"}}]) bad = jmcp.batch_create("x", [{"type": "ensure_fields", "params": {"key": "A-1"}}])
assert bad.startswith("ошибка хаба 422") assert bad.startswith("ошибка хаба 422")
cmd = live["agent"].post("/agent/claim", {})["command"] cmd = live["agent"].post("/agent/claim", {})["command"]
live["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 400 поле"}) live["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 400 поле"})
tail = live["agent"].post("/agent/claim", {})["command"] tail = live["agent"].post("/agent/claim", {})["command"]
live["agent"].post(f"/agent/commands/{tail['id']}/result", {"status": "done"}) live["agent"].post(f"/agent/commands/{tail['id']}/result", {"status": "done"})
waited = asyncio.run(mcp_server.batch_wait(batch_id, timeout_minutes=1)) waited = asyncio.run(jmcp.batch_wait(batch_id, timeout_minutes=1))
assert "done" in waited and "failed" in waited and "HTTP 400" in waited assert "done" in waited and "failed" in waited and "HTTP 400" in waited
assert f"#{batch_id}" in mcp_server.batches(limit=5) assert f"#{batch_id}" in jmcp.batches(limit=5)
assert "Повтор команды" in mcp_server.command_retry(cmd["id"]) assert "Повтор команды" in jmcp.command_retry(cmd["id"])
assert "cancelled" in mcp_server.batch_cancel(batch_id + 1) assert "cancelled" in jmcp.batch_cancel(batch_id + 1)
r = asyncio.run(mcp_server.refresh(full=True)) r = asyncio.run(jmcp.refresh(full=True))
assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", r) assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", r)
assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", asyncio.run(mcp_server.refresh(keys=["A-1"], wait=False))) assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", asyncio.run(jmcp.refresh(keys=["A-1"], wait=False)))
assert "онлайн" in mcp_server.agent_status() assert "онлайн" in jmcp.agent_status()
assert "120" in mcp_server.settings_get("pacing") assert "120" in jmcp.settings_get("pacing")
assert "settings_version" in mcp_server.settings_set("pacing", json.dumps({"refresh": 1})) assert "settings_version" in jmcp.settings_set("pacing", json.dumps({"refresh": 1}))
assert mcp_server.settings_set("tokens", '"x"').startswith("ошибка хаба 422") assert jmcp.settings_set("tokens", '"x"').startswith("ошибка хаба 422")
def test_formatters(): def test_formatters():
assert mcp_server.fmt_issues([]) == "задач нет" assert jmcp.fmt_issues([]) == "задач нет"
text = mcp_server.fmt_issues([{"key": "A-1", "summary": "x" * 100, "status": "Готово", "duedate": None, "assignee": None, text = jmcp.fmt_issues([{"key": "A-1", "summary": "x" * 100, "status": "Готово", "duedate": None, "assignee": None,
"sprint": None, "fact_status": None}]) "sprint": None, "fact_status": None}])
assert "x" * 79 + "…" in text and "x" * 80 not in text and "—" in text assert "x" * 79 + "…" in text and "x" * 80 not in text and "—" in text
assert "очередь" in mcp_server.fmt_agent({"online": False, "agent_version": None, "current_command_id": None, assert "очередь" in jmcp.fmt_agent({"online": False, "agent_version": None, "current_command_id": None,
"rate_limited_24h": 0, "redelivered": [], "warning": None, "last_seen_at": None, "rate_limited_24h": 0, "redelivered": [], "warning": None, "last_seen_at": None,
"settings_version": None}) "settings_version": None})
text = mcp_server.fmt_agent({"online": True, "agent_version": "2", "current_command_id": 40, "rate_limited_24h": 0, text = jmcp.fmt_agent({"online": True, "agent_version": "2", "current_command_id": 40, "rate_limited_24h": 0,
"redelivered": [{"id": 40, "type": "ensure_fields", "claimed_at": "2026-10-13T10:00:00+00:00", "redelivered": [{"id": 40, "type": "ensure_fields", "claimed_at": "2026-10-13T10:00:00+00:00",
"deliveries": 2}], "deliveries": 2}],
"warning": None, "last_seen_at": "2026-10-13T10:00:00+00:00", "settings_version": 3}) "warning": None, "last_seen_at": "2026-10-13T10:00:00+00:00", "settings_version": 3})
@ -87,7 +89,7 @@ def test_formatters():
def test_settings_set_bad_json(live): def test_settings_set_bad_json(live):
assert mcp_server.settings_set("pacing", "{не json").startswith("значение не JSON") assert jmcp.settings_set("pacing", "{не json").startswith("значение не JSON")
def _batch(status): def _batch(status):
@ -116,25 +118,25 @@ def _fake_time(monkeypatch, step=1.0):
async def sleep(_): async def sleep(_):
clock["sleeps"] += 1 clock["sleeps"] += 1
monkeypatch.setattr(mcp_server, "time", SimpleNamespace(monotonic=monotonic)) monkeypatch.setattr(jmcp, "time", SimpleNamespace(monotonic=monotonic))
monkeypatch.setattr(mcp_server, "asyncio", SimpleNamespace(sleep=sleep, to_thread=asyncio.to_thread)) monkeypatch.setattr(jmcp, "asyncio", SimpleNamespace(sleep=sleep, to_thread=asyncio.to_thread))
return clock return clock
def test_batch_wait_polls_until_done(monkeypatch): def test_batch_wait_polls_until_done(monkeypatch):
fake = FakeClient(["running", "running", "done"]) fake = FakeClient(["running", "running", "done"])
monkeypatch.setattr(mcp_server, "_client", lambda: fake) monkeypatch.setattr(core_mcp, "client", lambda: fake)
clock = _fake_time(monkeypatch) clock = _fake_time(monkeypatch)
out = asyncio.run(mcp_server.batch_wait(7, timeout_minutes=10)) out = asyncio.run(jmcp.batch_wait(7, timeout_minutes=10))
assert out == mcp_server.fmt_batch(_batch("done")) and "таймаут" not in out assert out == jmcp.fmt_batch(_batch("done")) and "таймаут" not in out
assert fake.calls == 3 and clock["sleeps"] == 2 assert fake.calls == 3 and clock["sleeps"] == 2
def test_batch_wait_timeout(monkeypatch): def test_batch_wait_timeout(monkeypatch):
fake = FakeClient(["running"]) fake = FakeClient(["running"])
monkeypatch.setattr(mcp_server, "_client", lambda: fake) monkeypatch.setattr(core_mcp, "client", lambda: fake)
clock = _fake_time(monkeypatch, step=30.0) # 1 минута = 2 шага монотонных часов clock = _fake_time(monkeypatch, step=30.0) # 1 минута = 2 шага монотонных часов
out = asyncio.run(mcp_server.batch_wait(7, timeout_minutes=1)) out = asyncio.run(jmcp.batch_wait(7, timeout_minutes=1))
assert "таймаут ожидания" in out assert "таймаут ожидания" in out
assert 1 < fake.calls < 10 and clock["sleeps"] == fake.calls - 1 assert 1 < fake.calls < 10 and clock["sleeps"] == fake.calls - 1
@ -142,47 +144,47 @@ def test_batch_wait_timeout(monkeypatch):
@pytest.mark.parametrize("asked,minutes", [(0, 1), (1000, 60)]) @pytest.mark.parametrize("asked,minutes", [(0, 1), (1000, 60)])
def test_batch_wait_clamps_timeout(monkeypatch, asked, minutes): def test_batch_wait_clamps_timeout(monkeypatch, asked, minutes):
fake = FakeClient(["running"]) fake = FakeClient(["running"])
monkeypatch.setattr(mcp_server, "_client", lambda: fake) monkeypatch.setattr(core_mcp, "client", lambda: fake)
clock = _fake_time(monkeypatch, step=60.0) # каждый вызов часов = +1 минута clock = _fake_time(monkeypatch, step=60.0) # каждый вызов часов = +1 минута
asyncio.run(mcp_server.batch_wait(7, timeout_minutes=asked)) asyncio.run(jmcp.batch_wait(7, timeout_minutes=asked))
# первый вызов часов (1 мин) задаёт старт; дедлайн = старт + minutes; выход, когда now >= дедлайна # первый вызов часов (1 мин) задаёт старт; дедлайн = старт + minutes; выход, когда now >= дедлайна
assert fake.calls == minutes assert fake.calls == minutes
def test_client_created_once_per_wait(monkeypatch): def test_client_created_once_per_wait(monkeypatch):
fake, made = FakeClient(["running", "running", "done"]), [] fake, made = FakeClient(["running", "running", "done"]), []
monkeypatch.setattr(mcp_server, "_client", lambda: made.append(1) or fake) monkeypatch.setattr(core_mcp, "client", lambda: made.append(1) or fake)
_fake_time(monkeypatch) _fake_time(monkeypatch)
asyncio.run(mcp_server.batch_wait(7)) asyncio.run(jmcp.batch_wait(7))
assert len(made) == 1 assert len(made) == 1
def test_sync_log_content(monkeypatch): def test_sync_log_content(monkeypatch):
runs = [{"id": 3, "kind": "full", "query": "project = AWPCA", "started_at": "2026-10-11T10:00", "finished_at": None, runs = [{"id": 3, "kind": "full", "query": "project = AWPCA", "started_at": "2026-10-11T10:00", "finished_at": None,
"issues_count": 12, "links_count": 5, "jira_calls": 4, "rate_limited": 1}] "issues_count": 12, "links_count": 5, "jira_calls": 4, "rate_limited": 1}]
monkeypatch.setattr(mcp_server, "_client", lambda: FakeClient(["done"], runs)) monkeypatch.setattr(core_mcp, "client", lambda: FakeClient(["done"], runs))
assert mcp_server.sync_log() == "#3 full project = AWPCA: 2026-10-11T10:00 → —, задач 12, связей 5, вызовов 4, 429: 1, журнал: нет" assert jmcp.sync_log() == "#3 full project = AWPCA: 2026-10-11T10:00 → —, задач 12, связей 5, вызовов 4, 429: 1, журнал: нет"
def test_async_tools_are_coroutines_and_registered(): def test_async_tools_are_coroutines_and_registered():
assert asyncio.iscoroutinefunction(mcp_server.batch_wait) and asyncio.iscoroutinefunction(mcp_server.refresh) assert asyncio.iscoroutinefunction(jmcp.batch_wait) and asyncio.iscoroutinefunction(jmcp.refresh)
tools = {t.name: t for t in asyncio.run(mcp_server.mcp.list_tools())} tools = {t.name: t for t in asyncio.run(jmcp.mcp.list_tools())}
assert {"batch_wait", "refresh"} <= set(tools) assert {"batch_wait", "refresh"} <= set(tools)
assert tools["refresh"].inputSchema["properties"]["wait"]["default"] is False assert tools["refresh"].inputSchema["properties"]["wait"]["default"] is False
def test_async_guard_offline(monkeypatch): def test_async_guard_offline(monkeypatch):
monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1)) monkeypatch.setattr(core_mcp, "client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1))
assert asyncio.run(mcp_server.batch_wait(1)) == mcp_server.OFFLINE assert asyncio.run(jmcp.batch_wait(1)) == core_mcp.OFFLINE
assert asyncio.run(mcp_server.refresh(full=True)) == mcp_server.OFFLINE assert asyncio.run(jmcp.refresh(full=True)) == core_mcp.OFFLINE
def test_batch_wait_does_not_block_event_loop(monkeypatch): def test_batch_wait_does_not_block_event_loop(monkeypatch):
"""Пока идёт ожидание, другая задача event loop продолжает выполняться.""" """Пока идёт ожидание, другая задача event loop продолжает выполняться."""
fake = FakeClient(["running", "running", "done"]) fake = FakeClient(["running", "running", "done"])
monkeypatch.setattr(mcp_server, "_client", lambda: fake) monkeypatch.setattr(core_mcp, "client", lambda: fake)
monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.01) monkeypatch.setattr(jmcp, "POLL_SECONDS", 0.01)
async def scenario(): async def scenario():
ticks = [] ticks = []
@ -192,7 +194,7 @@ def test_batch_wait_does_not_block_event_loop(monkeypatch):
ticks.append(1) ticks.append(1)
await asyncio.sleep(0.005) await asyncio.sleep(0.005)
out, _ = await asyncio.gather(mcp_server.batch_wait(7), ticker()) out, _ = await asyncio.gather(jmcp.batch_wait(7), ticker())
return out, ticks return out, ticks
out, ticks = asyncio.run(scenario()) out, ticks = asyncio.run(scenario())
@ -203,12 +205,12 @@ def test_fmt_batch_warns_about_claimed():
b = {"id": 9, "title": "x", "author": "claude", "created_at": "2026-10-14T10:00:00+00:00", "status": "running", b = {"id": 9, "title": "x", "author": "claude", "created_at": "2026-10-14T10:00:00+00:00", "status": "running",
"remaining_seconds": 120, "counts": {"claimed": 1, "cancelled": 1}, "commands": [], "remaining_seconds": 120, "counts": {"claimed": 1, "cancelled": 1}, "commands": [],
"still_claimed": [{"id": 30, "type": "ensure_fields"}]} "still_claimed": [{"id": 30, "type": "ensure_fields"}]}
text = mcp_server.fmt_batch(b) text = jmcp.fmt_batch(b)
assert "команда #30 уже у агента и будет выполнена" in text and "POST /commands/30/cancel" in text assert "команда #30 уже у агента и будет выполнена" in text and "POST /commands/30/cancel" in text
def test_batch_create_mentions_labels(): def test_batch_create_mentions_labels():
doc = mcp_server.batch_create.__doc__ or "" doc = jmcp.batch_create.__doc__ or ""
assert "метку проекта сессии хаб дописывает в ensure_issue сам" in doc assert "метку проекта сессии хаб дописывает в ensure_issue сам" in doc
assert "метки других проектов запрещены" in doc and "board_id для ensure_sprint выставляет хаб" in doc assert "метки других проектов запрещены" in doc and "board_id для ensure_sprint выставляет хаб" in doc
assert "AWPCA" not in doc assert "AWPCA" not in doc
@ -216,7 +218,7 @@ def test_batch_create_mentions_labels():
def test_batch_create_description_lists_params_from_spec(): def test_batch_create_description_lists_params_from_spec():
"""Клиент MCP не видит specs/commands: описание инструмента перечисляет параметры каждого типа из SPEC (#44).""" """Клиент MCP не видит specs/commands: описание инструмента перечисляет параметры каждого типа из SPEC (#44)."""
tools = {t.name: t for t in asyncio.run(mcp_server.mcp.list_tools())} tools = {t.name: t for t in asyncio.run(jmcp.mcp.list_tools())}
desc = tools["batch_create"].description desc = tools["batch_create"].description
for ctype, spec in commands.SPEC.items(): for ctype, spec in commands.SPEC.items():
line = next(ln for ln in desc.splitlines() if ln.startswith(f"- {ctype}:")) line = next(ln for ln in desc.splitlines() if ln.startswith(f"- {ctype}:"))
@ -234,13 +236,13 @@ def test_batch_create_description_lists_params_from_spec():
def test_fmt_agent_update_lines(): def test_fmt_agent_update_lines():
base = {"online": True, "agent_version": "6", "current_command_id": None, "rate_limited_24h": 0, "redelivered": [], base = {"online": True, "agent_version": "6", "current_command_id": None, "rate_limited_24h": 0, "redelivered": [],
"warning": None, "last_seen_at": "2026-10-14T20:00:00+00:00", "settings_version": 3} "warning": None, "last_seen_at": "2026-10-14T20:00:00+00:00", "settings_version": 3}
text = mcp_server.fmt_agent({**base, "release": {"version": 7, "sha256": "s", "published_at": "2026-10-14T19:00:00+00:00"}, text = jmcp.fmt_agent({**base, "release": {"version": 7, "sha256": "s", "published_at": "2026-10-14T19:00:00+00:00"},
"update_state": {"state": "rolled_back", "from_version": 6, "to_version": 7}, "update_state": {"state": "rolled_back", "from_version": 6, "to_version": 7},
"launcher_version": 1, "update_hint": "откат с v7 на v6: код 1 через 3 с"}) "launcher_version": 1, "update_hint": "откат с v7 на v6: код 1 через 3 с"})
assert "публикация: v7 от 2026-10-14T19:00:00+00:00" in text assert "публикация: v7 от 2026-10-14T19:00:00+00:00" in text
assert "обновление: rolled_back v6 → v7" in text assert "обновление: rolled_back v6 → v7" in text
assert "запускатель: v1" in text and "откат с v7 на v6: код 1 через 3 с" in text assert "запускатель: v1" in text and "откат с v7 на v6: код 1 через 3 с" in text
text = mcp_server.fmt_agent({**base, "release": None, "update_state": None, "launcher_version": None, "update_hint": None}) text = jmcp.fmt_agent({**base, "release": None, "update_state": None, "launcher_version": None, "update_hint": None})
assert "публикации нет" in text and "без запускателя" in text and "обновление:" not in text assert "публикации нет" in text and "без запускателя" in text and "обновление:" not in text
@ -250,18 +252,18 @@ def test_fmt_agent_launcher_unknown_after_restart():
"warning": None, "last_seen_at": "2026-10-15T10:00:00+00:00", "settings_version": 3, "warning": None, "last_seen_at": "2026-10-15T10:00:00+00:00", "settings_version": 3,
"release": {"version": 10, "sha256": "s", "published_at": "2026-10-15T09:00:00+00:00"}, "release": {"version": 10, "sha256": "s", "published_at": "2026-10-15T09:00:00+00:00"},
"update_state": None, "launcher_version": None, "update_hint": None} "update_state": None, "launcher_version": None, "update_hint": None}
text = mcp_server.fmt_agent({**base, "agent_reported": False}) text = jmcp.fmt_agent({**base, "agent_reported": False})
assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in text assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in text
assert "без запускателя" not in text and "агент запущен без run.py" not in text assert "без запускателя" not in text and "агент запущен без run.py" not in text
assert "без запускателя" in mcp_server.fmt_agent({**base, "agent_reported": True}) assert "без запускателя" in jmcp.fmt_agent({**base, "agent_reported": True})
assert "без запускателя" in mcp_server.fmt_agent(base) # старый хаб: поля agent_reported нет assert "без запускателя" in jmcp.fmt_agent(base) # старый хаб: поля agent_reported нет
def test_agent_status_right_after_hub_start(live): def test_agent_status_right_after_hub_start(live):
"""Спека mcp «Статус агента сразу после перезапуска хаба».""" """Спека mcp «Статус агента сразу после перезапуска хаба»."""
assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in mcp_server.agent_status() assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in jmcp.agent_status()
live["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2}) live["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2})
text = mcp_server.agent_status() text = jmcp.agent_status()
assert "запускатель: v2" in text and "неизвестно" not in text assert "запускатель: v2" in text and "неизвестно" not in text
@ -273,17 +275,17 @@ def _detail(**over):
def test_fmt_issue_comments(): def test_fmt_issue_comments():
assert "комментариев нет" in mcp_server.fmt_issue(_detail()) assert "комментариев нет" in jmcp.fmt_issue(_detail())
comments = [{"comment_id": str(n), "author": f"Автор {n}", "created": f"2026-10-1{n}T12:00:00.000+0300", comments = [{"comment_id": str(n), "author": f"Автор {n}", "created": f"2026-10-1{n}T12:00:00.000+0300",
"updated": None, "body": ("д" * 500 if n == 7 else f"текст {n}\nстрока")} for n in range(7, 0, -1)] "updated": None, "body": ("д" * 500 if n == 7 else f"текст {n}\nстрока")} for n in range(7, 0, -1)]
text = mcp_server.fmt_issue(_detail(comments=comments)) text = jmcp.fmt_issue(_detail(comments=comments))
assert "комментарии (7):" in text assert "комментарии (7):" in text
assert "д" * 299 + "…" in text and "д" * 300 not in text # самый новый — первым, обрезан до 300 assert "д" * 299 + "…" in text and "д" * 300 not in text # самый новый — первым, обрезан до 300
assert "Автор 3" in text and "Автор 2" not in text # 5 последних: 7…3 assert "Автор 3" in text and "Автор 2" not in text # 5 последних: 7…3
assert " #7 Автор 7 2026-10-17T12:00:00.000+0300: ддд" in text and " #3 Автор 3 " in text # номер — для comment (#80) assert " #7 Автор 7 2026-10-17T12:00:00.000+0300: ддд" in text and " #3 Автор 3 " in text # номер — для comment (#80)
assert "ещё 2" in text assert "ещё 2" in text
assert text.index("заметки:") < text.index("комментарии (7):") < text.index("команды:") assert text.index("заметки:") < text.index("комментарии (7):") < text.index("команды:")
no_author = mcp_server.fmt_issue(_detail(comments=[{"comment_id": "1", "author": None, "created": None, no_author = jmcp.fmt_issue(_detail(comments=[{"comment_id": "1", "author": None, "created": None,
"updated": None, "body": ""}])) "updated": None, "body": ""}]))
assert "комментарии (1):" in no_author and "ещё" not in no_author assert "комментарии (1):" in no_author and "ещё" not in no_author
@ -291,10 +293,10 @@ def test_fmt_issue_comments():
def test_fmt_comment(): def test_fmt_comment():
"""Один комментарий целиком (#80): переносы строк сохраняются, «изменён» — только если правили.""" """Один комментарий целиком (#80): переносы строк сохраняются, «изменён» — только если правили."""
body = "первая строка\n" + "т" * 1000 body = "первая строка\n" + "т" * 1000
text = mcp_server.fmt_comment("A-1", {"comment_id": "12", "author": "golov", "created": "2026-10-20T10:00:00.000+0300", text = jmcp.fmt_comment("A-1", {"comment_id": "12", "author": "golov", "created": "2026-10-20T10:00:00.000+0300",
"updated": "2026-10-21T09:00:00.000+0300", "body": body}) "updated": "2026-10-21T09:00:00.000+0300", "body": body})
assert text == ("A-1 #12 golov, создан 2026-10-20T10:00:00.000+0300, изменён 2026-10-21T09:00:00.000+0300\n" + body) assert text == ("A-1 #12 golov, создан 2026-10-20T10:00:00.000+0300, изменён 2026-10-21T09:00:00.000+0300\n" + body)
same = mcp_server.fmt_comment("A-1", {"comment_id": "12", "author": None, "created": "2026-10-20T10:00:00.000+0300", same = jmcp.fmt_comment("A-1", {"comment_id": "12", "author": None, "created": "2026-10-20T10:00:00.000+0300",
"updated": "2026-10-20T10:00:00.000+0300", "body": ""}) "updated": "2026-10-20T10:00:00.000+0300", "body": ""})
assert same == "A-1 #12 —, создан 2026-10-20T10:00:00.000+0300\n—" assert same == "A-1 #12 —, создан 2026-10-20T10:00:00.000+0300\n—"
@ -307,14 +309,14 @@ def test_comment_tool(live, monkeypatch):
{"id": "102", "author": "ivanov", "body": "коротко", {"id": "102", "author": "ivanov", "body": "коротко",
"created": "2026-10-21T10:00:00.000+0300"}]}], "created": "2026-10-21T10:00:00.000+0300"}]}],
"links": []}) "links": []})
assert "#101 golov" in mcp_server.issue("A-1") and long not in mcp_server.issue("A-1") assert "#101 golov" in jmcp.issue("A-1") and long not in jmcp.issue("A-1")
out = mcp_server.comment("A-1", "101") out = jmcp.comment("A-1", "101")
assert out.startswith("A-1 #101 golov, создан 2026-10-20T10:00:00.000+0300\n") and out.endswith(long) assert out.startswith("A-1 #101 golov, создан 2026-10-20T10:00:00.000+0300\n") and out.endswith(long)
assert mcp_server.comment("A-1", "#102").endswith("\nкоротко") # номер можно передать как в issue assert jmcp.comment("A-1", "#102").endswith("\nкоротко") # номер можно передать как в issue
assert mcp_server.comment("A-1", "999") == "у A-1 нет комментария #999" assert jmcp.comment("A-1", "999") == "у A-1 нет комментария #999"
assert mcp_server.comment("A-404", "1").startswith("ошибка хаба 404") assert jmcp.comment("A-404", "1").startswith("ошибка хаба 404")
monkeypatch.setattr(mcp_server, "PROJECT", None) monkeypatch.setattr(core_mcp, "PROJECT", None)
assert mcp_server.comment("A-1", "101") == mcp_server.NOT_BOUND assert jmcp.comment("A-1", "101") == core_mcp.NOT_BOUND
BUG = {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", BUG = {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана",
@ -323,7 +325,7 @@ BUG = {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bu
def _batch_commands(out): def _batch_commands(out):
batch_id = int(out.split("#")[1].split()[0]) batch_id = int(out.split("#")[1].split()[0])
return mcp_server._client().get(f"/batches/{batch_id}")["commands"] return core_mcp.client().get(f"/batches/{batch_id}")["commands"]
def _write(path, data): def _write(path, data):
@ -333,11 +335,11 @@ def _write(path, data):
def test_batch_create_bug_with_local_file(live, tmp_path): def test_batch_create_bug_with_local_file(live, tmp_path):
"""Спека mcp «Ошибка с файлом из чата»: файл загружен в хаб, path заменён на file и filename.""" """Спека mcp «Ошибка с файлом из чата»: файл загружен в хаб, path заменён на file и filename."""
mcp_server.settings_set("issue_types", json.dumps({"task": {"name": "Задача", "id": "10100"}, jmcp.settings_set("issue_types", json.dumps({"task": {"name": "Задача", "id": "10100"},
"bug": {"name": "Ошибка", "id": None}})) "bug": {"name": "Ошибка", "id": None}}))
f = tmp_path / "ОСВ сч. 60 за 2 кв. 2026.xlsx" f = tmp_path / "ОСВ сч. 60 за 2 кв. 2026.xlsx"
f.write_bytes(b"PK\x03\x04 xlsx") f.write_bytes(b"PK\x03\x04 xlsx")
out = mcp_server.batch_create("Ошибка ОСВ 60", [BUG, {"type": "ensure_attachment", out = jmcp.batch_create("Ошибка ОСВ 60", [BUG, {"type": "ensure_attachment",
"params": {"key": "new:1", "path": str(f)}}]) "params": {"key": "new:1", "path": str(f)}}])
assert "3 команд" in out assert "3 команд" in out
cmds = _batch_commands(out) cmds = _batch_commands(out)
@ -351,7 +353,7 @@ def test_batch_create_explicit_filename_and_tilde(live, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("HOME", str(tmp_path))
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], "links": []}) live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], "links": []})
(tmp_path / "скрин.png").write_bytes(b"png") (tmp_path / "скрин.png").write_bytes(b"png")
out = mcp_server.batch_create("скрин", [{"type": "ensure_attachment", "params": { out = jmcp.batch_create("скрин", [{"type": "ensure_attachment", "params": {
"key": "A-1", "path": "~/скрин.png", "filename": "Экран 1.png"}}]) "key": "A-1", "path": "~/скрин.png", "filename": "Экран 1.png"}}])
assert _batch_commands(out)[0]["params"] == {"key": "A-1", "file": hashlib.sha256(b"png").hexdigest(), assert _batch_commands(out)[0]["params"] == {"key": "A-1", "file": hashlib.sha256(b"png").hexdigest(),
"filename": "Экран 1.png"} "filename": "Экран 1.png"}
@ -370,9 +372,9 @@ def test_batch_create_bad_path(live, tmp_path, make, expected):
src = tmp_path / "src" src = tmp_path / "src"
src.mkdir() src.mkdir()
p = make(src) p = make(src)
out = mcp_server.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": p}}]) out = jmcp.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": p}}])
assert out == expected.format(p=p) assert out == expected.format(p=p)
assert mcp_server._client().get("/batches")["batches"] == [] assert core_mcp.client().get("/batches")["batches"] == []
assert list((tmp_path / "files").iterdir()) == [] assert list((tmp_path / "files").iterdir()) == []
@ -383,7 +385,7 @@ def test_batch_create_unreadable_file(live, tmp_path):
try: try:
if os.access(f, os.R_OK): if os.access(f, os.R_OK):
pytest.skip("файл читается и без прав (root)") pytest.skip("файл читается и без прав (root)")
out = mcp_server.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(f)}}]) out = jmcp.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(f)}}])
assert out == f"нет прав на чтение: {f}" assert out == f"нет прав на чтение: {f}"
finally: finally:
f.chmod(0o600) f.chmod(0o600)
@ -392,7 +394,7 @@ def test_batch_create_unreadable_file(live, tmp_path):
def test_batch_create_path_and_file(live, tmp_path): def test_batch_create_path_and_file(live, tmp_path):
f = tmp_path / "a.xlsx" f = tmp_path / "a.xlsx"
f.write_bytes(b"a") f.write_bytes(b"a")
out = mcp_server.batch_create("x", [{"type": "ensure_attachment", "params": { out = jmcp.batch_create("x", [{"type": "ensure_attachment", "params": {
"key": "A-1", "path": str(f), "file": "0" * 64}}]) "key": "A-1", "path": str(f), "file": "0" * 64}}])
assert out == "у ensure_attachment либо path, либо file" assert out == "у ensure_attachment либо path, либо file"
assert list((tmp_path / "files").iterdir()) == [] assert list((tmp_path / "files").iterdir()) == []
@ -402,7 +404,7 @@ def test_batch_create_second_file_missing_uploads_nothing(live, tmp_path):
good = tmp_path / "a.xlsx" good = tmp_path / "a.xlsx"
good.write_bytes(b"a") good.write_bytes(b"a")
missing = tmp_path / "нет.xlsx" missing = tmp_path / "нет.xlsx"
out = mcp_server.batch_create("x", [ out = jmcp.batch_create("x", [
{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(good)}}, {"type": "ensure_attachment", "params": {"key": "A-1", "path": str(good)}},
{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(missing)}}]) {"type": "ensure_attachment", "params": {"key": "A-1", "path": str(missing)}}])
assert out == f"файл не найден: {missing}" assert out == f"файл не найден: {missing}"
@ -413,117 +415,117 @@ def test_batch_create_hub_422_after_upload(live, tmp_path):
"""Отказ хаба по другой команде — текст ошибки; загруженный файл остаётся до уборки (design, «MCP»).""" """Отказ хаба по другой команде — текст ошибки; загруженный файл остаётся до уборки (design, «MCP»)."""
f = tmp_path / "a.xlsx" f = tmp_path / "a.xlsx"
f.write_bytes(b"a") f.write_bytes(b"a")
out = mcp_server.batch_create("x", [BUG, {"type": "ensure_attachment", "params": {"key": "new:1", "path": str(f)}}]) out = jmcp.batch_create("x", [BUG, {"type": "ensure_attachment", "params": {"key": "new:1", "path": str(f)}}])
assert out == "ошибка хаба 422 bad_batch: команда 1: нет настройки issue_types.bug" assert out == "ошибка хаба 422 bad_batch: команда 1: нет настройки issue_types.bug"
assert mcp_server._client().get("/batches")["batches"] == [] assert core_mcp.client().get("/batches")["batches"] == []
def test_batch_create_description_mentions_path(): def test_batch_create_description_mentions_path():
desc = {t.name: t for t in asyncio.run(mcp_server.mcp.list_tools())}["batch_create"].description desc = {t.name: t for t in asyncio.run(jmcp.mcp.list_tools())}["batch_create"].description
assert "file (sha256 из POST /files; в MCP — path к локальному файлу)" in desc assert "file (sha256 из POST /files; в MCP — path к локальному файлу)" in desc
assert "issue_type (task|epic|bug)" in desc assert "issue_type (task|epic|bug)" in desc
assert "вместо file передайте path" in desc and "10 МБ" in desc and "filename по умолчанию — имя файла" in desc assert "вместо file передайте path" in desc and "10 МБ" in desc and "filename по умолчанию — имя файла" in desc
def test_unbound_session(live, monkeypatch): # сценарий «Сессия без проекта» def test_unbound_session(live, monkeypatch): # сценарий «Сессия без проекта»
monkeypatch.setattr(mcp_server, "PROJECT", None) monkeypatch.setattr(core_mcp, "PROJECT", None)
calls = [] calls = []
real = HubClient.call real = HubClient.call
monkeypatch.setattr(HubClient, "call", lambda self, m, p, *a, **k: calls.append(p) or real(self, m, p, *a, **k)) monkeypatch.setattr(HubClient, "call", lambda self, m, p, *a, **k: calls.append(p) or real(self, m, p, *a, **k))
for out in (mcp_server.issues(), mcp_server.issue("A-1"), mcp_server.notes_set("A-1", work_kind="x"), for out in (jmcp.issues(), jmcp.issue("A-1"), jmcp.notes_set("A-1", work_kind="x"),
mcp_server.batch_create("x", []), mcp_server.command_retry(1), asyncio.run(mcp_server.refresh(full=True))): jmcp.batch_create("x", []), jmcp.command_retry(1), asyncio.run(jmcp.refresh(full=True))):
assert out == mcp_server.NOT_BOUND assert out == core_mcp.NOT_BOUND
assert calls == [] assert calls == []
for out in (mcp_server.batches(), mcp_server.sync_log(), mcp_server.settings_get(), asyncio.run(mcp_server.batch_wait(1))): for out in (jmcp.batches(), jmcp.sync_log(), jmcp.settings_get(), asyncio.run(jmcp.batch_wait(1))):
assert out != mcp_server.NOT_BOUND # не зависящие от проекта — работают без привязки assert out != core_mcp.NOT_BOUND # не зависящие от проекта — работают без привязки
assert "AWPCA" in mcp_server.projects() assert "AWPCA" in core_mcp.projects()
assert mcp_server.agent_status().startswith("агент ") # служебные инструменты работают без привязки assert jmcp.agent_status().startswith("агент ") # служебные инструменты работают без привязки
def test_unknown_bound_project(live, monkeypatch): # сценарий «Опечатка в проекте» def test_unknown_bound_project(live, monkeypatch): # сценарий «Опечатка в проекте»
monkeypatch.setattr(mcp_server, "PROJECT", "STOKS") monkeypatch.setattr(core_mcp, "PROJECT", "STOKS")
assert mcp_server.issues() == "проект STOKS не заведён в хабе: project_create или исправьте --project в .mcp.json" assert jmcp.issues() == "проект STOKS не заведён в хабе: project_create или исправьте --project в .mcp.json"
assert mcp_server.agent_status().startswith("проект STOKS не заведён") assert jmcp.agent_status().startswith("проект STOKS не заведён")
assert asyncio.run(mcp_server.batch_wait(1)).startswith("проект STOKS не заведён") assert asyncio.run(jmcp.batch_wait(1)).startswith("проект STOKS не заведён")
assert "AWPCA" in mcp_server.projects() assert "AWPCA" in core_mcp.projects()
def test_bound_offline(monkeypatch): # Review Focus 4: хаб выключен — не «проект не заведён» def test_bound_offline(monkeypatch): # Review Focus 4: хаб выключен — не «проект не заведён»
monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1)) monkeypatch.setattr(core_mcp, "client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1))
assert mcp_server.issues() == mcp_server.OFFLINE assert jmcp.issues() == core_mcp.OFFLINE
assert mcp_server.agent_status() == mcp_server.OFFLINE assert jmcp.agent_status() == core_mcp.OFFLINE
assert asyncio.run(mcp_server.refresh(full=True)) == mcp_server.OFFLINE assert asyncio.run(jmcp.refresh(full=True)) == core_mcp.OFFLINE
assert mcp_server.projects() == mcp_server.OFFLINE assert core_mcp.projects() == core_mcp.OFFLINE
def test_bound_issues_and_create(live): # «Задачи своего проекта», «Создание задачи без метки» def test_bound_issues_and_create(live): # «Задачи своего проекта», «Создание задачи без метки»
live["client"].post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"}) live["client"].post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"})
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "своя АРМ", "labels": ["AWPCA"]}, live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "своя АРМ", "labels": ["AWPCA"]},
{"key": "S-1", "summary": "своя запасы", "labels": ["STOCKS_DRRK"]}], "links": []}) {"key": "S-1", "summary": "своя запасы", "labels": ["STOCKS_DRRK"]}], "links": []})
assert "A-1" in mcp_server.issues() and "S-1" not in mcp_server.issues() assert "A-1" in jmcp.issues() and "S-1" not in jmcp.issues()
out = mcp_server.batch_create("новая", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "т"}}]) out = jmcp.batch_create("новая", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "т"}}])
b = live["client"].get(f"/batches/{int(out.split('#')[1].split()[0])}") b = live["client"].get(f"/batches/{int(out.split('#')[1].split()[0])}")
assert b["project"] == "AWPCA" and b["commands"][0]["params"]["labels"] == ["AWPCA"] assert b["project"] == "AWPCA" and b["commands"][0]["params"]["labels"] == ["AWPCA"]
assert mcp_server.notes_set("S-1", work_kind="x") == "S-1 не в проекте AWPCA" assert jmcp.notes_set("S-1", work_kind="x") == "S-1 не в проекте AWPCA"
assert "work_kind: x" in mcp_server.notes_set("new:3", work_kind="x") # временный ключ — можно, без GET /issues assert "work_kind: x" in jmcp.notes_set("new:3", work_kind="x") # временный ключ — можно, без GET /issues
assert "S-1" in mcp_server.issue("S-1") # явный ключ — любая задача assert "S-1" in jmcp.issue("S-1") # явный ключ — любая задача
assert mcp_server.batch_create("чужая", [{"type": "ensure_fields", "params": {"key": "S-1", "duedate": "2026-10-23"}}]).startswith("ошибка хаба 422") assert jmcp.batch_create("чужая", [{"type": "ensure_fields", "params": {"key": "S-1", "duedate": "2026-10-23"}}]).startswith("ошибка хаба 422")
def test_bound_batches_and_retry(live, monkeypatch): def test_bound_batches_and_retry(live, monkeypatch):
live["client"].post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"}) live["client"].post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"})
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], "links": []}) live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], "links": []})
mine = mcp_server.batch_create("моя", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]) mine = jmcp.batch_create("моя", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}])
mine_id = int(mine.split("#")[1].split()[0]) mine_id = int(mine.split("#")[1].split()[0])
monkeypatch.setattr(mcp_server, "PROJECT", "STOCKS_DRRK") monkeypatch.setattr(core_mcp, "PROJECT", "STOCKS_DRRK")
other = mcp_server.batch_create("чужого проекта", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "с"}}]) other = jmcp.batch_create("чужого проекта", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "с"}}])
other_id = int(other.split("#")[1].split()[0]) other_id = int(other.split("#")[1].split()[0])
out = mcp_server.batches(limit=50) out = jmcp.batches(limit=50)
assert f"пакет #{other_id} " in out and f"пакет #{mine_id} " not in out assert f"пакет #{other_id} " in out and f"пакет #{mine_id} " not in out
monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
assert f"пакет #{mine_id} " in mcp_server.batches(limit=50) and f"пакет #{other_id} " not in mcp_server.batches(limit=50) assert f"пакет #{mine_id} " in jmcp.batches(limit=50) and f"пакет #{other_id} " not in jmcp.batches(limit=50)
assert f"#{other_id}" in mcp_server.batch_status(other_id) # по id — без ограничения assert f"#{other_id}" in jmcp.batch_status(other_id) # по id — без ограничения
other_cmd = live["client"].get(f"/batches/{other_id}")["commands"][0]["id"] other_cmd = live["client"].get(f"/batches/{other_id}")["commands"][0]["id"]
out = mcp_server.command_retry(other_cmd) out = jmcp.command_retry(other_cmd)
assert out.startswith("ошибка хаба 422") and "не из проекта AWPCA" in out assert out.startswith("ошибка хаба 422") and "не из проекта AWPCA" in out
monkeypatch.setattr(mcp_server, "PROJECT", None) # без привязки batches — все monkeypatch.setattr(core_mcp, "PROJECT", None) # без привязки batches — все
assert f"пакет #{mine_id} " in mcp_server.batches(limit=50) and f"пакет #{other_id} " in mcp_server.batches(limit=50) assert f"пакет #{mine_id} " in jmcp.batches(limit=50) and f"пакет #{other_id} " in jmcp.batches(limit=50)
def test_project_create(live, monkeypatch): # сценарий «Проект из чата» def test_project_create(live, monkeypatch): # сценарий «Проект из чата»
monkeypatch.setattr(mcp_server, "PROJECT", None) monkeypatch.setattr(core_mcp, "PROJECT", None)
out = mcp_server.project_create("STOCKS_DRRK", "Запасы ДРРК", 5120) out = core_mcp.project_create("STOCKS_DRRK", "Запасы ДРРК", 5120)
assert "STOCKS_DRRK" in out and re.search(r"пакет[^#]*#\d+", out) and "/mcp" in out assert "STOCKS_DRRK" in out and re.search(r"пакет[^#]*#\d+", out) and "/mcp" in out
frag = json.loads(out[out.index("{"):]) frag = json.loads(out[out.index("{"):])
assert frag == mcp_server.mcp_json("STOCKS_DRRK") assert frag == core_mcp.mcp_json("STOCKS_DRRK")
assert frag["mcpServers"]["jira-hub"]["args"][-2:] == ["--project", "STOCKS_DRRK"] assert frag["mcpServers"]["jira-hub"]["args"][-2:] == ["--project", "STOCKS_DRRK"]
assert live["client"].get("/batches")["batches"][0]["author"] == "claude" assert live["client"].get("/batches")["batches"][0]["author"] == "claude"
assert mcp_server.project_create("awpca", "x").startswith("ошибка хаба 409") assert core_mcp.project_create("awpca", "x").startswith("ошибка хаба 409")
assert mcp_server.project_create("BAD LABEL", "x").startswith("ошибка хаба 422") assert core_mcp.project_create("BAD LABEL", "x").startswith("ошибка хаба 422")
def test_hub_root_is_repo(): def test_hub_root_is_repo():
assert (mcp_server.HUB_ROOT / "pyproject.toml").is_file() assert (core_mcp.HUB_ROOT / "pyproject.toml").is_file()
assert mcp_server.mcp_json("X")["mcpServers"]["jira-hub"]["args"][:3] == ["run", "--project", str(mcp_server.HUB_ROOT)] assert core_mcp.mcp_json("X")["mcpServers"]["jira-hub"]["args"][:3] == ["run", "--project", str(core_mcp.HUB_ROOT)]
def test_fmt_projects(): def test_fmt_projects():
items = [{"label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": "scrum", "issues_count": 190}, items = [{"label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": "scrum", "issues_count": 190},
{"label": "STOCKS_DRRK", "name": "Запасы ДРРК", "board_id": None, "board_type": None, "issues_count": 0}] {"label": "STOCKS_DRRK", "name": "Запасы ДРРК", "board_id": None, "board_type": None, "issues_count": 0}]
out = mcp_server.fmt_projects(items, "AWPCA").splitlines() out = core_mcp.fmt_projects(items, "AWPCA").splitlines()
assert out[0] == "метка | название | доска | тип доски | задач" assert out[0] == "метка | название | доска | тип доски | задач"
assert out[1] == "AWPCA | АРМ КА | 4216 | scrum | 190 (текущий)" assert out[1] == "AWPCA | АРМ КА | 4216 | scrum | 190 (текущий)"
assert out[2] == "STOCKS_DRRK | Запасы ДРРК | — | неизвестен | 0" assert out[2] == "STOCKS_DRRK | Запасы ДРРК | — | неизвестен | 0"
assert mcp_server.fmt_projects([], None) == "проектов нет" assert core_mcp.fmt_projects([], None) == "проектов нет"
assert "(текущий)" not in mcp_server.fmt_projects(items, None) assert "(текущий)" not in core_mcp.fmt_projects(items, None)
def test_run_binds_project(monkeypatch): def test_run_binds_project(monkeypatch):
monkeypatch.setattr(mcp_server, "PROJECT", None) monkeypatch.setattr(core_mcp, "PROJECT", None)
monkeypatch.setattr(mcp_server.mcp, "run", lambda transport: None) monkeypatch.setattr(jmcp.mcp, "run", lambda transport: None)
mcp_server.run("AWPCA") assert core_mcp.run("jira", "AWPCA") == 0
assert mcp_server.PROJECT == "AWPCA" assert core_mcp.PROJECT == "AWPCA"
# --- лента изменений (#70, Task 5) ------------------------------------------------------------------------------ # --- лента изменений (#70, Task 5) ------------------------------------------------------------------------------
@ -545,29 +547,29 @@ def _ev(**kw):
def test_fmt_event_status(): def test_fmt_event_status():
e = _ev() e = _ev()
hm = _local(e["at"]).strftime("%H:%M") hm = _local(e["at"]).strftime("%H:%M")
assert mcp_server.fmt_event(e, _local(e["at"]).date()) == f"{hm} Петров Пётр: статус «В работе» → «Готово»" assert jmcp.fmt_event(e, _local(e["at"]).date()) == f"{hm} Петров Пётр: статус «В работе» → «Готово»"
def test_fmt_event_other_day_has_date(): def test_fmt_event_other_day_has_date():
e = _ev() e = _ev()
d = _local(e["at"]) d = _local(e["at"])
assert mcp_server.fmt_event(e, d.date() + timedelta(days=1)).startswith(d.strftime("%d.%m %H:%M ")) assert jmcp.fmt_event(e, d.date() + timedelta(days=1)).startswith(d.strftime("%d.%m %H:%M "))
assert mcp_server.fmt_event(e, None).startswith(d.strftime("%d.%m %H:%M ")) assert jmcp.fmt_event(e, None).startswith(d.strftime("%d.%m %H:%M "))
def test_fmt_event_dates_by_value(): def test_fmt_event_dates_by_value():
e = _ev(kind="dates", field="duedate", **{"from": "25/окт/26", "to": None}, from_value="2026-10-25", to_value=None) e = _ev(kind="dates", field="duedate", **{"from": "25/окт/26", "to": None}, from_value="2026-10-25", to_value=None)
assert mcp_server.fmt_event(e, None).endswith(": срок 25.10 → —") assert jmcp.fmt_event(e, None).endswith(": срок 25.10 → —")
e = _ev(kind="dates", field="planned_end", **{"from": "сырое", "to": "30/окт/26"}, from_value=None, to_value="2026-10-30 00:00:00.0") e = _ev(kind="dates", field="planned_end", **{"from": "сырое", "to": "30/окт/26"}, from_value=None, to_value="2026-10-30 00:00:00.0")
assert mcp_server.fmt_event(e, None).endswith(": плановое окончание сырое → 30.10") assert jmcp.fmt_event(e, None).endswith(": плановое окончание сырое → 30.10")
def test_fmt_event_kinds(): def test_fmt_event_kinds():
assert mcp_server.fmt_event(_ev(kind="description", field="description"), None).endswith(": описание изменено") assert jmcp.fmt_event(_ev(kind="description", field="description"), None).endswith(": описание изменено")
long = "к" * 250 long = "к" * 250
out = mcp_server.fmt_event(_ev(kind="comment", field="comment", **{"from": None, "to": long}), None) out = jmcp.fmt_event(_ev(kind="comment", field="comment", **{"from": None, "to": long}), None)
assert out.endswith(": комментарий: " + "к" * 199 + "…") assert out.endswith(": комментарий: " + "к" * 199 + "…")
out = mcp_server.fmt_event(_ev(kind="other", field="assignee", **{"from": None, "to": "ivanov"}), None) out = jmcp.fmt_event(_ev(kind="other", field="assignee", **{"from": None, "to": "ivanov"}), None)
assert out.endswith(": assignee: «—» → «ivanov»") assert out.endswith(": assignee: «—» → «ivanov»")
@ -578,7 +580,7 @@ def test_fmt_changes_grouped():
_ev(at="2026-10-22T08:15:00+00:00"), _ev(at="2026-10-22T08:15:00+00:00"),
_ev(at="2026-10-22T08:00:00+00:00", key="Y", summary="Вторая", status="В работе", kind="description", _ev(at="2026-10-22T08:00:00+00:00", key="Y", summary="Вторая", status="В работе", kind="description",
field="description")]} field="description")]}
lines = mcp_server.fmt_changes(data, now).split("\n") lines = jmcp.fmt_changes(data, now).split("\n")
assert lines[0].startswith("изменения с ") assert lines[0].startswith("изменения с ")
assert lines[1] == "Y Вторая — В работе" and lines[2].startswith(" ") and lines[3].endswith("описание изменено") assert lines[1] == "Y Вторая — В работе" and lines[2].startswith(" ") and lines[3].endswith("описание изменено")
assert lines[4] == "X Тема — Готово" assert lines[4] == "X Тема — Готово"
@ -586,7 +588,7 @@ def test_fmt_changes_grouped():
def test_fmt_changes_empty(): def test_fmt_changes_empty():
out = mcp_server.fmt_changes({"since": "2026-10-22T10:00:00+00:00", "until": "2026-10-22T11:00:00+00:00", out = jmcp.fmt_changes({"since": "2026-10-22T10:00:00+00:00", "until": "2026-10-22T11:00:00+00:00",
"truncated": False, "changes": []}, datetime.now().astimezone()) "truncated": False, "changes": []}, datetime.now().astimezone())
assert out.split("\n")[1] == "за период изменений нет" assert out.split("\n")[1] == "за период изменений нет"
@ -604,51 +606,51 @@ def test_changes_today(live): # «Ч
now = datetime.now(timezone.utc).replace(microsecond=0) now = datetime.now(timezone.utc).replace(microsecond=0)
_history(live, "A-1", 1, created=(now - timedelta(minutes=30)).isoformat()) _history(live, "A-1", 1, created=(now - timedelta(minutes=30)).isoformat())
_history(live, "B-1", 1, created=(now - timedelta(minutes=30)).isoformat()) # не в проекте _history(live, "B-1", 1, created=(now - timedelta(minutes=30)).isoformat()) # не в проекте
out = mcp_server.changes(kinds="status") out = jmcp.changes(kinds="status")
assert "A-1 Тема — Готово" in out and "статус «a» → «s0»" in out and "B-1" not in out assert "A-1 Тема — Готово" in out and "статус «a» → «s0»" in out and "B-1" not in out
assert "A-1" in mcp_server.changes(hours=2) assert "A-1" in jmcp.changes(hours=2)
assert "B-1" in mcp_server.changes(key="B-1") assert "B-1" in jmcp.changes(key="B-1")
def test_changes_hours_and_since(live): def test_changes_hours_and_since(live):
assert mcp_server.changes(hours=2, since="2026-10-22T10:00:00Z") == "либо hours, либо since" assert jmcp.changes(hours=2, since="2026-10-22T10:00:00Z") == "либо hours, либо since"
assert mcp_server.changes(hours=0) == "hours — число больше 0" assert jmcp.changes(hours=0) == "hours — число больше 0"
def test_changes_bad_since(live): def test_changes_bad_since(live):
assert mcp_server.changes(since="вчера").startswith("ошибка хаба 422") assert jmcp.changes(since="вчера").startswith("ошибка хаба 422")
def test_changes_naive_since_is_local(live): # M-2: время без смещения — местное def test_changes_naive_since_is_local(live): # M-2: время без смещения — местное
out = mcp_server.changes(since="2026-10-22T09:00", until="2026-10-22T10:00") out = jmcp.changes(since="2026-10-22T09:00", until="2026-10-22T10:00")
assert out.split("\n")[0] == "изменения с 22.10 09:00 по 22.10 10:00" assert out.split("\n")[0] == "изменения с 22.10 09:00 по 22.10 10:00"
def test_changes_empty(live): def test_changes_empty(live):
assert mcp_server.changes(hours=1).split("\n")[1] == "за период изменений нет" assert jmcp.changes(hours=1).split("\n")[1] == "за период изменений нет"
def test_changes_unbound(live, monkeypatch): def test_changes_unbound(live, monkeypatch):
monkeypatch.setattr(mcp_server, "PROJECT", None) monkeypatch.setattr(core_mcp, "PROJECT", None)
assert mcp_server.changes() == mcp_server.NOT_BOUND assert jmcp.changes() == core_mcp.NOT_BOUND
assert mcp_server.changes(key="A-1").split("\n")[1] == "за период изменений нет" assert jmcp.changes(key="A-1").split("\n")[1] == "за период изменений нет"
def test_issue_shows_changes(live): # «История в карточке» def test_issue_shows_changes(live): # «История в карточке»
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема", "labels": ["AWPCA"]}], "links": []}) live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема", "labels": ["AWPCA"]}], "links": []})
assert "изменения (" not in mcp_server.issue("A-1") assert "изменения (" not in jmcp.issue("A-1")
_history(live, "A-1", 7) _history(live, "A-1", 7)
out = mcp_server.issue("A-1") out = jmcp.issue("A-1")
assert "изменения (7):" in out and " ещё 2" in out and out.count("статус «a»") == 5 assert "изменения (7):" in out and " ещё 2" in out and out.count("статус «a»") == 5
def test_sync_log_history(live): def test_sync_log_history(live):
asyncio.run(mcp_server.refresh(full=True)) asyncio.run(jmcp.refresh(full=True))
cmd = live["agent"].post("/agent/claim", {})["command"] cmd = live["agent"].post("/agent/claim", {})["command"]
assert "журнал: нет" in mcp_server.sync_log() assert "журнал: нет" in jmcp.sync_log()
live["agent"].post(f"/agent/commands/{cmd['id']}/result", live["agent"].post(f"/agent/commands/{cmd['id']}/result",
{"status": "done", "result": {"history": {"issues": 0, "changes": 0, "incomplete": []}}}) {"status": "done", "result": {"history": {"issues": 0, "changes": 0, "incomplete": []}}})
assert re.search(r"журнал: принят \d\d\.\d\d \d\d:\d\d", mcp_server.sync_log()) assert re.search(r"журнал: принят \d\d\.\d\d \d\d:\d\d", jmcp.sync_log())
# --- вложения (#83) ----------------------------------------------------------------- # --- вложения (#83) -----------------------------------------------------------------
@ -694,14 +696,14 @@ class FakeHub:
@pytest.fixture @pytest.fixture
def downloads(tmp_path, monkeypatch): def downloads(tmp_path, monkeypatch):
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05) monkeypatch.setattr(jmcp, "POLL_SECONDS", 0.05)
return tmp_path / "home" / "downloads" return tmp_path / "home" / "downloads"
def _use(monkeypatch, hub): def _use(monkeypatch, hub):
monkeypatch.setattr(mcp_server, "_client", lambda: hub) monkeypatch.setattr(core_mcp, "client", lambda: hub)
monkeypatch.setattr(mcp_server, "_unbound_or_unknown", lambda required: None) monkeypatch.setattr(core_mcp, "unbound_or_unknown", lambda required: None)
def _issue_with_att(): def _issue_with_att():
@ -713,32 +715,32 @@ def _issue_with_att():
def test_issue_attachments_block(): def test_issue_attachments_block():
text = mcp_server.fmt_issue(_issue_with_att()) text = jmcp.fmt_issue(_issue_with_att())
assert "вложения (2):" in text and text.index("комментарии") < text.index("вложения (2):") assert "вложения (2):" in text and text.index("комментарии") < text.index("вложения (2):")
lines = {l.split()[0]: l for l in text.splitlines() if l.strip().startswith("#3133")} lines = {l.split()[0]: l for l in text.splitlines() if l.strip().startswith("#3133")}
assert "больше 10 МБ — только в Jira" in lines["#31338"] and "25,0 МБ" in lines["#31338"] assert "больше 10 МБ — только в Jira" in lines["#31338"] and "25,0 МБ" in lines["#31338"]
assert "(234,2 КБ)" in lines["#31337"] and "в описании, в #101, скачано" in lines["#31337"] assert "(234,2 КБ)" in lines["#31337"] and "в описании, в #101, скачано" in lines["#31337"]
assert "файлы: #31337 скрин 1.png" in text assert "файлы: #31337 скрин 1.png" in text
assert "вложений нет" in mcp_server.fmt_issue(_detail(attachments=[])) assert "вложений нет" in jmcp.fmt_issue(_detail(attachments=[]))
assert "вложений нет" in mcp_server.fmt_issue(_detail()) assert "вложений нет" in jmcp.fmt_issue(_detail())
def test_comment_attachments(): def test_comment_attachments():
d = _issue_with_att() d = _issue_with_att()
text = mcp_server.fmt_comment(ATT_KEY, d["comments"][0], d["attachments"]) text = jmcp.fmt_comment(ATT_KEY, d["comments"][0], d["attachments"])
assert text.endswith("вложения:\n #31337 скрин 1.png (234,2 КБ)") assert text.endswith("вложения:\n #31337 скрин 1.png (234,2 КБ)")
assert "вложения" not in mcp_server.fmt_comment(ATT_KEY, {**d["comments"][0], "attachment_ids": []}, d["attachments"]) assert "вложения" not in jmcp.fmt_comment(ATT_KEY, {**d["comments"][0], "attachment_ids": []}, d["attachments"])
def test_safe_name_limits_255_bytes(): def test_safe_name_limits_255_bytes():
"""M-7: имя ≤ 255 байт UTF-8, расширение сохраняется, многобайтовый символ не режется.""" """M-7: имя ≤ 255 байт UTF-8, расширение сохраняется, многобайтовый символ не режется."""
for stem in ("я" * 200, "ы" * 127 + "a"): for stem in ("я" * 200, "ы" * 127 + "a"):
name = mcp_server.safe_name(stem + ".xlsx", "7") name = jmcp.safe_name(stem + ".xlsx", "7")
assert len(name.encode()) <= 255 and name.endswith(".xlsx") assert len(name.encode()) <= 255 and name.endswith(".xlsx")
name.encode("utf-8") name.encode("utf-8")
assert name.startswith("я") or name.startswith("ы") assert name.startswith("я") or name.startswith("ы")
assert len(mcp_server.safe_name("ф" * 300, "7").encode()) <= 255 assert len(jmcp.safe_name("ф" * 300, "7").encode()) <= 255
assert mcp_server.safe_name("короткое.png", "7") == "короткое.png" assert jmcp.safe_name("короткое.png", "7") == "короткое.png"
def test_attachment_long_cyrillic_name_written(downloads, monkeypatch): def test_attachment_long_cyrillic_name_written(downloads, monkeypatch):
@ -746,16 +748,16 @@ def test_attachment_long_cyrillic_name_written(downloads, monkeypatch):
att = _att("31337", name, 3, state="ready") att = _att("31337", name, 3, state="ready")
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch={"state": "ready", "command_id": 5}, polls=[att], data=b"abc") hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch={"state": "ready", "command_id": 5}, polls=[att], data=b"abc")
_use(monkeypatch, hub) _use(monkeypatch, hub)
out = asyncio.run(mcp_server.attachment(ATT_KEY, "31337")) out = asyncio.run(jmcp.attachment(ATT_KEY, "31337"))
path = Path(out.splitlines()[0]) path = Path(out.splitlines()[0])
assert path.is_file() and path.read_bytes() == b"abc" and len(path.name.encode()) <= 255 assert path.is_file() and path.read_bytes() == b"abc" and len(path.name.encode()) <= 255
def test_safe_name(): def test_safe_name():
assert mcp_server.safe_name("a/b\\c:d\x01.png", "7") == "a_b_c_d_.png" assert jmcp.safe_name("a/b\\c:d\x01.png", "7") == "a_b_c_d_.png"
assert mcp_server.safe_name(" ", "7") == "attachment-7" assert jmcp.safe_name(" ", "7") == "attachment-7"
assert mcp_server.safe_name("..", "7") == "attachment-7" assert jmcp.safe_name("..", "7") == "attachment-7"
assert mcp_server.safe_name("a\x7f", "7") == "a_" assert jmcp.safe_name("a\x7f", "7") == "a_"
def _ready_hub(size=239821, data=b"PNG" * 3): def _ready_hub(size=239821, data=b"PNG" * 3):
@ -767,7 +769,7 @@ def _ready_hub(size=239821, data=b"PNG" * 3):
def test_attachment_by_id_ready(downloads, monkeypatch): def test_attachment_by_id_ready(downloads, monkeypatch):
hub = _ready_hub() hub = _ready_hub()
_use(monkeypatch, hub) _use(monkeypatch, hub)
out = asyncio.run(mcp_server.attachment(ATT_KEY, "#31337")) out = asyncio.run(jmcp.attachment(ATT_KEY, "#31337"))
path = downloads / "31337" / "скрин 1.png" path = downloads / "31337" / "скрин 1.png"
lines = out.splitlines() lines = out.splitlines()
assert lines[0] == str(path) and lines[1] == "скрин 1.png (234,2 КБ), image/png, задача AURORA-1650" assert lines[0] == str(path) and lines[1] == "скрин 1.png (234,2 КБ), image/png, задача AURORA-1650"
@ -781,7 +783,7 @@ def test_attachment_by_name_ambiguous(downloads, monkeypatch):
hub = FakeHub(_detail(key=ATT_KEY, attachments=[a20, a10]), fetch={"state": "ready", "command_id": 1}, hub = FakeHub(_detail(key=ATT_KEY, attachments=[a20, a10]), fetch={"state": "ready", "command_id": 1},
polls=[{**a20, "state": "ready"}], data=b"abc") polls=[{**a20, "state": "ready"}], data=b"abc")
_use(monkeypatch, hub) _use(monkeypatch, hub)
out = asyncio.run(mcp_server.attachment(ATT_KEY, "скрин.png")) out = asyncio.run(jmcp.attachment(ATT_KEY, "скрин.png"))
assert "у AURORA-1650 2 вложений «скрин.png», взято самое новое #20" in out assert "у AURORA-1650 2 вложений «скрин.png», взято самое новое #20" in out
assert str(downloads / "20" / "скрин.png") in out assert str(downloads / "20" / "скрин.png") in out
@ -789,7 +791,7 @@ def test_attachment_by_name_ambiguous(downloads, monkeypatch):
def test_attachment_not_found(downloads, monkeypatch): def test_attachment_not_found(downloads, monkeypatch):
hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("1", "a.png", 1)])) hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("1", "a.png", 1)]))
_use(monkeypatch, hub) _use(monkeypatch, hub)
out = asyncio.run(mcp_server.attachment(ATT_KEY, "нет.png")) out = asyncio.run(jmcp.attachment(ATT_KEY, "нет.png"))
assert out == "у AURORA-1650 нет вложения «нет.png»; список — issue AURORA-1650" assert out == "у AURORA-1650 нет вложения «нет.png»; список — issue AURORA-1650"
assert all(c[1] == f"/issues/{ATT_KEY}" for c in hub.calls) assert all(c[1] == f"/issues/{ATT_KEY}" for c in hub.calls)
@ -799,7 +801,7 @@ def test_attachment_wait_timeout(downloads, monkeypatch):
fetching = {**att, "state": "fetching", "command_id": 9} fetching = {**att, "state": "fetching", "command_id": 9}
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=fetching, polls=[fetching]) hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=fetching, polls=[fetching])
_use(monkeypatch, hub) _use(monkeypatch, hub)
out = asyncio.run(mcp_server.attachment(ATT_KEY, "1", wait_seconds=1)) out = asyncio.run(jmcp.attachment(ATT_KEY, "1", wait_seconds=1))
assert out == "скачивание ещё идёт (команда #9, агент онлайн); вызовите attachment ещё раз — вторая команда не поставится" assert out == "скачивание ещё идёт (команда #9, агент онлайн); вызовите attachment ещё раз — вторая команда не поставится"
@ -808,7 +810,7 @@ def test_attachment_failed(downloads, monkeypatch):
failed = {**att, "state": "failed", "error": "HTTP 404", "command_id": 3} failed = {**att, "state": "failed", "error": "HTTP 404", "command_id": 3}
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch={"state": "fetching", "command_id": 3}, polls=[failed]) hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch={"state": "fetching", "command_id": 3}, polls=[failed])
_use(monkeypatch, hub) _use(monkeypatch, hub)
assert asyncio.run(mcp_server.attachment(ATT_KEY, "1")) == "не удалось скачать: HTTP 404" assert asyncio.run(jmcp.attachment(ATT_KEY, "1")) == "не удалось скачать: HTTP 404"
def test_attachment_413(downloads, monkeypatch): def test_attachment_413(downloads, monkeypatch):
@ -816,7 +818,7 @@ def test_attachment_413(downloads, monkeypatch):
att = _att("5", "запись.mp4", 26214400) att = _att("5", "запись.mp4", 26214400)
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=HubError(413, "too_large", "большой")) hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=HubError(413, "too_large", "большой"))
_use(monkeypatch, hub) _use(monkeypatch, hub)
assert asyncio.run(mcp_server.attachment(ATT_KEY, "#5")) == \ assert asyncio.run(jmcp.attachment(ATT_KEY, "#5")) == \
"файл больше 10 МБ (25,0 МБ) — скачайте в Jira: задача AURORA-1650, вложение «запись.mp4»" "файл больше 10 МБ (25,0 МБ) — скачайте в Jira: задача AURORA-1650, вложение «запись.mp4»"
@ -825,7 +827,7 @@ def test_attachment_409_passes_hub_text(downloads, monkeypatch):
hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("5", "a.png", 1)]), hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("5", "a.png", 1)]),
fetch=HubError(409, "agent_too_old", "агент v9 не умеет скачивать вложения — нужен v14")) fetch=HubError(409, "agent_too_old", "агент v9 не умеет скачивать вложения — нужен v14"))
_use(monkeypatch, hub) _use(monkeypatch, hub)
assert "агент v9 не умеет" in asyncio.run(mcp_server.attachment(ATT_KEY, "5")) assert "агент v9 не умеет" in asyncio.run(jmcp.attachment(ATT_KEY, "5"))
def test_attachment_existing_copy_same_size(downloads, monkeypatch): def test_attachment_existing_copy_same_size(downloads, monkeypatch):
@ -836,6 +838,6 @@ def test_attachment_existing_copy_same_size(downloads, monkeypatch):
path.write_bytes(b"OLD" * 3) path.write_bytes(b"OLD" * 3)
os.utime(path, (1_000_000, 1_000_000)) os.utime(path, (1_000_000, 1_000_000))
os.utime(path.parent, (1_000_000, 1_000_000)) os.utime(path.parent, (1_000_000, 1_000_000))
asyncio.run(mcp_server.attachment(ATT_KEY, "31337")) asyncio.run(jmcp.attachment(ATT_KEY, "31337"))
assert path.read_bytes() == b"OLD" * 3 assert path.read_bytes() == b"OLD" * 3
assert path.parent.stat().st_mtime > 1_000_000 + 100 # M-6: папка «освежена» — уборка её не заденет assert path.parent.stat().st_mtime > 1_000_000 + 100 # M-6: папка «освежена» — уборка её не заденет

View File

@ -9,8 +9,8 @@ from jira_hub.core.modules import CommandHooks, Module
CORE = Path(__file__).resolve().parent.parent / "src" / "jira_hub" / "core" CORE = Path(__file__).resolve().parent.parent / "src" / "jira_hub" / "core"
# временно: R-1, сокращается в Task 6 # временно: R-1, список только сокращается; после Task 6 пуст
PENDING = {"cli.py", "mcp_server.py"} PENDING: set[str] = set()
def _noop(*a, **k): def _noop(*a, **k):