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` в пакете, без
|
||||
обращения к зеркалу.** Обоснование: `new:<n>` в зеркале — не задача Jira; метки пакета (с дописанной меткой проекта)
|
||||
единственный надёжный источник. Цена ошибки: ветка в `_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 = sub.add_parser("import", help="импорт CSV-пакета admin/jira-sync в заметки")
|
||||
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="показать токены агента и клиента")
|
||||
p = sub.add_parser("agent", help="версия агента для самообновления")
|
||||
agent_sub = p.add_subparsers(dest="agent_cmd", required=True)
|
||||
@ -46,7 +47,7 @@ def main(argv=None) -> int:
|
||||
return 0
|
||||
if args.cmd == "mcp":
|
||||
from . import mcp_server
|
||||
mcp_server.run()
|
||||
mcp_server.run(args.project)
|
||||
return 0
|
||||
|
||||
conn = db.connect(paths.db_path())
|
||||
|
||||
@ -12,16 +12,28 @@ from pathlib import Path
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from .client import HubClient, HubError, HubUnavailable
|
||||
from .commands import describe
|
||||
from .commands import describe, is_temp
|
||||
from .files import MAX_SIZE
|
||||
|
||||
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[2] # корень репозитория хаба, из которого загружен пакет (решение 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()
|
||||
|
||||
@ -54,6 +66,33 @@ def guard(fn):
|
||||
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):
|
||||
@ -65,6 +104,16 @@ 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 "задач нет"
|
||||
@ -137,17 +186,19 @@ def fmt_agent(a) -> str:
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
@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", 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)
|
||||
return fmt_issues(data["issues"])
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
@scoped(True)
|
||||
def issue(key: str) -> str:
|
||||
"""Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание."""
|
||||
return fmt_issue(_client().get(f"/issues/{key}"))
|
||||
@ -155,12 +206,16 @@ def issue(key: str) -> str:
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
@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
|
||||
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)
|
||||
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()
|
||||
@guard
|
||||
@scoped(True)
|
||||
@_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам.
|
||||
Параметры по типам (необязательные — в [ ]):
|
||||
""" + describe() + """
|
||||
ensure_issue передавайте с labels, включающими базовую метку проекта (AWPCA): хаб и агент метки сами не добавляют,
|
||||
задача без неё выпадет из базовой JQL и из зеркала.
|
||||
Проект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб.
|
||||
ensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит
|
||||
файл в хаб и подставит file, filename по умолчанию — имя файла.""")
|
||||
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)
|
||||
if isinstance(prepared, str):
|
||||
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)} мин"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
@scoped(False)
|
||||
def batch_status(id: int) -> str:
|
||||
"""Состояние пакета и его команд."""
|
||||
return fmt_batch(_client().get(f"/batches/{id}"))
|
||||
@ -257,6 +313,7 @@ def batch_status(id: int) -> str:
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
@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
|
||||
@ -270,22 +327,25 @@ async def batch_wait(id: int, timeout_minutes: int = 10) -> str:
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
@scoped(False)
|
||||
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 "пакетов нет"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
@scoped(True)
|
||||
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']} команд"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
@scoped(False)
|
||||
def batch_cancel(id: int) -> str:
|
||||
"""Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает —
|
||||
в ответе подсказка (POST /commands/N/cancel с force)."""
|
||||
@ -294,6 +354,7 @@ def batch_cancel(id: int) -> str:
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
@scoped(True)
|
||||
async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool = False) -> str:
|
||||
"""Обновить зеркало из Jira: по ключам или полностью (базовая JQL). По умолчанию только ставит пакет;
|
||||
wait=true — дождаться результата (до 30 мин)."""
|
||||
@ -305,6 +366,7 @@ async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
@scoped(False)
|
||||
def agent_status() -> str:
|
||||
"""Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия,
|
||||
состояние самообновления и запускатель."""
|
||||
@ -313,6 +375,7 @@ def agent_status() -> str:
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
@scoped(False)
|
||||
def sync_log(limit: int = 10) -> str:
|
||||
"""Журнал обновлений зеркала."""
|
||||
runs = _client().get("/sync-runs", limit=limit)["runs"]
|
||||
@ -323,6 +386,7 @@ def sync_log(limit: int = 10) -> str:
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
@scoped(False)
|
||||
def settings_get(key: str = "") -> str:
|
||||
"""Настройки хаба (все или одна по ключу)."""
|
||||
data = _client().get("/settings")
|
||||
@ -331,6 +395,7 @@ def settings_get(key: str = "") -> str:
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
@scoped(False)
|
||||
def settings_set(key: str, value: str) -> str:
|
||||
"""Записать настройку: value — JSON. Поднимает settings_version."""
|
||||
try:
|
||||
@ -341,5 +406,30 @@ def settings_set(key: str, value: str) -> str:
|
||||
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")
|
||||
|
||||
@ -64,3 +64,12 @@ def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setattr(os, "replace", real)
|
||||
assert release.load(paths.release_dir())["version"] == 8
|
||||
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}"
|
||||
monkeypatch.setattr(mcp_server, "_client", lambda: HubClient(base, tok["client"]))
|
||||
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.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
|
||||
@ -168,6 +170,7 @@ def test_async_tools_are_coroutines_and_registered():
|
||||
|
||||
|
||||
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
|
||||
@ -204,7 +207,9 @@ def test_fmt_batch_warns_about_claimed():
|
||||
|
||||
def test_batch_create_mentions_labels():
|
||||
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():
|
||||
@ -221,7 +226,7 @@ def test_batch_create_description_lists_params_from_spec():
|
||||
for name, hint in spec.get("hints", {}).items():
|
||||
assert f"{name} ({hint})" in line, (ctype, name)
|
||||
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():
|
||||
@ -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):
|
||||
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": {
|
||||
"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 "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)
|
||||
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