import asyncio import hashlib import json import os import re import threading from pathlib import Path from types import SimpleNamespace import pytest 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 @pytest.fixture def live(tmp_path, monkeypatch, add_project): h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") h.claim_wait = 0 conn = h.conn() add_project(conn) tok = settings.tokens(conn) conn.close() 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(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(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 = jmcp.issues(sprint="Спринт 2") assert "A-1" in out and "Тема раз" in out and "ivanov" in out assert "В работе" in jmcp.notes_set("A-1", fact_status="В работе") detail = jmcp.issue("A-1") assert "fact_status: В работе" in detail and "Готово" in detail 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 = 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(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 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(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 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 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 = 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}) assert "онлайн" in text and "#40" in text and "выдач 2" in text def test_settings_set_bad_json(live): assert jmcp.settings_set("pacing", "{не json").startswith("значение не JSON") def _batch(status): return {"id": 7, "title": "т", "author": "claude", "created_at": "2026-10-11", "status": status, "remaining_seconds": 0, "counts": {status: 1}, "commands": []} class FakeClient: def __init__(self, statuses, runs=None): self.statuses, self.calls, self.runs = list(statuses), 0, runs def get(self, path, **params): if path == "/sync-runs": return {"runs": self.runs} self.calls += 1 return _batch(self.statuses.pop(0) if len(self.statuses) > 1 else self.statuses[0]) def _fake_time(monkeypatch, step=1.0): clock = {"now": 0.0, "sleeps": 0, "deadlines": []} def monotonic(): clock["now"] += step return clock["now"] async def sleep(_): clock["sleeps"] += 1 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(core_mcp, "client", lambda: fake) clock = _fake_time(monkeypatch) 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(core_mcp, "client", lambda: fake) clock = _fake_time(monkeypatch, step=30.0) # 1 минута = 2 шага монотонных часов out = asyncio.run(jmcp.batch_wait(7, timeout_minutes=1)) assert "таймаут ожидания" in out assert 1 < fake.calls < 10 and clock["sleeps"] == fake.calls - 1 @pytest.mark.parametrize("asked,minutes", [(0, 1), (1000, 60)]) def test_batch_wait_clamps_timeout(monkeypatch, asked, minutes): fake = FakeClient(["running"]) monkeypatch.setattr(core_mcp, "client", lambda: fake) clock = _fake_time(monkeypatch, step=60.0) # каждый вызов часов = +1 минута 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(core_mcp, "client", lambda: made.append(1) or fake) _fake_time(monkeypatch) 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(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(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(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(core_mcp, "client", lambda: fake) monkeypatch.setattr(jmcp, "POLL_SECONDS", 0.01) async def scenario(): ticks = [] async def ticker(): for _ in range(5): ticks.append(1) await asyncio.sleep(0.005) out, _ = await asyncio.gather(jmcp.batch_wait(7), ticker()) return out, ticks out, ticks = asyncio.run(scenario()) assert "done" in out and len(ticks) == 5 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 = jmcp.fmt_batch(b) assert "команда #30 уже у агента и будет выполнена" in text and "POST /commands/30/cancel" in text def test_batch_create_mentions_labels(): 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 def test_batch_create_description_lists_params_from_spec(): """Клиент MCP не видит specs/commands: описание инструмента перечисляет параметры каждого типа из SPEC (#44).""" 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}:")) required, _, optional = line.removeprefix(f"- {ctype}:").partition("; [") for name in spec["required"]: assert re.search(rf"\b{name}\b", required), (ctype, name) for name in spec["optional"]: assert re.search(rf"\b{name}\b", optional), (ctype, name) 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 "метку проекта сессии хаб дописывает в ensure_issue сам" in desc and "AWPCA" not in desc 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 = 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 = 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 def test_fmt_agent_launcher_unknown_after_restart(): """M-2 #25, решение 8: agent_reported = false — «неизвестно», а не «без запускателя»; поля нет — прежний текст.""" base = {"online": True, "agent_version": "9", "current_command_id": None, "rate_limited_24h": 0, "redelivered": [], "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 = jmcp.fmt_agent({**base, "agent_reported": False}) assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in text assert "без запускателя" not in text and "агент запущен без run.py" not in text 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 jmcp.agent_status() live["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2}) text = jmcp.agent_status() assert "запускатель: v2" in text and "неизвестно" not in text def _detail(**over): d = {"key": "A-1", "summary": "Т", "labels": [], "links_out": [], "links_in": [], "note": None, "commands": [], "comments": [], "description": None} d.update(over) return d def test_fmt_issue_comments(): 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 = 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 = 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 def test_fmt_comment(): """Один комментарий целиком (#80): переносы строк сохраняются, «изменён» — только если правили.""" body = "первая строка\n" + "т" * 1000 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 = 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—" def test_comment_tool(live, monkeypatch): long = "абв " * 200 + "\nконец" live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "status": "Готово", "labels": ["AWPCA"], "comments": [{"id": "101", "author": "golov", "body": long, "created": "2026-10-20T10:00:00.000+0300"}, {"id": "102", "author": "ivanov", "body": "коротко", "created": "2026-10-21T10:00:00.000+0300"}]}], "links": []}) 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 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 не распознана", "labels": ["AWPCA"]}} def _batch_commands(out): batch_id = int(out.split("#")[1].split()[0]) return core_mcp.client().get(f"/batches/{batch_id}")["commands"] def _write(path, data): path.write_bytes(data) return str(path) def test_batch_create_bug_with_local_file(live, tmp_path): """Спека mcp «Ошибка с файлом из чата»: файл загружен в хаб, path заменён на file и filename.""" 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 = jmcp.batch_create("Ошибка ОСВ 60", [BUG, {"type": "ensure_attachment", "params": {"key": "new:1", "path": str(f)}}]) assert "3 команд" in out cmds = _batch_commands(out) sha = hashlib.sha256(f.read_bytes()).hexdigest() assert [c["type"] for c in cmds] == ["ensure_issue", "ensure_attachment", "refresh"] assert cmds[1]["params"] == {"key": "new:1", "file": sha, "filename": "ОСВ сч. 60 за 2 кв. 2026.xlsx"} assert (tmp_path / "files" / sha).read_bytes() == f.read_bytes() 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 = 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"} @pytest.mark.parametrize("make, expected", [ (lambda d: str(d / "нет.xlsx"), "файл не найден: {p}"), (lambda d: str(d), "не файл: {p}"), (lambda d: "отчёт.xlsx", "нужен абсолютный путь: {p}"), (lambda d: _write(d / "пусто.xlsx", b""), "пустой файл: {p}"), (lambda d: _write(d / "большой.bin", b"\0" * (files.MAX_SIZE + 1)), "файл больше 10 МБ (" + str(files.MAX_SIZE + 1) + " байт): {p}"), ]) def test_batch_create_bad_path(live, tmp_path, make, expected): """Спека mcp «Файла нет» и соседние причины: текст с путём, пакет не создан, в хаб ничего не загружено.""" src = tmp_path / "src" src.mkdir() p = make(src) out = jmcp.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": p}}]) assert out == expected.format(p=p) assert core_mcp.client().get("/batches")["batches"] == [] assert list((tmp_path / "files").iterdir()) == [] def test_batch_create_unreadable_file(live, tmp_path): f = tmp_path / "закрыт.xlsx" f.write_bytes(b"x") f.chmod(0) try: if os.access(f, os.R_OK): pytest.skip("файл читается и без прав (root)") out = jmcp.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(f)}}]) assert out == f"нет прав на чтение: {f}" finally: f.chmod(0o600) def test_batch_create_path_and_file(live, tmp_path): f = tmp_path / "a.xlsx" f.write_bytes(b"a") 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()) == [] 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 = 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}" assert list((tmp_path / "files").iterdir()) == [] def test_batch_create_hub_422_after_upload(live, tmp_path): """Отказ хаба по другой команде — текст ошибки; загруженный файл остаётся до уборки (design, «MCP»).""" f = tmp_path / "a.xlsx" f.write_bytes(b"a") 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 core_mcp.client().get("/batches")["batches"] == [] def test_batch_create_description_mentions_path(): 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(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 (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 (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(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(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 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 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 = jmcp.batch_create("моя", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]) mine_id = int(mine.split("#")[1].split()[0]) 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 = jmcp.batches(limit=50) assert f"пакет #{other_id} " in out and f"пакет #{mine_id} " not in out 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 = jmcp.command_retry(other_cmd) assert out.startswith("ошибка хаба 422") and "не из проекта AWPCA" in out 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(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 == 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 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 (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 = core_mcp.fmt_projects(items, "AWPCA").splitlines() assert out[0] == "метка | название | доска | тип доски | задач" assert out[1] == "AWPCA | АРМ КА | 4216 | scrum | 190 (текущий)" assert out[2] == "STOCKS_DRRK | Запасы ДРРК | — | неизвестен | 0" assert core_mcp.fmt_projects([], None) == "проектов нет" assert "(текущий)" not in core_mcp.fmt_projects(items, None) def test_run_binds_project(monkeypatch): 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) ------------------------------------------------------------------------------ from datetime import datetime, timedelta, timezone # noqa: E402 def _local(at: str) -> datetime: return datetime.fromisoformat(at).astimezone() def _ev(**kw): base = {"at": "2026-10-22T08:15:00+00:00", "key": "X", "summary": "Тема", "status": "Готово", "kind": "status", "field": "status", "author": "Петров Пётр", "from": "В работе", "to": "Готово", "from_value": None, "to_value": None} return {**base, **kw} def test_fmt_event_status(): e = _ev() hm = _local(e["at"]).strftime("%H:%M") 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 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 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 jmcp.fmt_event(e, None).endswith(": плановое окончание сырое → 30.10") def test_fmt_event_kinds(): assert jmcp.fmt_event(_ev(kind="description", field="description"), None).endswith(": описание изменено") long = "к" * 250 out = jmcp.fmt_event(_ev(kind="comment", field="comment", **{"from": None, "to": long}), None) assert out.endswith(": комментарий: " + "к" * 199 + "…") out = jmcp.fmt_event(_ev(kind="other", field="assignee", **{"from": None, "to": "ivanov"}), None) assert out.endswith(": assignee: «—» → «ivanov»") def test_fmt_changes_grouped(): now = datetime(2026, 10, 22, 14, 5, tzinfo=timezone.utc).astimezone() data = {"since": "2026-10-21T21:00:00+00:00", "until": "2026-10-22T11:05:00+00:00", "truncated": True, "changes": [ _ev(at="2026-10-22T09:00:00+00:00", key="Y", summary="Вторая", status="В работе"), _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 = 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 Тема — Готово" assert lines[-1] == "показаны последние 3 событий, сузьте период" def test_fmt_changes_empty(): 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] == "за период изменений нет" def _history(live, key, n, created="2026-10-22T08:00:00Z", **kw): items = [{"history_id": f"{key}{i}", "seq": 0, "key": key, "created": created, "author": "Петров", "field_id": "status", "field_name": "status", "from": None, "from_string": "a", "to": None, "to_string": f"s{i}", **kw} for i in range(n)] live["agent"].post("/agent/history", {"changes": items}) def test_changes_today(live): # «Что закрылось сегодня» / «За два часа» live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема", "status": "Готово", "labels": ["AWPCA"]}], "links": []}) 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 = jmcp.changes(kinds="status") assert "A-1 Тема — Готово" in out and "статус «a» → «s0»" in out and "B-1" not in out 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 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 jmcp.changes(since="вчера").startswith("ошибка хаба 422") def test_changes_naive_since_is_local(live): # M-2: время без смещения — местное 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 jmcp.changes(hours=1).split("\n")[1] == "за период изменений нет" def test_changes_unbound(live, monkeypatch): 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 jmcp.issue("A-1") _history(live, "A-1", 7) 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(jmcp.refresh(full=True)) cmd = live["agent"].post("/agent/claim", {})["command"] 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", jmcp.sync_log()) # --- вложения (#83) ----------------------------------------------------------------- ATT_KEY = "AURORA-1650" def _att(aid, name, size, state="none", **over): d = {"attachment_id": aid, "filename": name, "size": size, "mime_type": "image/png", "author": "golov", "created": "2026-10-20T10:00:00.000+0300", "state": state, "error": None} d.update(over) return d class FakeHub: """Фейковый HubClient: журнал вызовов и сценарий ответов по маршрутам.""" def __init__(self, issue, fetch=None, polls=None, data=b"x" * 10, online=True): self.issue, self.fetch, self.polls, self.data, self.online = issue, fetch, polls or [], data, online self.calls = [] def get(self, path, **params): self.calls.append(("GET", path)) if path == f"/issues/{ATT_KEY}": return self.issue if path == "/agent": return {"online": self.online} if path.startswith("/attachments/"): return self.polls.pop(0) if len(self.polls) > 1 else self.polls[0] raise AssertionError(path) def post(self, path, body=None): self.calls.append(("POST", path, body)) if isinstance(self.fetch, Exception): raise self.fetch return self.fetch def get_bytes(self, path): self.calls.append(("BYTES", path)) return self.data @pytest.fixture def downloads(tmp_path, monkeypatch): monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA") monkeypatch.setattr(jmcp, "POLL_SECONDS", 0.05) return tmp_path / "home" / "downloads" def _use(monkeypatch, hub): monkeypatch.setattr(core_mcp, "client", lambda: hub) monkeypatch.setattr(core_mcp, "unbound_or_unknown", lambda required: None) def _issue_with_att(): return _detail(key=ATT_KEY, description="см. !скрин 1.png!", description_attachment_ids=["31337"], comments=[{"comment_id": "101", "author": "golov", "created": "2026-10-20T10:00:00.000+0300", "updated": None, "body": "!скрин 1.png!", "attachment_ids": ["31337"]}], attachments=[_att("31338", "запись.mp4", 26214400, mime_type="video/mp4"), _att("31337", "скрин 1.png", 239821, state="ready")]) def test_issue_attachments_block(): 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 jmcp.fmt_issue(_detail(attachments=[])) assert "вложений нет" in jmcp.fmt_issue(_detail()) def test_comment_attachments(): d = _issue_with_att() text = jmcp.fmt_comment(ATT_KEY, d["comments"][0], d["attachments"]) assert text.endswith("вложения:\n #31337 скрин 1.png (234,2 КБ)") 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 = 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(jmcp.safe_name("ф" * 300, "7").encode()) <= 255 assert jmcp.safe_name("короткое.png", "7") == "короткое.png" def test_attachment_long_cyrillic_name_written(downloads, monkeypatch): name = "я" * 200 + ".png" 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(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 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): att = _att("31337", "скрин 1.png", size, state="ready") return FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch={"state": "ready", "command_id": 5}, polls=[att], data=data) def test_attachment_by_id_ready(downloads, monkeypatch): hub = _ready_hub() _use(monkeypatch, hub) 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" assert path.read_bytes() == b"PNG" * 3 assert (downloads.stat().st_mode & 0o777) == 0o700 and ((downloads / "31337").stat().st_mode & 0o777) == 0o700 assert ("POST", "/attachments/31337/fetch", {"author": "claude"}) in hub.calls def test_attachment_by_name_ambiguous(downloads, monkeypatch): a20, a10 = _att("20", "скрин.png", 3), _att("10", "скрин.png", 3) # новые сверху, как отдаёт хаб 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(jmcp.attachment(ATT_KEY, "скрин.png")) assert "у AURORA-1650 2 вложений «скрин.png», взято самое новое #20" in out assert str(downloads / "20" / "скрин.png") in out 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(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) def test_attachment_wait_timeout(downloads, monkeypatch): att = _att("1", "a.png", 5) 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(jmcp.attachment(ATT_KEY, "1", wait_seconds=1)) assert out == "скачивание ещё идёт (команда #9, агент онлайн); вызовите attachment ещё раз — вторая команда не поставится" def test_attachment_failed(downloads, monkeypatch): att = _att("1", "a.png", 5) 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(jmcp.attachment(ATT_KEY, "1")) == "не удалось скачать: HTTP 404" def test_attachment_413(downloads, monkeypatch): from jira_hub.core.client import HubError att = _att("5", "запись.mp4", 26214400) hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=HubError(413, "too_large", "большой")) _use(monkeypatch, hub) assert asyncio.run(jmcp.attachment(ATT_KEY, "#5")) == \ "файл больше 10 МБ (25,0 МБ) — скачайте в Jira: задача AURORA-1650, вложение «запись.mp4»" def test_attachment_409_passes_hub_text(downloads, monkeypatch): from jira_hub.core.client import HubError 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(jmcp.attachment(ATT_KEY, "5")) def test_attachment_existing_copy_same_size(downloads, monkeypatch): hub = _ready_hub(data=b"PNG" * 3) _use(monkeypatch, hub) path = downloads / "31337" / "скрин 1.png" path.parent.mkdir(parents=True) 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(jmcp.attachment(ATT_KEY, "31337")) assert path.read_bytes() == b"OLD" * 3 assert path.parent.stat().st_mtime > 1_000_000 + 100 # M-6: папка «освежена» — уборка её не заденет