MCP: привязка сессии к проекту (--project), инструменты projects и project_create (#61)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e2e63dab23
commit
c8741be309
@ -270,3 +270,12 @@
|
|||||||
28. 2026-10-19 — **метки временного ключа для `ensure_sprint` берутся только из его `ensure_issue` в пакете, без
|
28. 2026-10-19 — **метки временного ключа для `ensure_sprint` берутся только из его `ensure_issue` в пакете, без
|
||||||
обращения к зеркалу.** Обоснование: `new:<n>` в зеркале — не задача Jira; метки пакета (с дописанной меткой проекта)
|
обращения к зеркалу.** Обоснование: `new:<n>` в зеркале — не задача Jira; метки пакета (с дописанной меткой проекта)
|
||||||
единственный надёжный источник. Цена ошибки: ветка в `_assign_boards`.
|
единственный надёжный источник. Цена ошибки: ветка в `_assign_boards`.
|
||||||
|
29. 2026-10-19 — **проверка метки сессии (`GET /projects`) выполняется внутри `guard`, поэтому при выключенном хабе
|
||||||
|
привязанная сессия отвечает «хаб не запущен: jira-hub serve», а не «проект не заведён»; в async-инструментах
|
||||||
|
проверка идёт через `asyncio.to_thread`.** Обоснование: Review Focus 4 плана; `scoped` стоит под `guard`. Цена
|
||||||
|
ошибки: порядок декораторов; лишний `GET /projects` на каждый вызов (в `refresh(wait=True)` — второй, из
|
||||||
|
`batch_wait`).
|
||||||
|
30. 2026-10-19 — **`projects` и `project_create` без привязки не проверяют метку сессии; `notes_set` для не временного
|
||||||
|
ключа проверяет принадлежность по `labels` из `GET /issues/{key}` (точное совпадение, как в хабе).** Обоснование:
|
||||||
|
спека исключает эти два инструмента из проверки; «только своя задача» требует метки P. Цена ошибки: одна ветка в
|
||||||
|
`notes_set`.
|
||||||
|
|||||||
@ -18,7 +18,8 @@ def main(argv=None) -> int:
|
|||||||
p.add_argument("--port", type=int)
|
p.add_argument("--port", type=int)
|
||||||
p = sub.add_parser("import", help="импорт CSV-пакета admin/jira-sync в заметки")
|
p = sub.add_parser("import", help="импорт CSV-пакета admin/jira-sync в заметки")
|
||||||
p.add_argument("folder")
|
p.add_argument("folder")
|
||||||
sub.add_parser("mcp", help="MCP-сервер по stdio")
|
p = sub.add_parser("mcp", help="MCP-сервер по stdio")
|
||||||
|
p.add_argument("--project", help="метка проекта, к которому привязана сессия")
|
||||||
sub.add_parser("tokens", help="показать токены агента и клиента")
|
sub.add_parser("tokens", help="показать токены агента и клиента")
|
||||||
p = sub.add_parser("agent", help="версия агента для самообновления")
|
p = sub.add_parser("agent", help="версия агента для самообновления")
|
||||||
agent_sub = p.add_subparsers(dest="agent_cmd", required=True)
|
agent_sub = p.add_subparsers(dest="agent_cmd", required=True)
|
||||||
@ -46,7 +47,7 @@ def main(argv=None) -> int:
|
|||||||
return 0
|
return 0
|
||||||
if args.cmd == "mcp":
|
if args.cmd == "mcp":
|
||||||
from . import mcp_server
|
from . import mcp_server
|
||||||
mcp_server.run()
|
mcp_server.run(args.project)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
conn = db.connect(paths.db_path())
|
conn = db.connect(paths.db_path())
|
||||||
|
|||||||
@ -12,16 +12,28 @@ from pathlib import Path
|
|||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
from .client import HubClient, HubError, HubUnavailable
|
from .client import HubClient, HubError, HubUnavailable
|
||||||
from .commands import describe
|
from .commands import describe, is_temp
|
||||||
from .files import MAX_SIZE
|
from .files import MAX_SIZE
|
||||||
|
|
||||||
OFFLINE = "хаб не запущен: jira-hub serve"
|
OFFLINE = "хаб не запущен: jira-hub serve"
|
||||||
POLL_SECONDS = 5
|
POLL_SECONDS = 5
|
||||||
AUTHOR = "claude"
|
AUTHOR = "claude"
|
||||||
BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file"
|
BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file"
|
||||||
|
NOT_BOUND = "сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)"
|
||||||
|
HUB_ROOT = Path(__file__).resolve().parents[2] # корень репозитория хаба, из которого загружен пакет (решение 19)
|
||||||
|
PROJECT: str | None = None # привязка сессии, задаётся run(project)
|
||||||
mcp = FastMCP("jira-hub")
|
mcp = FastMCP("jira-hub")
|
||||||
|
|
||||||
|
|
||||||
|
def unknown_project(label) -> str:
|
||||||
|
return f"проект {label} не заведён в хабе: project_create или исправьте --project в .mcp.json"
|
||||||
|
|
||||||
|
|
||||||
|
def mcp_json(label: str) -> dict:
|
||||||
|
return {"mcpServers": {"jira-hub": {"command": "uv", "args": ["run", "--project", str(HUB_ROOT), "jira-hub", "mcp",
|
||||||
|
"--project", label]}}}
|
||||||
|
|
||||||
|
|
||||||
def _client() -> HubClient:
|
def _client() -> HubClient:
|
||||||
return HubClient.from_home()
|
return HubClient.from_home()
|
||||||
|
|
||||||
@ -54,6 +66,33 @@ def guard(fn):
|
|||||||
return wrapper
|
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):
|
def _cut(text, n=80):
|
||||||
@ -65,6 +104,16 @@ def _v(value):
|
|||||||
return "—" if value in (None, "", []) else str(value)
|
return "—" if value in (None, "", []) else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_projects(items, current: str | None) -> str:
|
||||||
|
if not items:
|
||||||
|
return "проектов нет"
|
||||||
|
lines = ["метка | название | доска | тип доски | задач"]
|
||||||
|
for p in items:
|
||||||
|
lines.append(" | ".join([p["label"], p["name"], _v(p.get("board_id")), p.get("board_type") or "неизвестен",
|
||||||
|
str(p.get("issues_count", 0))]) + (" (текущий)" if p["label"] == current else ""))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def fmt_issues(issues) -> str:
|
def fmt_issues(issues) -> str:
|
||||||
if not issues:
|
if not issues:
|
||||||
return "задач нет"
|
return "задач нет"
|
||||||
@ -137,17 +186,19 @@ def fmt_agent(a) -> str:
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@guard
|
@guard
|
||||||
|
@scoped(True)
|
||||||
def issues(sprint: str = "", assignee: str = "", work_kind: str = "", fact_status: str = "", status: str = "",
|
def issues(sprint: str = "", assignee: str = "", work_kind: str = "", fact_status: str = "", status: str = "",
|
||||||
label: str = "", epic: str = "", q: str = "", neighbor: str = "", limit: int = 50) -> str:
|
label: str = "", epic: str = "", q: str = "", neighbor: str = "", limit: int = 50) -> str:
|
||||||
"""Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус по факту, статус Jira, метка, эпик,
|
"""Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус по факту, статус Jira, метка, эпик,
|
||||||
подстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица."""
|
подстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица."""
|
||||||
data = _client().get("/issues", sprint=sprint, assignee=assignee, work_kind=work_kind, fact_status=fact_status,
|
data = _client().get("/issues", project=PROJECT, sprint=sprint, assignee=assignee, work_kind=work_kind, fact_status=fact_status,
|
||||||
status=status, label=label, epic=epic, q=q, neighbor=neighbor, limit=limit)
|
status=status, label=label, epic=epic, q=q, neighbor=neighbor, limit=limit)
|
||||||
return fmt_issues(data["issues"])
|
return fmt_issues(data["issues"])
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@guard
|
@guard
|
||||||
|
@scoped(True)
|
||||||
def issue(key: str) -> str:
|
def issue(key: str) -> str:
|
||||||
"""Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание."""
|
"""Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание."""
|
||||||
return fmt_issue(_client().get(f"/issues/{key}"))
|
return fmt_issue(_client().get(f"/issues/{key}"))
|
||||||
@ -155,12 +206,16 @@ def issue(key: str) -> str:
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@guard
|
@guard
|
||||||
|
@scoped(True)
|
||||||
def notes_set(key: str, fact_status: str | None = None, work_kind: str | None = None, done_text: str | None = None,
|
def notes_set(key: str, fact_status: str | None = None, work_kind: str | None = None, done_text: str | None = None,
|
||||||
rest_text: str | None = None, owner_note: str | None = None) -> str:
|
rest_text: str | None = None, owner_note: str | None = None) -> str:
|
||||||
"""Правка служебных заметок задачи (только переданные поля)."""
|
"""Правка служебных заметок задачи (только переданные поля)."""
|
||||||
fields = {k: v for k, v in dict(fact_status=fact_status, work_kind=work_kind, done_text=done_text,
|
fields = {k: v for k, v in dict(fact_status=fact_status, work_kind=work_kind, done_text=done_text,
|
||||||
rest_text=rest_text, owner_note=owner_note).items() if v is not None}
|
rest_text=rest_text, owner_note=owner_note).items() if v is not None}
|
||||||
fields["updated_by"] = AUTHOR
|
fields["updated_by"] = 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)
|
note = _client().put(f"/issues/{key}/note", fields)
|
||||||
return "заметки " + key + ": " + "; ".join(f"{k}: {note.get(k)}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k))
|
return "заметки " + key + ": " + "; ".join(f"{k}: {note.get(k)}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k))
|
||||||
|
|
||||||
@ -232,11 +287,11 @@ def _doc(text: str):
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@guard
|
@guard
|
||||||
|
@scoped(True)
|
||||||
@_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам.
|
@_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам.
|
||||||
Параметры по типам (необязательные — в [ ]):
|
Параметры по типам (необязательные — в [ ]):
|
||||||
""" + describe() + """
|
""" + describe() + """
|
||||||
ensure_issue передавайте с labels, включающими базовую метку проекта (AWPCA): хаб и агент метки сами не добавляют,
|
Проект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб.
|
||||||
задача без неё выпадет из базовой JQL и из зеркала.
|
|
||||||
ensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит
|
ensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит
|
||||||
файл в хаб и подставит file, filename по умолчанию — имя файла.""")
|
файл в хаб и подставит file, filename по умолчанию — имя файла.""")
|
||||||
def batch_create(title: str, commands: list[dict]) -> str:
|
def batch_create(title: str, commands: list[dict]) -> str:
|
||||||
@ -244,12 +299,13 @@ def batch_create(title: str, commands: list[dict]) -> str:
|
|||||||
prepared = _attach_files(client, commands)
|
prepared = _attach_files(client, commands)
|
||||||
if isinstance(prepared, str):
|
if isinstance(prepared, str):
|
||||||
return prepared
|
return prepared
|
||||||
b = client.post("/batches", {"title": title, "author": AUTHOR, "commands": prepared})
|
b = client.post("/batches", {"title": title, "author": AUTHOR, "project": PROJECT, "commands": prepared})
|
||||||
return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин"
|
return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин"
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@guard
|
@guard
|
||||||
|
@scoped(False)
|
||||||
def batch_status(id: int) -> str:
|
def batch_status(id: int) -> str:
|
||||||
"""Состояние пакета и его команд."""
|
"""Состояние пакета и его команд."""
|
||||||
return fmt_batch(_client().get(f"/batches/{id}"))
|
return fmt_batch(_client().get(f"/batches/{id}"))
|
||||||
@ -257,6 +313,7 @@ def batch_status(id: int) -> str:
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@guard
|
@guard
|
||||||
|
@scoped(False)
|
||||||
async def batch_wait(id: int, timeout_minutes: int = 10) -> str:
|
async def batch_wait(id: int, timeout_minutes: int = 10) -> str:
|
||||||
"""Ждать завершения пакета (до 60 мин), вернуть итог по командам. Ожидание не блокирует event loop сервера."""
|
"""Ждать завершения пакета (до 60 мин), вернуть итог по командам. Ожидание не блокирует event loop сервера."""
|
||||||
deadline = time.monotonic() + min(max(int(timeout_minutes), 1), 60) * 60
|
deadline = time.monotonic() + min(max(int(timeout_minutes), 1), 60) * 60
|
||||||
@ -270,22 +327,25 @@ async def batch_wait(id: int, timeout_minutes: int = 10) -> str:
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@guard
|
@guard
|
||||||
|
@scoped(False)
|
||||||
def batches(limit: int = 10) -> str:
|
def batches(limit: int = 10) -> str:
|
||||||
"""Последние пакеты со статусом."""
|
"""Последние пакеты со статусом."""
|
||||||
items = _client().get("/batches", limit=limit)["batches"]
|
items = _client().get("/batches", project=PROJECT, limit=limit)["batches"]
|
||||||
return "\n".join(fmt_batch(b) for b in items) or "пакетов нет"
|
return "\n".join(fmt_batch(b) for b in items) or "пакетов нет"
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@guard
|
@guard
|
||||||
|
@scoped(True)
|
||||||
def command_retry(id: int) -> str:
|
def command_retry(id: int) -> str:
|
||||||
"""Повторить команду — новый пакет с теми же параметрами."""
|
"""Повторить команду — новый пакет с теми же параметрами."""
|
||||||
b = _client().post(f"/commands/{id}/retry", {"author": AUTHOR})
|
b = _client().post(f"/commands/{id}/retry", {"author": AUTHOR, "project": PROJECT})
|
||||||
return f"пакет #{b['id']} «Повтор команды {id}»: {b['count']} команд"
|
return f"пакет #{b['id']} «Повтор команды {id}»: {b['count']} команд"
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@guard
|
@guard
|
||||||
|
@scoped(False)
|
||||||
def batch_cancel(id: int) -> str:
|
def batch_cancel(id: int) -> str:
|
||||||
"""Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает —
|
"""Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает —
|
||||||
в ответе подсказка (POST /commands/N/cancel с force)."""
|
в ответе подсказка (POST /commands/N/cancel с force)."""
|
||||||
@ -294,6 +354,7 @@ def batch_cancel(id: int) -> str:
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@guard
|
@guard
|
||||||
|
@scoped(True)
|
||||||
async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool = False) -> str:
|
async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool = False) -> str:
|
||||||
"""Обновить зеркало из Jira: по ключам или полностью (базовая JQL). По умолчанию только ставит пакет;
|
"""Обновить зеркало из Jira: по ключам или полностью (базовая JQL). По умолчанию только ставит пакет;
|
||||||
wait=true — дождаться результата (до 30 мин)."""
|
wait=true — дождаться результата (до 30 мин)."""
|
||||||
@ -305,6 +366,7 @@ async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@guard
|
@guard
|
||||||
|
@scoped(False)
|
||||||
def agent_status() -> str:
|
def agent_status() -> str:
|
||||||
"""Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия,
|
"""Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия,
|
||||||
состояние самообновления и запускатель."""
|
состояние самообновления и запускатель."""
|
||||||
@ -313,6 +375,7 @@ def agent_status() -> str:
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@guard
|
@guard
|
||||||
|
@scoped(False)
|
||||||
def sync_log(limit: int = 10) -> str:
|
def sync_log(limit: int = 10) -> str:
|
||||||
"""Журнал обновлений зеркала."""
|
"""Журнал обновлений зеркала."""
|
||||||
runs = _client().get("/sync-runs", limit=limit)["runs"]
|
runs = _client().get("/sync-runs", limit=limit)["runs"]
|
||||||
@ -323,6 +386,7 @@ def sync_log(limit: int = 10) -> str:
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@guard
|
@guard
|
||||||
|
@scoped(False)
|
||||||
def settings_get(key: str = "") -> str:
|
def settings_get(key: str = "") -> str:
|
||||||
"""Настройки хаба (все или одна по ключу)."""
|
"""Настройки хаба (все или одна по ключу)."""
|
||||||
data = _client().get("/settings")
|
data = _client().get("/settings")
|
||||||
@ -331,6 +395,7 @@ def settings_get(key: str = "") -> str:
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@guard
|
@guard
|
||||||
|
@scoped(False)
|
||||||
def settings_set(key: str, value: str) -> str:
|
def settings_set(key: str, value: str) -> str:
|
||||||
"""Записать настройку: value — JSON. Поднимает settings_version."""
|
"""Записать настройку: value — JSON. Поднимает settings_version."""
|
||||||
try:
|
try:
|
||||||
@ -341,5 +406,30 @@ def settings_set(key: str, value: str) -> str:
|
|||||||
return f"settings_version = {out['settings_version']}"
|
return f"settings_version = {out['settings_version']}"
|
||||||
|
|
||||||
|
|
||||||
def run() -> None:
|
@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")
|
mcp.run(transport="stdio")
|
||||||
|
|||||||
@ -64,3 +64,12 @@ def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys):
|
|||||||
monkeypatch.setattr(os, "replace", real)
|
monkeypatch.setattr(os, "replace", real)
|
||||||
assert release.load(paths.release_dir())["version"] == 8
|
assert release.load(paths.release_dir())["version"] == 8
|
||||||
assert sorted(p.name for p in paths.release_dir().iterdir()) == ["agent.py", "manifest.json"]
|
assert sorted(p.name for p in paths.release_dir().iterdir()) == ["agent.py", "manifest.json"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_project_option(monkeypatch):
|
||||||
|
from jira_hub import mcp_server
|
||||||
|
seen = []
|
||||||
|
monkeypatch.setattr(mcp_server, "run", lambda project=None: seen.append(project))
|
||||||
|
assert cli.main(["mcp", "--project", "AWPCA"]) == 0
|
||||||
|
assert cli.main(["mcp"]) == 0
|
||||||
|
assert seen == ["AWPCA", None]
|
||||||
|
|||||||
@ -25,12 +25,14 @@ def live(tmp_path, monkeypatch, add_project):
|
|||||||
base = f"http://127.0.0.1:{srv.server_port}"
|
base = f"http://127.0.0.1:{srv.server_port}"
|
||||||
monkeypatch.setattr(mcp_server, "_client", lambda: HubClient(base, tok["client"]))
|
monkeypatch.setattr(mcp_server, "_client", lambda: HubClient(base, tok["client"]))
|
||||||
monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05)
|
monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05)
|
||||||
yield {"agent": HubClient(base, tok["agent"])}
|
monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA")
|
||||||
|
yield {"agent": HubClient(base, tok["agent"]), "client": HubClient(base, tok["client"])}
|
||||||
srv.shutdown()
|
srv.shutdown()
|
||||||
srv.server_close()
|
srv.server_close()
|
||||||
|
|
||||||
|
|
||||||
def test_offline_message(monkeypatch):
|
def test_offline_message(monkeypatch):
|
||||||
|
monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA")
|
||||||
monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1))
|
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.issues() == mcp_server.OFFLINE
|
||||||
assert mcp_server.agent_status() == mcp_server.OFFLINE
|
assert mcp_server.agent_status() == mcp_server.OFFLINE
|
||||||
@ -168,6 +170,7 @@ def test_async_tools_are_coroutines_and_registered():
|
|||||||
|
|
||||||
|
|
||||||
def test_async_guard_offline(monkeypatch):
|
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))
|
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.batch_wait(1)) == mcp_server.OFFLINE
|
||||||
assert asyncio.run(mcp_server.refresh(full=True)) == mcp_server.OFFLINE
|
assert asyncio.run(mcp_server.refresh(full=True)) == mcp_server.OFFLINE
|
||||||
@ -204,7 +207,9 @@ def test_fmt_batch_warns_about_claimed():
|
|||||||
|
|
||||||
def test_batch_create_mentions_labels():
|
def test_batch_create_mentions_labels():
|
||||||
doc = mcp_server.batch_create.__doc__ or ""
|
doc = mcp_server.batch_create.__doc__ or ""
|
||||||
assert "labels" in doc and "AWPCA" in doc
|
assert "метку проекта сессии хаб дописывает в ensure_issue сам" in doc
|
||||||
|
assert "метки других проектов запрещены" in doc and "board_id для ensure_sprint выставляет хаб" in doc
|
||||||
|
assert "AWPCA" not in doc
|
||||||
|
|
||||||
|
|
||||||
def test_batch_create_description_lists_params_from_spec():
|
def test_batch_create_description_lists_params_from_spec():
|
||||||
@ -221,7 +226,7 @@ def test_batch_create_description_lists_params_from_spec():
|
|||||||
for name, hint in spec.get("hints", {}).items():
|
for name, hint in spec.get("hints", {}).items():
|
||||||
assert f"{name} ({hint})" in line, (ctype, name)
|
assert f"{name} ({hint})" in line, (ctype, name)
|
||||||
assert "target (статус или список-путь)" in desc and "sprint (имя спринта или backlog)" in desc
|
assert "target (статус или список-путь)" in desc and "sprint (имя спринта или backlog)" in desc
|
||||||
assert "labels" in desc and "AWPCA" in desc
|
assert "метку проекта сессии хаб дописывает в ensure_issue сам" in desc and "AWPCA" not in desc
|
||||||
|
|
||||||
|
|
||||||
def test_fmt_agent_update_lines():
|
def test_fmt_agent_update_lines():
|
||||||
@ -312,6 +317,7 @@ def test_batch_create_bug_with_local_file(live, tmp_path):
|
|||||||
|
|
||||||
def test_batch_create_explicit_filename_and_tilde(live, tmp_path, monkeypatch):
|
def test_batch_create_explicit_filename_and_tilde(live, tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("HOME", str(tmp_path))
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], "links": []})
|
||||||
(tmp_path / "скрин.png").write_bytes(b"png")
|
(tmp_path / "скрин.png").write_bytes(b"png")
|
||||||
out = mcp_server.batch_create("скрин", [{"type": "ensure_attachment", "params": {
|
out = mcp_server.batch_create("скрин", [{"type": "ensure_attachment", "params": {
|
||||||
"key": "A-1", "path": "~/скрин.png", "filename": "Экран 1.png"}}])
|
"key": "A-1", "path": "~/скрин.png", "filename": "Экран 1.png"}}])
|
||||||
@ -385,3 +391,102 @@ def test_batch_create_description_mentions_path():
|
|||||||
assert "file (sha256 из POST /files; в MCP — path к локальному файлу)" in desc
|
assert "file (sha256 из POST /files; в MCP — path к локальному файлу)" in desc
|
||||||
assert "issue_type (task|epic|bug)" in desc
|
assert "issue_type (task|epic|bug)" in desc
|
||||||
assert "вместо file передайте path" in desc and "10 МБ" in desc and "filename по умолчанию — имя файла" in desc
|
assert "вместо file передайте path" in desc and "10 МБ" in desc and "filename по умолчанию — имя файла" in desc
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbound_session(live, monkeypatch): # сценарий «Сессия без проекта»
|
||||||
|
monkeypatch.setattr(mcp_server, "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
|
||||||
|
assert calls == []
|
||||||
|
assert "AWPCA" in mcp_server.projects()
|
||||||
|
assert mcp_server.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()
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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": "т"}}])
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
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_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": "с"}}])
|
||||||
|
other_id = int(other.split("#")[1].split()[0])
|
||||||
|
out = mcp_server.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 — без ограничения
|
||||||
|
other_cmd = live["client"].get(f"/batches/{other_id}")["commands"][0]["id"]
|
||||||
|
out = mcp_server.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)
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_create(live, monkeypatch): # сценарий «Проект из чата»
|
||||||
|
monkeypatch.setattr(mcp_server, "PROJECT", None)
|
||||||
|
out = mcp_server.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["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")
|
||||||
|
|
||||||
|
|
||||||
|
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)]
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user