Модульная перестройка хаба: ядро + модуль jira (#87) #88
@ -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
146
src/jira_hub/core/mcp.py
Normal 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
|
||||
@ -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,
|
||||
)
|
||||
|
||||
23
src/jira_hub/modules/jira/cli.py
Normal file
23
src/jira_hub/modules/jira/cli.py
Normal 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
|
||||
@ -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)
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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 == []
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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: папка «освежена» — уборка её не заденет
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user