Модульная перестройка хаба: ядро + модуль 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,
agent publish (версия агента для самообновления)."""
"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), mcp [модуль] (stdio), tokens,
agent publish (версия агента для самообновления) и подкоманды модулей (jira: import)."""
import argparse
import json
import sys
from . import db, paths, registry, settings
from ..modules.jira import importer
def main(argv=None) -> int:
@ -17,10 +16,12 @@ def main(argv=None) -> int:
p = sub.add_parser("serve", help="запустить HTTP API")
p.add_argument("--host", default="0.0.0.0")
p.add_argument("--port", type=int)
p = sub.add_parser("import", help="импорт CSV-пакета admin/jira-sync в заметки")
p.add_argument("folder")
p = sub.add_parser("mcp", help="MCP-сервер по stdio")
p = sub.add_parser("mcp", help="MCP-сервер модуля по stdio (по умолчанию jira)")
p.add_argument("module", nargs="?", default="jira", help="имя модуля; неизвестное — код 2 (проверка в mcp.run)")
p.add_argument("--project", help="метка проекта, к которому привязана сессия")
for m in registry.modules():
if m.cli_register:
m.cli_register(sub)
sub.add_parser("tokens", help="показать токены агента и клиента")
p = sub.add_parser("agent", help="версия агента для самообновления")
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)
return 0
if args.cmd == "mcp":
from . import mcp_server
mcp_server.run(args.project)
return 0
from . import mcp
return mcp.run(args.module, args.project)
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())
settings.ensure_defaults(conn)
@ -69,9 +74,6 @@ def main(argv=None) -> int:
print(line)
_print_tokens(conn)
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":
_print_tokens(conn)
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, очередь команд агенту, вложения, журнал изменений."""
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(
name="jira",
@ -15,4 +22,7 @@ MODULE = Module(
routes=api.ROUTES,
tick=(refresh.auto_refresh_tick, api.downloads_tick),
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 — «claude». Если хаб выключен, инструмент возвращает подсказку, а не исключение."""
"""MCP-инструменты модуля jira (stdio, FastMCP) — инструменты сессии Claude поверх HTTP API (specs/mcp/spec.md).
Общие охрана, привязка к проекту и инструменты projects/project_create — в core/mcp.py; состояние (клиент хаба,
привязка сессии) читается через core в момент вызова."""
import asyncio
import base64
import functools
import inspect
import json
import os
import stat
@ -14,125 +13,32 @@ from pathlib import Path
from mcp.server.fastmcp import FastMCP
from . import paths
from ..modules.jira.attachments import fmt_size
from .client import HubClient, HubError, HubUnavailable
from ..modules.jira.commands import describe, is_temp
from .files import MAX_SIZE
from ...core import mcp as core
from ...core import paths
from ...core.client import HubError
from ...core.files import MAX_SIZE
from .attachments import fmt_size
from .commands import describe, is_temp
OFFLINE = "хаб не запущен: jira-hub serve"
POLL_SECONDS = 5
AUTHOR = "claude"
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")
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:
if not issues:
return "задач нет"
lines = ["ключ | тема | статус | срок | исполнитель | спринт | статус по факту"]
for i in issues:
lines.append(" | ".join([i["key"], _cut(i.get("summary")), _v(i.get("status")), _v(i.get("duedate")),
_v(i.get("assignee")), _v(i.get("sprint")), _v(i.get("fact_status"))]))
lines.append(" | ".join([i["key"], core.cut(i.get("summary")), core.value(i.get("status")), core.value(i.get("duedate")),
core.value(i.get("assignee")), core.value(i.get("sprint")), core.value(i.get("fact_status"))]))
return "\n".join(lines)
def fmt_attachment_line(a: dict, d: dict) -> str:
"""Строка вложения в issue: «#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 = []
if aid in (d.get("description_attachment_ids") or []):
marks.append("в описании")
@ -173,10 +79,10 @@ def safe_name(filename: str, attachment_id: str) -> str:
def fmt_issue(d) -> str:
head = [f"{d['key']} — {d.get('summary')}",
f"тип: {_v(d.get('issue_type'))}; статус: {_v(d.get('status'))}; приоритет: {_v(d.get('priority'))}; "
f"исполнитель: {_v(d.get('assignee'))}; срок: {_v(d.get('duedate'))}; план: {_v(d.get('planned_start'))}…{_v(d.get('planned_end'))}",
f"эпик: {_v(d.get('epic_key'))}; спринт: {_v(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"тип: {core.value(d.get('issue_type'))}; статус: {core.value(d.get('status'))}; приоритет: {core.value(d.get('priority'))}; "
f"исполнитель: {core.value(d.get('assignee'))}; срок: {core.value(d.get('duedate'))}; план: {core.value(d.get('planned_start'))}…{core.value(d.get('planned_end'))}",
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: {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", [])] + \
[f" ← {l['source_key']} ({l['link_type']})" for l in d.get("links_in", [])]
note = d.get("note") or {}
@ -186,7 +92,7 @@ def fmt_issue(d) -> str:
if comments:
shown = []
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 [])
if 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():
return f"{value[8:10]}.{value[5:7]}"
return _v(text)
return core.value(text)
def fmt_comment(key, c, atts=None) -> str:
"""Один комментарий целиком (#80): шапка, затем текст с переносами строк; «изменён» — только если правили.
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"):
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 []}
refs = [by_id[i] for i in (str(x) for x in c.get("attachment_ids") or []) if i in by_id]
if refs:
@ -237,17 +143,17 @@ def fmt_event(e: dict, today) -> str:
when = "—"
kind, frm, to = e.get("kind"), e.get("from"), e.get("to")
if kind == "comment":
what = "комментарий: " + _cut(to, 200)
what = "комментарий: " + core.cut(to, 200)
elif kind == "status":
what = f"статус «{_v(frm)}» → «{_v(to)}»"
what = f"статус «{core.value(frm)}» → «{core.value(to)}»"
elif kind == "description":
what = "описание изменено"
elif kind == "dates":
what = (f"{DATE_LABELS.get(e.get('field'), e.get('field'))} {_day(e.get('from_value'), frm)} → "
f"{_day(e.get('to_value'), to)}")
else:
what = f"{e.get('field')}: «{_cut(frm) if frm else '—'}» → «{_cut(to) if to else '—'}»"
return f"{when} {_v(e.get('author'))}: {what}"
what = f"{e.get('field')}: «{core.cut(frm) if frm else '—'}» → «{core.cut(to) if to else '—'}»"
return f"{when} {core.value(e.get('author'))}: {what}"
def fmt_changes(data: dict, now: datetime) -> str:
@ -262,7 +168,7 @@ def fmt_changes(data: dict, now: datetime) -> str:
for e in events:
groups.setdefault(e["key"], []).append(e)
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]
if data.get("truncated"):
lines.append(f"показаны последние {len(events)} событий, сузьте период")
@ -285,17 +191,17 @@ def fmt_batch(b) -> str:
def fmt_agent(a) -> str:
state = "онлайн" if a.get("online") else "офлайн"
cur = f"выполняет #{a['current_command_id']}" if a.get("current_command_id") else "очередь свободна"
out = [f"агент {state} (версия {_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)}"]
if a.get("warning"):
out.append("внимание: " + a["warning"])
for s in a.get("redelivered", []):
out.append(f"выдана повторно: #{s['id']} {s['type']}, выдач {s['deliveries']}, последняя {s['claimed_at']}")
rel = a.get("release")
out.append(f"публикация: v{rel['version']} от {_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")
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")
if a.get("agent_reported") is False: # хаб перезапущен, claim ещё не было (решение 8); старый хаб поля не шлёт
out.append("запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)")
@ -309,34 +215,34 @@ def fmt_agent(a) -> str:
# --- инструменты -----------------------------------------------------------------
@mcp.tool()
@guard
@scoped(True)
@core.guard
@core.scoped(True)
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:
"""Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус по факту, статус Jira, метка, эпик,
подстрока темы/ключа, 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)
return fmt_issues(data["issues"])
@mcp.tool()
@guard
@scoped(True)
@core.guard
@core.scoped(True)
def issue(key: str) -> str:
"""Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание. Комментарии — 5
последних с номером, текст до 300 символов; полный текст — инструмент comment по номеру. Вложения — списком
(номер, имя, размер, где упомянуты, скачано ли); содержимое — инструмент attachment."""
return fmt_issue(_client().get(f"/issues/{key}"))
return fmt_issue(core.client().get(f"/issues/{key}"))
@mcp.tool()
@guard
@scoped(True)
@core.guard
@core.scoped(True)
def comment(key: str, comment_id: str) -> str:
"""Один комментарий задачи целиком: автор, время, полный текст. comment_id — номер из ответа issue («#123»)."""
cid = comment_id.lstrip("#")
d = _client().get(f"/issues/{key}")
d = core.client().get(f"/issues/{key}")
for c in d.get("comments") or []:
if c.get("comment_id") == cid:
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()
@guard
@scoped(True)
@core.guard
@core.scoped(True)
async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str:
"""Скачать вложение задачи и вернуть путь к локальной копии (читайте файл по этому пути). attachment — номер из
issue/comment («#31337» или «31337») либо точное имя файла; при нескольких одноимённых берётся самое новое.
Хаб просит агента скачать файл и сохраняет копию в downloads/ каталога данных; ожидание — до wait_seconds (1…600,
по умолчанию 120), не дождался — вызовите инструмент ещё раз: вторая команда не поставится. Файлы больше 10 МБ
скачиваются только в Jira."""
client = _client()
client = core.client()
wait = min(max(int(wait_seconds), 1), 600)
d = await asyncio.to_thread(client.get, f"/issues/{key}")
atts = d.get("attachments") or []
@ -391,7 +297,7 @@ async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str:
att = found[0]
aid = str(att["attachment_id"])
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:
if e.status == 413:
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 'офлайн'}); "
"вызовите attachment ещё раз — вторая команда не поставится")
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")
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:
@ -423,7 +329,7 @@ def _local_if_naive(value: str) -> str:
@mcp.tool()
@guard
@core.guard
def changes(hours: float | None = None, since: str = "", until: str = "", kinds: str = "", key: str = "") -> str:
"""Лента изменений задач: смена статуса, правка описания, сроки, новые комментарии. По умолчанию — с начала
сегодняшнего дня; 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"
if hours is not None and 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:
return refusal
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()
elif not since:
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,
project=None if key else PROJECT)
data = core.client().get("/changes", since=_local_if_naive(since), until=_local_if_naive(until), kinds=kinds, key=key,
project=None if key else core.PROJECT)
return fmt_changes(data, now)
@mcp.tool()
@guard
@scoped(True)
@core.guard
@core.scoped(True)
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:
"""Правка служебных заметок задачи (только переданные поля)."""
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}
fields["updated_by"] = AUTHOR
fields["updated_by"] = core.AUTHOR
if not is_temp(key): # новая задача пакета проекта — своя по построению
if PROJECT not in (_client().get(f"/issues/{key}").get("labels") or []):
return f"{key} не в проекте {PROJECT}"
note = _client().put(f"/issues/{key}/note", fields)
if core.PROJECT not in (core.client().get(f"/issues/{key}").get("labels") or []):
return f"{key} не в проекте {core.PROJECT}"
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))
@ -527,8 +433,8 @@ def _doc(text: str):
@mcp.tool()
@guard
@scoped(True)
@core.guard
@core.scoped(True)
@_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам.
Параметры по типам (необязательные — в [ ]):
""" + describe() + """
@ -536,29 +442,29 @@ def _doc(text: str):
ensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит
файл в хаб и подставит file, filename по умолчанию — имя файла.""")
def batch_create(title: str, commands: list[dict]) -> str:
client = _client()
client = core.client()
prepared = _attach_files(client, commands)
if isinstance(prepared, str):
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)} мин"
@mcp.tool()
@guard
@scoped(False)
@core.guard
@core.scoped(False)
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()
@guard
@scoped(False)
@core.guard
@core.scoped(False)
async def batch_wait(id: int, timeout_minutes: int = 10) -> str:
"""Ждать завершения пакета (до 60 мин), вернуть итог по командам. Ожидание не блокирует event loop сервера."""
deadline = time.monotonic() + min(max(int(timeout_minutes), 1), 60) * 60
client = _client()
client = core.client()
while True:
b = await asyncio.to_thread(client.get, f"/batches/{id}")
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()
@guard
@scoped(False)
@core.guard
@core.scoped(False)
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 "пакетов нет"
@mcp.tool()
@guard
@scoped(True)
@core.guard
@core.scoped(True)
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']} команд"
@mcp.tool()
@guard
@scoped(False)
@core.guard
@core.scoped(False)
def batch_cancel(id: int) -> str:
"""Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает —
в ответе подсказка (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()
@guard
@scoped(True)
@core.guard
@core.scoped(True)
async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool = False) -> str:
"""Обновить зеркало из Jira: по ключам или полностью (базовая JQL). По умолчанию только ставит пакет;
wait=true — дождаться результата (до 30 мин)."""
body = {"full": True} if full or not keys else {"keys": keys}
body["author"] = AUTHOR
b = await asyncio.to_thread(_client().post, "/refresh", body)
body["author"] = core.AUTHOR
b = await asyncio.to_thread(core.client().post, "/refresh", body)
return await batch_wait(b["id"], 30) if wait else f"пакет #{b['id']} «Обновление» поставлен"
@mcp.tool()
@guard
@scoped(False)
@core.guard
@core.scoped(False)
def agent_status() -> str:
"""Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия,
состояние самообновления и запускатель."""
return fmt_agent(_client().get("/agent"))
return fmt_agent(core.client().get("/agent"))
@mcp.tool()
@guard
@scoped(False)
@core.guard
@core.scoped(False)
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):
at = r.get("history_at")
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"{journal(r)}" for r in runs) or "обновлений не было"
@mcp.tool()
@guard
@scoped(False)
@core.guard
@core.scoped(False)
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)
@mcp.tool()
@guard
@scoped(False)
@core.guard
@core.scoped(False)
def settings_set(key: str, value: str) -> str:
"""Записать настройку: value — JSON. Поднимает settings_version."""
try:
parsed = json.loads(value)
except ValueError as 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']}"
@mcp.tool()
@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")
core.register_core_tools(mcp)

View File

@ -5,7 +5,8 @@ import json
import os
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"
@ -20,7 +21,7 @@ def _check(name, snapshot):
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}
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"]
def test_mcp_project_option(monkeypatch):
from jira_hub.core import mcp_server
def test_mcp_default_module(monkeypatch):
from jira_hub.core import mcp as core_mcp
from jira_hub.modules.jira import mcp as jira_mcp
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"]) == 0
assert seen == ["AWPCA", None]
assert cli.main(["mcp", "jira", "--project", "AWPCA"]) == 0
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():
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)
assert r.returncode == 0, r.stderr
@ -19,5 +19,5 @@ def test_web_app_path():
def test_hub_root():
from jira_hub.core import mcp_server
assert (mcp_server.HUB_ROOT / "pyproject.toml").is_file()
from jira_hub.core import mcp
assert (mcp.HUB_ROOT / "pyproject.toml").is_file()

View File

@ -9,7 +9,9 @@ from types import SimpleNamespace
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.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)
threading.Thread(target=srv.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{srv.server_port}"
monkeypatch.setattr(mcp_server, "_client", lambda: HubClient(base, tok["client"]))
monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05)
monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA")
monkeypatch.setattr(core_mcp, "client", lambda: HubClient(base, tok["client"]))
monkeypatch.setattr(jmcp, "POLL_SECONDS", 0.05)
monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
yield {"agent": HubClient(base, tok["agent"]), "client": HubClient(base, tok["client"])}
srv.shutdown()
srv.server_close()
def test_offline_message(monkeypatch):
monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA")
monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1))
assert mcp_server.issues() == mcp_server.OFFLINE
assert mcp_server.agent_status() == mcp_server.OFFLINE
monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
monkeypatch.setattr(core_mcp, "client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1))
assert jmcp.issues() == core_mcp.OFFLINE
assert jmcp.agent_status() == core_mcp.OFFLINE
def test_tools_roundtrip(live):
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема раз", "status": "Готово", "duedate": "2026-10-23",
"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 "В работе" in mcp_server.notes_set("A-1", fact_status="В работе")
detail = mcp_server.issue("A-1")
assert "В работе" in jmcp.notes_set("A-1", fact_status="В работе")
detail = jmcp.issue("A-1")
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])
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")
cmd = live["agent"].post("/agent/claim", {})["command"]
live["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 400 поле"})
tail = live["agent"].post("/agent/claim", {})["command"]
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 f"#{batch_id}" in mcp_server.batches(limit=5)
assert "Повтор команды" in mcp_server.command_retry(cmd["id"])
assert "cancelled" in mcp_server.batch_cancel(batch_id + 1)
r = asyncio.run(mcp_server.refresh(full=True))
assert f"#{batch_id}" in jmcp.batches(limit=5)
assert "Повтор команды" in jmcp.command_retry(cmd["id"])
assert "cancelled" in jmcp.batch_cancel(batch_id + 1)
r = asyncio.run(jmcp.refresh(full=True))
assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", r)
assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", asyncio.run(mcp_server.refresh(keys=["A-1"], wait=False)))
assert "онлайн" in mcp_server.agent_status()
assert "120" in mcp_server.settings_get("pacing")
assert "settings_version" in mcp_server.settings_set("pacing", json.dumps({"refresh": 1}))
assert mcp_server.settings_set("tokens", '"x"').startswith("ошибка хаба 422")
assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", asyncio.run(jmcp.refresh(keys=["A-1"], wait=False)))
assert "онлайн" in jmcp.agent_status()
assert "120" in jmcp.settings_get("pacing")
assert "settings_version" in jmcp.settings_set("pacing", json.dumps({"refresh": 1}))
assert jmcp.settings_set("tokens", '"x"').startswith("ошибка хаба 422")
def test_formatters():
assert mcp_server.fmt_issues([]) == "задач нет"
text = mcp_server.fmt_issues([{"key": "A-1", "summary": "x" * 100, "status": "Готово", "duedate": None, "assignee": None,
assert jmcp.fmt_issues([]) == "задач нет"
text = jmcp.fmt_issues([{"key": "A-1", "summary": "x" * 100, "status": "Готово", "duedate": None, "assignee": None,
"sprint": None, "fact_status": None}])
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,
"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",
"deliveries": 2}],
"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):
assert mcp_server.settings_set("pacing", "{не json").startswith("значение не JSON")
assert jmcp.settings_set("pacing", "{не json").startswith("значение не JSON")
def _batch(status):
@ -116,25 +118,25 @@ def _fake_time(monkeypatch, step=1.0):
async def sleep(_):
clock["sleeps"] += 1
monkeypatch.setattr(mcp_server, "time", SimpleNamespace(monotonic=monotonic))
monkeypatch.setattr(mcp_server, "asyncio", SimpleNamespace(sleep=sleep, to_thread=asyncio.to_thread))
monkeypatch.setattr(jmcp, "time", SimpleNamespace(monotonic=monotonic))
monkeypatch.setattr(jmcp, "asyncio", SimpleNamespace(sleep=sleep, to_thread=asyncio.to_thread))
return clock
def test_batch_wait_polls_until_done(monkeypatch):
fake = FakeClient(["running", "running", "done"])
monkeypatch.setattr(mcp_server, "_client", lambda: fake)
monkeypatch.setattr(core_mcp, "client", lambda: fake)
clock = _fake_time(monkeypatch)
out = asyncio.run(mcp_server.batch_wait(7, timeout_minutes=10))
assert out == mcp_server.fmt_batch(_batch("done")) and "таймаут" not in out
out = asyncio.run(jmcp.batch_wait(7, timeout_minutes=10))
assert out == jmcp.fmt_batch(_batch("done")) and "таймаут" not in out
assert fake.calls == 3 and clock["sleeps"] == 2
def test_batch_wait_timeout(monkeypatch):
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 шага монотонных часов
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 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)])
def test_batch_wait_clamps_timeout(monkeypatch, asked, minutes):
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 минута
asyncio.run(mcp_server.batch_wait(7, timeout_minutes=asked))
asyncio.run(jmcp.batch_wait(7, timeout_minutes=asked))
# первый вызов часов (1 мин) задаёт старт; дедлайн = старт + minutes; выход, когда now >= дедлайна
assert fake.calls == minutes
def test_client_created_once_per_wait(monkeypatch):
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)
asyncio.run(mcp_server.batch_wait(7))
asyncio.run(jmcp.batch_wait(7))
assert len(made) == 1
def test_sync_log_content(monkeypatch):
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}]
monkeypatch.setattr(mcp_server, "_client", lambda: FakeClient(["done"], runs))
assert mcp_server.sync_log() == "#3 full project = AWPCA: 2026-10-11T10:00 → —, задач 12, связей 5, вызовов 4, 429: 1, журнал: нет"
monkeypatch.setattr(core_mcp, "client", lambda: FakeClient(["done"], runs))
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():
assert asyncio.iscoroutinefunction(mcp_server.batch_wait) and asyncio.iscoroutinefunction(mcp_server.refresh)
tools = {t.name: t for t in asyncio.run(mcp_server.mcp.list_tools())}
assert asyncio.iscoroutinefunction(jmcp.batch_wait) and asyncio.iscoroutinefunction(jmcp.refresh)
tools = {t.name: t for t in asyncio.run(jmcp.mcp.list_tools())}
assert {"batch_wait", "refresh"} <= set(tools)
assert tools["refresh"].inputSchema["properties"]["wait"]["default"] is False
def test_async_guard_offline(monkeypatch):
monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA")
monkeypatch.setattr(mcp_server, "_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(mcp_server.refresh(full=True)) == mcp_server.OFFLINE
monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
monkeypatch.setattr(core_mcp, "client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1))
assert asyncio.run(jmcp.batch_wait(1)) == core_mcp.OFFLINE
assert asyncio.run(jmcp.refresh(full=True)) == core_mcp.OFFLINE
def test_batch_wait_does_not_block_event_loop(monkeypatch):
"""Пока идёт ожидание, другая задача event loop продолжает выполняться."""
fake = FakeClient(["running", "running", "done"])
monkeypatch.setattr(mcp_server, "_client", lambda: fake)
monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.01)
monkeypatch.setattr(core_mcp, "client", lambda: fake)
monkeypatch.setattr(jmcp, "POLL_SECONDS", 0.01)
async def scenario():
ticks = []
@ -192,7 +194,7 @@ def test_batch_wait_does_not_block_event_loop(monkeypatch):
ticks.append(1)
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
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",
"remaining_seconds": 120, "counts": {"claimed": 1, "cancelled": 1}, "commands": [],
"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
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 "метки других проектов запрещены" in doc and "board_id для ensure_sprint выставляет хаб" 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():
"""Клиент 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
for ctype, spec in commands.SPEC.items():
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():
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}
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},
"launcher_version": 1, "update_hint": "откат с v7 на v6: код 1 через 3 с"})
assert "публикация: v7 от 2026-10-14T19:00:00+00:00" in text
assert "обновление: rolled_back v6 → v7" 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
@ -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,
"release": {"version": 10, "sha256": "s", "published_at": "2026-10-15T09:00:00+00:00"},
"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 "без запускателя" not in text and "агент запущен без run.py" not in text
assert "без запускателя" in mcp_server.fmt_agent({**base, "agent_reported": True})
assert "без запускателя" in mcp_server.fmt_agent(base) # старый хаб: поля agent_reported нет
assert "без запускателя" in jmcp.fmt_agent({**base, "agent_reported": True})
assert "без запускателя" in jmcp.fmt_agent(base) # старый хаб: поля agent_reported нет
def test_agent_status_right_after_hub_start(live):
"""Спека mcp «Статус агента сразу после перезапуска хаба»."""
assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in mcp_server.agent_status()
assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in jmcp.agent_status()
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
@ -273,17 +275,17 @@ def _detail(**over):
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",
"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 "д" * 299 + "…" in text and "д" * 300 not in text # самый новый — первым, обрезан до 300
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 "ещё 2" in text
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": ""}]))
assert "комментарии (1):" in no_author and "ещё" not in no_author
@ -291,10 +293,10 @@ def test_fmt_issue_comments():
def test_fmt_comment():
"""Один комментарий целиком (#80): переносы строк сохраняются, «изменён» — только если правили."""
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})
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": ""})
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": "коротко",
"created": "2026-10-21T10:00:00.000+0300"}]}],
"links": []})
assert "#101 golov" in mcp_server.issue("A-1") and long not in mcp_server.issue("A-1")
out = mcp_server.comment("A-1", "101")
assert "#101 golov" in jmcp.issue("A-1") and long not in jmcp.issue("A-1")
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 mcp_server.comment("A-1", "#102").endswith("\nкоротко") # номер можно передать как в issue
assert mcp_server.comment("A-1", "999") == "у A-1 нет комментария #999"
assert mcp_server.comment("A-404", "1").startswith("ошибка хаба 404")
monkeypatch.setattr(mcp_server, "PROJECT", None)
assert mcp_server.comment("A-1", "101") == mcp_server.NOT_BOUND
assert jmcp.comment("A-1", "#102").endswith("\nкоротко") # номер можно передать как в issue
assert jmcp.comment("A-1", "999") == "у A-1 нет комментария #999"
assert jmcp.comment("A-404", "1").startswith("ошибка хаба 404")
monkeypatch.setattr(core_mcp, "PROJECT", None)
assert jmcp.comment("A-1", "101") == core_mcp.NOT_BOUND
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):
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):
@ -333,11 +335,11 @@ def _write(path, data):
def test_batch_create_bug_with_local_file(live, tmp_path):
"""Спека 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}}))
f = tmp_path / "ОСВ сч. 60 за 2 кв. 2026.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)}}])
assert "3 команд" in 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))
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], "links": []})
(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"}}])
assert _batch_commands(out)[0]["params"] == {"key": "A-1", "file": hashlib.sha256(b"png").hexdigest(),
"filename": "Экран 1.png"}
@ -370,9 +372,9 @@ def test_batch_create_bad_path(live, tmp_path, make, expected):
src = tmp_path / "src"
src.mkdir()
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 mcp_server._client().get("/batches")["batches"] == []
assert core_mcp.client().get("/batches")["batches"] == []
assert list((tmp_path / "files").iterdir()) == []
@ -383,7 +385,7 @@ def test_batch_create_unreadable_file(live, tmp_path):
try:
if os.access(f, os.R_OK):
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}"
finally:
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):
f = tmp_path / "a.xlsx"
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}}])
assert out == "у ensure_attachment либо path, либо file"
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.write_bytes(b"a")
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(missing)}}])
assert out == f"файл не найден: {missing}"
@ -413,117 +415,117 @@ def test_batch_create_hub_422_after_upload(live, tmp_path):
"""Отказ хаба по другой команде — текст ошибки; загруженный файл остаётся до уборки (design, «MCP»)."""
f = tmp_path / "a.xlsx"
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 mcp_server._client().get("/batches")["batches"] == []
assert core_mcp.client().get("/batches")["batches"] == []
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 "issue_type (task|epic|bug)" in desc
assert "вместо file передайте path" in desc and "10 МБ" in desc and "filename по умолчанию — имя файла" in desc
def test_unbound_session(live, monkeypatch): # сценарий «Сессия без проекта»
monkeypatch.setattr(mcp_server, "PROJECT", None)
monkeypatch.setattr(core_mcp, "PROJECT", None)
calls = []
real = HubClient.call
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"),
mcp_server.batch_create("x", []), mcp_server.command_retry(1), asyncio.run(mcp_server.refresh(full=True))):
assert out == mcp_server.NOT_BOUND
for out in (jmcp.issues(), jmcp.issue("A-1"), jmcp.notes_set("A-1", work_kind="x"),
jmcp.batch_create("x", []), jmcp.command_retry(1), asyncio.run(jmcp.refresh(full=True))):
assert out == core_mcp.NOT_BOUND
assert calls == []
for out in (mcp_server.batches(), mcp_server.sync_log(), mcp_server.settings_get(), asyncio.run(mcp_server.batch_wait(1))):
assert out != mcp_server.NOT_BOUND # не зависящие от проекта — работают без привязки
assert "AWPCA" in mcp_server.projects()
assert mcp_server.agent_status().startswith("агент ") # служебные инструменты работают без привязки
for out in (jmcp.batches(), jmcp.sync_log(), jmcp.settings_get(), asyncio.run(jmcp.batch_wait(1))):
assert out != core_mcp.NOT_BOUND # не зависящие от проекта — работают без привязки
assert "AWPCA" in core_mcp.projects()
assert jmcp.agent_status().startswith("агент ") # служебные инструменты работают без привязки
def test_unknown_bound_project(live, monkeypatch): # сценарий «Опечатка в проекте»
monkeypatch.setattr(mcp_server, "PROJECT", "STOKS")
assert mcp_server.issues() == "проект STOKS не заведён в хабе: project_create или исправьте --project в .mcp.json"
assert mcp_server.agent_status().startswith("проект STOKS не заведён")
assert asyncio.run(mcp_server.batch_wait(1)).startswith("проект STOKS не заведён")
assert "AWPCA" in mcp_server.projects()
monkeypatch.setattr(core_mcp, "PROJECT", "STOKS")
assert jmcp.issues() == "проект STOKS не заведён в хабе: project_create или исправьте --project в .mcp.json"
assert jmcp.agent_status().startswith("проект STOKS не заведён")
assert asyncio.run(jmcp.batch_wait(1)).startswith("проект STOKS не заведён")
assert "AWPCA" in core_mcp.projects()
def test_bound_offline(monkeypatch): # Review Focus 4: хаб выключен — не «проект не заведён»
monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA")
monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1))
assert mcp_server.issues() == mcp_server.OFFLINE
assert mcp_server.agent_status() == mcp_server.OFFLINE
assert asyncio.run(mcp_server.refresh(full=True)) == mcp_server.OFFLINE
assert mcp_server.projects() == mcp_server.OFFLINE
monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
monkeypatch.setattr(core_mcp, "client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1))
assert jmcp.issues() == core_mcp.OFFLINE
assert jmcp.agent_status() == core_mcp.OFFLINE
assert asyncio.run(jmcp.refresh(full=True)) == core_mcp.OFFLINE
assert core_mcp.projects() == core_mcp.OFFLINE
def test_bound_issues_and_create(live): # «Задачи своего проекта», «Создание задачи без метки»
live["client"].post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"})
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "своя АРМ", "labels": ["AWPCA"]},
{"key": "S-1", "summary": "своя запасы", "labels": ["STOCKS_DRRK"]}], "links": []})
assert "A-1" in mcp_server.issues() and "S-1" not in mcp_server.issues()
out = mcp_server.batch_create("новая", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "т"}}])
assert "A-1" in jmcp.issues() and "S-1" not in jmcp.issues()
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])}")
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 "work_kind: x" in mcp_server.notes_set("new:3", work_kind="x") # временный ключ — можно, без GET /issues
assert "S-1" in mcp_server.issue("S-1") # явный ключ — любая задача
assert mcp_server.batch_create("чужая", [{"type": "ensure_fields", "params": {"key": "S-1", "duedate": "2026-10-23"}}]).startswith("ошибка хаба 422")
assert jmcp.notes_set("S-1", work_kind="x") == "S-1 не в проекте AWPCA"
assert "work_kind: x" in jmcp.notes_set("new:3", work_kind="x") # временный ключ — можно, без GET /issues
assert "S-1" in jmcp.issue("S-1") # явный ключ — любая задача
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):
live["client"].post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"})
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])
monkeypatch.setattr(mcp_server, "PROJECT", "STOCKS_DRRK")
other = mcp_server.batch_create("чужого проекта", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "с"}}])
monkeypatch.setattr(core_mcp, "PROJECT", "STOCKS_DRRK")
other = jmcp.batch_create("чужого проекта", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "с"}}])
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
monkeypatch.setattr(mcp_server, "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"#{other_id}" in mcp_server.batch_status(other_id) # по id — без ограничения
monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
assert f"пакет #{mine_id} " in jmcp.batches(limit=50) and f"пакет #{other_id} " not in jmcp.batches(limit=50)
assert f"#{other_id}" in jmcp.batch_status(other_id) # по 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
monkeypatch.setattr(mcp_server, "PROJECT", None) # без привязки batches — все
assert f"пакет #{mine_id} " in mcp_server.batches(limit=50) and f"пакет #{other_id} " in mcp_server.batches(limit=50)
monkeypatch.setattr(core_mcp, "PROJECT", None) # без привязки batches — все
assert f"пакет #{mine_id} " in jmcp.batches(limit=50) and f"пакет #{other_id} " in jmcp.batches(limit=50)
def test_project_create(live, monkeypatch): # сценарий «Проект из чата»
monkeypatch.setattr(mcp_server, "PROJECT", None)
out = mcp_server.project_create("STOCKS_DRRK", "Запасы ДРРК", 5120)
monkeypatch.setattr(core_mcp, "PROJECT", None)
out = core_mcp.project_create("STOCKS_DRRK", "Запасы ДРРК", 5120)
assert "STOCKS_DRRK" in out and re.search(r"пакет[^#]*#\d+", out) and "/mcp" in out
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 live["client"].get("/batches")["batches"][0]["author"] == "claude"
assert mcp_server.project_create("awpca", "x").startswith("ошибка хаба 409")
assert mcp_server.project_create("BAD LABEL", "x").startswith("ошибка хаба 422")
assert core_mcp.project_create("awpca", "x").startswith("ошибка хаба 409")
assert core_mcp.project_create("BAD LABEL", "x").startswith("ошибка хаба 422")
def test_hub_root_is_repo():
assert (mcp_server.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.HUB_ROOT / "pyproject.toml").is_file()
assert core_mcp.mcp_json("X")["mcpServers"]["jira-hub"]["args"][:3] == ["run", "--project", str(core_mcp.HUB_ROOT)]
def test_fmt_projects():
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}]
out = mcp_server.fmt_projects(items, "AWPCA").splitlines()
out = core_mcp.fmt_projects(items, "AWPCA").splitlines()
assert out[0] == "метка | название | доска | тип доски | задач"
assert out[1] == "AWPCA | АРМ КА | 4216 | scrum | 190 (текущий)"
assert out[2] == "STOCKS_DRRK | Запасы ДРРК | — | неизвестен | 0"
assert mcp_server.fmt_projects([], None) == "проектов нет"
assert "(текущий)" not in mcp_server.fmt_projects(items, None)
assert core_mcp.fmt_projects([], None) == "проектов нет"
assert "(текущий)" not in core_mcp.fmt_projects(items, None)
def test_run_binds_project(monkeypatch):
monkeypatch.setattr(mcp_server, "PROJECT", None)
monkeypatch.setattr(mcp_server.mcp, "run", lambda transport: None)
mcp_server.run("AWPCA")
assert mcp_server.PROJECT == "AWPCA"
monkeypatch.setattr(core_mcp, "PROJECT", None)
monkeypatch.setattr(jmcp.mcp, "run", lambda transport: None)
assert core_mcp.run("jira", "AWPCA") == 0
assert core_mcp.PROJECT == "AWPCA"
# --- лента изменений (#70, Task 5) ------------------------------------------------------------------------------
@ -545,29 +547,29 @@ def _ev(**kw):
def test_fmt_event_status():
e = _ev()
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():
e = _ev()
d = _local(e["at"])
assert mcp_server.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, d.date() + timedelta(days=1)).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():
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")
assert mcp_server.fmt_event(e, None).endswith(": плановое окончание сырое → 30.10")
assert jmcp.fmt_event(e, None).endswith(": плановое окончание сырое → 30.10")
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
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 + "…")
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»")
@ -578,7 +580,7 @@ def test_fmt_changes_grouped():
_ev(at="2026-10-22T08:15:00+00:00"),
_ev(at="2026-10-22T08:00:00+00:00", key="Y", summary="Вторая", status="В работе", kind="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[1] == "Y Вторая — В работе" and lines[2].startswith(" ") and lines[3].endswith("описание изменено")
assert lines[4] == "X Тема — Готово"
@ -586,7 +588,7 @@ def test_fmt_changes_grouped():
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())
assert out.split("\n")[1] == "за период изменений нет"
@ -604,51 +606,51 @@ def test_changes_today(live): # «Ч
now = datetime.now(timezone.utc).replace(microsecond=0)
_history(live, "A-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 mcp_server.changes(hours=2)
assert "B-1" in mcp_server.changes(key="B-1")
assert "A-1" in jmcp.changes(hours=2)
assert "B-1" in jmcp.changes(key="B-1")
def test_changes_hours_and_since(live):
assert mcp_server.changes(hours=2, since="2026-10-22T10:00:00Z") == "либо hours, либо since"
assert mcp_server.changes(hours=0) == "hours — число больше 0"
assert jmcp.changes(hours=2, since="2026-10-22T10:00:00Z") == "либо hours, либо since"
assert jmcp.changes(hours=0) == "hours — число больше 0"
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: время без смещения — местное
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"
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):
monkeypatch.setattr(mcp_server, "PROJECT", None)
assert mcp_server.changes() == mcp_server.NOT_BOUND
assert mcp_server.changes(key="A-1").split("\n")[1] == "за период изменений нет"
monkeypatch.setattr(core_mcp, "PROJECT", None)
assert jmcp.changes() == core_mcp.NOT_BOUND
assert jmcp.changes(key="A-1").split("\n")[1] == "за период изменений нет"
def test_issue_shows_changes(live): # «История в карточке»
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)
out = mcp_server.issue("A-1")
out = jmcp.issue("A-1")
assert "изменения (7):" in out and " ещё 2" in out and out.count("статус «a»") == 5
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"]
assert "журнал: нет" in mcp_server.sync_log()
assert "журнал: нет" in jmcp.sync_log()
live["agent"].post(f"/agent/commands/{cmd['id']}/result",
{"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) -----------------------------------------------------------------
@ -694,14 +696,14 @@ class FakeHub:
@pytest.fixture
def downloads(tmp_path, monkeypatch):
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA")
monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05)
monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
monkeypatch.setattr(jmcp, "POLL_SECONDS", 0.05)
return tmp_path / "home" / "downloads"
def _use(monkeypatch, hub):
monkeypatch.setattr(mcp_server, "_client", lambda: hub)
monkeypatch.setattr(mcp_server, "_unbound_or_unknown", lambda required: None)
monkeypatch.setattr(core_mcp, "client", lambda: hub)
monkeypatch.setattr(core_mcp, "unbound_or_unknown", lambda required: None)
def _issue_with_att():
@ -713,32 +715,32 @@ def _issue_with_att():
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):")
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 "(234,2 КБ)" in lines["#31337"] and "в описании, в #101, скачано" in lines["#31337"]
assert "файлы: #31337 скрин 1.png" in text
assert "вложений нет" in mcp_server.fmt_issue(_detail(attachments=[]))
assert "вложений нет" in mcp_server.fmt_issue(_detail())
assert "вложений нет" in jmcp.fmt_issue(_detail(attachments=[]))
assert "вложений нет" in jmcp.fmt_issue(_detail())
def test_comment_attachments():
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 "вложения" 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():
"""M-7: имя ≤ 255 байт UTF-8, расширение сохраняется, многобайтовый символ не режется."""
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")
name.encode("utf-8")
assert name.startswith("я") or name.startswith("ы")
assert len(mcp_server.safe_name("ф" * 300, "7").encode()) <= 255
assert mcp_server.safe_name("короткое.png", "7") == "короткое.png"
assert len(jmcp.safe_name("ф" * 300, "7").encode()) <= 255
assert jmcp.safe_name("короткое.png", "7") == "короткое.png"
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")
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch={"state": "ready", "command_id": 5}, polls=[att], data=b"abc")
_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])
assert path.is_file() and path.read_bytes() == b"abc" and len(path.name.encode()) <= 255
def test_safe_name():
assert mcp_server.safe_name("a/b\\c:d\x01.png", "7") == "a_b_c_d_.png"
assert mcp_server.safe_name(" ", "7") == "attachment-7"
assert mcp_server.safe_name("..", "7") == "attachment-7"
assert mcp_server.safe_name("a\x7f", "7") == "a_"
assert jmcp.safe_name("a/b\\c:d\x01.png", "7") == "a_b_c_d_.png"
assert jmcp.safe_name(" ", "7") == "attachment-7"
assert jmcp.safe_name("..", "7") == "attachment-7"
assert jmcp.safe_name("a\x7f", "7") == "a_"
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):
hub = _ready_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"
lines = out.splitlines()
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},
polls=[{**a20, "state": "ready"}], data=b"abc")
_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 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):
hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("1", "a.png", 1)]))
_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 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}
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=fetching, polls=[fetching])
_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 ещё раз — вторая команда не поставится"
@ -808,7 +810,7 @@ def test_attachment_failed(downloads, monkeypatch):
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])
_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):
@ -816,7 +818,7 @@ def test_attachment_413(downloads, monkeypatch):
att = _att("5", "запись.mp4", 26214400)
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=HubError(413, "too_large", "большой"))
_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»"
@ -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)]),
fetch=HubError(409, "agent_too_old", "агент v9 не умеет скачивать вложения — нужен v14"))
_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):
@ -836,6 +838,6 @@ def test_attachment_existing_copy_same_size(downloads, monkeypatch):
path.write_bytes(b"OLD" * 3)
os.utime(path, (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.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"
# временно: R-1, сокращается в Task 6
PENDING = {"cli.py", "mcp_server.py"}
# временно: R-1, список только сокращается; после Task 6 пуст
PENDING: set[str] = set()
def _noop(*a, **k):