Каркас модуля wiki: схема (wiki_project, wiki_page, wiki_attachment, wiki_sync_stage), настройки, поле проекта wiki_root с хуками создания и правки, команды wiki_sync и wiki_fetch, фоновая синхронизация. Зависимость markdownify>=1.2.3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
263 lines
13 KiB
Python
263 lines
13 KiB
Python
"""Агенты модулей: токен определяет модуль, миграция v7 сохраняет агента jira (wiki-module, решения 1–3, 6, 15)."""
|
||
import os
|
||
|
||
import pytest
|
||
|
||
from jira_hub.core import api, cli, db, queue, release, settings
|
||
from jira_hub.core.client import HubError
|
||
from v6db import make_v6_db
|
||
|
||
|
||
def test_v6_upgrade_keeps_jira_agent(tmp_path):
|
||
make_v6_db(tmp_path / "hub.sqlite")
|
||
rel = tmp_path / "release"
|
||
rel.mkdir()
|
||
(rel / "agent.py").write_text("AGENT_VERSION = 14\n", encoding="utf-8")
|
||
release.publish(rel, rel / "agent.py")
|
||
hub = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
|
||
hub.claim_wait = 0
|
||
conn = db.connect(tmp_path / "hub.sqlite")
|
||
try:
|
||
tok = settings.tokens(conn)["agents"]["jira"]
|
||
assert tok == "T-agent"
|
||
code, body = api.h_claim(hub, conn, None, {"_agent": "jira"}, {"agent_version": 14})
|
||
assert code == 200 and body["agent_release"]["version"] == 14 # релиз v14 перенесён в release/jira/
|
||
assert db._schema_version(conn) == 7
|
||
tok = settings.tokens(conn)
|
||
assert tok["client"] == "T-client" and tok["agents"]["jira"] == "T-agent" and "agent" not in tok
|
||
row = conn.execute("SELECT * FROM agent_status WHERE agent = 'jira'").fetchone()
|
||
assert row["agent_version"] == "14"
|
||
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 2 # jira и wiki
|
||
assert list((tmp_path / "backups").glob("*schema-v6*")) # копия перед миграцией
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def test_v6_upgrade_keeps_settings_and_projects(tmp_path):
|
||
make_v6_db(tmp_path / "hub.sqlite")
|
||
conn = db.connect(tmp_path / "hub.sqlite")
|
||
try:
|
||
assert settings.get(conn, "min_agent_version") == 1
|
||
rows = conn.execute("SELECT label, board_id, board_type FROM jira_project ORDER BY label").fetchall()
|
||
assert [tuple(r) for r in rows] == [("AWPCA", 4216, "scrum"), ("sfm_kedr", None, None)]
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def test_migrate_adds_status_row_for_new_agent_module(conn_two_modules):
|
||
"""R-1: строки agent_status для модулей реестра вставляются при каждом migrate, а не только в шаге 6 → 7."""
|
||
names = {r["agent"] for r in conn_two_modules.execute("SELECT agent FROM agent_status")}
|
||
assert names == {"jira", "stub"}
|
||
|
||
|
||
def test_ensure_defaults_adds_missing_agent_token(conn_two_modules):
|
||
tok = settings.tokens(conn_two_modules)
|
||
assert set(tok["agents"]) == {"jira", "stub"} and tok["agents"]["jira"] != tok["agents"]["stub"]
|
||
|
||
|
||
def test_agent_token_routes(hub_server):
|
||
assert hub_server["agent"].post("/agent/claim", {})["command"] is None
|
||
assert hub_server["agent"].get("/agent/settings")["settings_version"] >= 1
|
||
with pytest.raises(HubError) as e:
|
||
hub_server["client"].post("/agent/claim", {})
|
||
assert e.value.status == 401
|
||
|
||
|
||
def test_module_agent_route_requires_own_module(hub_server_two_modules):
|
||
jira, stub = hub_server_two_modules["agents"]["jira"], hub_server_two_modules["agents"]["stub"]
|
||
with pytest.raises(HubError) as e:
|
||
stub.post("/agent/state", {})
|
||
assert e.value.status == 401
|
||
with pytest.raises(HubError) as e:
|
||
jira.post("/agent/stub", {})
|
||
assert e.value.status == 401
|
||
assert stub.post("/agent/stub", {}) == {"agent": "stub"}
|
||
assert jira.post("/agent/claim", {})["command"] is None # маршруты ядра открыты обоим агентам
|
||
assert stub.post("/agent/claim", {})["command"] is None
|
||
|
||
|
||
def test_agent_settings_per_module(hub_server_two_modules):
|
||
jira = hub_server_two_modules["agents"]["jira"].get("/agent/settings")
|
||
stub = hub_server_two_modules["agents"]["stub"].get("/agent/settings")
|
||
assert "project_key" in jira and "transitions_map" in jira and "settings_version" in jira
|
||
assert "stub_key" not in jira and "board_id" not in jira
|
||
assert set(stub) == {"stub_key", "settings_version"}
|
||
|
||
|
||
def test_agent_query_cannot_be_forged(hub_server_two_modules):
|
||
"""Служебный ключ запроса приходит только от диспетчера: _agent в адресе перезаписывается."""
|
||
stub = hub_server_two_modules["agents"]["stub"]
|
||
assert stub.call("POST", "/agent/stub", {}, params={"_agent": "jira"}) == {"agent": "stub"}
|
||
|
||
|
||
def test_tokens_cli_lists_agents(tmp_path, monkeypatch, capsys):
|
||
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path))
|
||
assert cli.main(["tokens"]) == 0
|
||
lines = capsys.readouterr().out.splitlines()
|
||
assert lines[0].startswith("клиентский токен: ") and lines[1].startswith("агент jira: ")
|
||
assert lines[2].startswith("агент wiki: ") and lines[3].startswith("база: ")
|
||
|
||
|
||
def test_agent_view_min_version_from_module_key(conn_two_modules):
|
||
conn_two_modules.execute("UPDATE agent_status SET agent_version = '3' WHERE agent = 'jira'")
|
||
settings.set_(conn_two_modules, "min_agent_version", 5)
|
||
assert queue.agent_view(conn_two_modules, "jira")["warning"] == "обновите агента"
|
||
assert queue.agent_view(conn_two_modules, "stub")["warning"] is None
|
||
|
||
|
||
# --- очередь, релиз и статус по агенту (Task 2) ------------------------------------------------------------------
|
||
|
||
def _two_batches(conn):
|
||
queue.create_batch(conn, "w", "t", [{"type": "stub_cmd", "params": {}}])
|
||
queue.create_batch(conn, "j", "t", [{"type": "ensure_issue",
|
||
"params": {"temp_key": "new:1", "issue_type": "task", "summary": "Тема"}}])
|
||
|
||
|
||
def test_wiki_queue_does_not_block_jira(conn_two_modules):
|
||
conn = conn_two_modules
|
||
_two_batches(conn)
|
||
s = queue.claim(conn, "stub")
|
||
assert s["type"] == "stub_cmd"
|
||
j = queue.claim(conn, "jira") # stub_cmd ещё claimed, но это очередь другого агента
|
||
assert j["type"] == "ensure_issue"
|
||
assert queue.agent_view(conn, "stub")["current_command_id"] == s["id"]
|
||
assert queue.agent_view(conn, "jira")["current_command_id"] == j["id"]
|
||
|
||
|
||
def test_redeliver_only_own(conn_two_modules):
|
||
conn = conn_two_modules
|
||
_two_batches(conn)
|
||
s = queue.claim(conn, "stub")
|
||
j = queue.claim(conn, "jira")
|
||
assert queue.claim(conn, "jira")["id"] == j["id"] # довыдача — своя команда, не stub_cmd
|
||
assert queue.claim(conn, "stub")["id"] == s["id"]
|
||
assert queue.agent_view(conn, "jira")["redelivered"][0]["id"] == j["id"]
|
||
assert [r["id"] for r in queue.agent_view(conn, "stub")["redelivered"]] == [s["id"]]
|
||
|
||
|
||
def test_agent_version_per_agent(conn_two_modules):
|
||
conn = conn_two_modules
|
||
queue.claim(conn, "jira", agent_version=14)
|
||
assert queue.agent_version(conn, "jira") == "14" and queue.agent_version(conn, "stub") is None
|
||
queue.claim(conn, "stub", agent_version=1)
|
||
assert queue.agent_version(conn, "jira") == "14" and queue.agent_version(conn, "stub") == "1"
|
||
|
||
|
||
def test_has_active_per_module(conn_two_modules):
|
||
conn = conn_two_modules
|
||
assert not queue.has_active(conn)
|
||
queue.create_batch(conn, "w", "t", [{"type": "stub_cmd", "params": {}}])
|
||
assert queue.has_active(conn) and queue.has_active(conn, "stub") and not queue.has_active(conn, "jira")
|
||
|
||
|
||
def _publish(root, module, tmp_path, version):
|
||
src = tmp_path / f"{module}{version}.py"
|
||
src.write_text(f"AGENT_VERSION = {version}\n", encoding="utf-8")
|
||
return release.publish(release.module_dir(root, module), src)
|
||
|
||
|
||
def test_release_per_module(hub_server_two_modules, tmp_path):
|
||
root = hub_server_two_modules["hub"].release_root
|
||
_publish(root, "jira", tmp_path, 14)
|
||
_publish(root, "stub", tmp_path, 1)
|
||
assert release.module_dir(root, "stub") == root / "stub" and oct(release.module_dir(root, "stub").stat().st_mode & 0o777) == "0o700"
|
||
jira, stub = hub_server_two_modules["agents"]["jira"], hub_server_two_modules["agents"]["stub"]
|
||
assert jira.post("/agent/claim", {})["agent_release"]["version"] == 14
|
||
assert stub.post("/agent/claim", {})["agent_release"]["version"] == 1
|
||
assert jira.get("/agent/release")["version"] == 14 and stub.get("/agent/release")["version"] == 1
|
||
|
||
|
||
def test_release_layout_migration(tmp_path):
|
||
root = tmp_path / "release"
|
||
root.mkdir()
|
||
(root / "agent.py").write_text("AGENT_VERSION = 14\n", encoding="utf-8")
|
||
release.publish(root, root / "agent.py") # старая раскладка: agent.py и manifest.json в корне
|
||
hub = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
|
||
assert not (root / "agent.py").exists() and not (root / "manifest.json").exists()
|
||
assert release.current(hub.release_root / "jira")["version"] == 14
|
||
api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") # повторный старт ничего не ломает
|
||
assert release.current(root / "jira")["version"] == 14
|
||
|
||
|
||
def test_migrate_layout_failure_is_warning(tmp_path, monkeypatch, caplog):
|
||
root = tmp_path / "release"
|
||
root.mkdir()
|
||
(root / "agent.py").write_text("AGENT_VERSION = 14\n", encoding="utf-8")
|
||
real = os.replace
|
||
monkeypatch.setattr("os.replace", lambda *a, **k: (_ for _ in ()).throw(OSError(13, "нет прав")))
|
||
release.migrate_layout(root) # без исключения
|
||
assert any("release" in r.getMessage() for r in caplog.records)
|
||
assert (root / "agent.py").exists()
|
||
monkeypatch.setattr("os.replace", real)
|
||
|
||
|
||
def test_migrate_layout_resumes_after_partial_failure(tmp_path, monkeypatch):
|
||
root = tmp_path / "release"
|
||
root.mkdir()
|
||
(root / "agent.py").write_text("AGENT_VERSION = 14\n", encoding="utf-8")
|
||
release.publish(root, root / "agent.py")
|
||
real, calls = os.replace, []
|
||
|
||
def flaky(a, b, *x, **k):
|
||
calls.append(a)
|
||
if len(calls) == 2:
|
||
raise OSError(5, "сбой")
|
||
return real(a, b, *x, **k)
|
||
|
||
monkeypatch.setattr("os.replace", flaky)
|
||
release.migrate_layout(root)
|
||
assert (root / "jira" / "agent.py").exists() and not (root / "jira" / "manifest.json").exists()
|
||
assert (root / "manifest.json").exists() and not (root / "agent.py").exists()
|
||
monkeypatch.setattr("os.replace", real)
|
||
release.migrate_layout(root) # повторный старт доделывает
|
||
assert release.current(root / "jira")["version"] == 14
|
||
assert not (root / "manifest.json").exists() and not (root / "agent.py").exists()
|
||
|
||
|
||
def test_release_dirs_are_private(tmp_path):
|
||
root = tmp_path / "release"
|
||
root.mkdir(mode=0o755)
|
||
(root / "jira").mkdir(mode=0o755)
|
||
api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
|
||
assert root.stat().st_mode & 0o777 == 0o700 and (root / "jira").stat().st_mode & 0o777 == 0o700
|
||
|
||
|
||
def test_publish_unknown_module(tmp_path, monkeypatch, capsys):
|
||
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
|
||
src = tmp_path / "agent.py"
|
||
src.write_text("AGENT_VERSION = 1\n", encoding="utf-8")
|
||
assert cli.main(["agent", "publish", "--module", "nope", str(src)]) == 2
|
||
assert "неизвестный модуль nope; модули с агентами: jira" in capsys.readouterr().err
|
||
|
||
|
||
def test_publish_module_option(tmp_path, monkeypatch, capsys):
|
||
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
|
||
src = tmp_path / "agent.py"
|
||
src.write_text("AGENT_VERSION = 1\n", encoding="utf-8")
|
||
assert cli.main(["agent", "publish", "--module", "jira", str(src)]) == 0
|
||
assert (tmp_path / "home" / "release" / "jira" / "manifest.json").exists()
|
||
|
||
|
||
def test_agents_endpoint(hub_server_two_modules):
|
||
c = hub_server_two_modules["client"]
|
||
agents = c.get("/agents")["agents"]
|
||
assert [a["module"] for a in agents] == ["jira", "stub"]
|
||
assert c.get("/agent")["module"] == "jira" and c.get("/agent", module="stub")["module"] == "stub"
|
||
with pytest.raises(HubError) as e:
|
||
c.get("/agent", module="nope")
|
||
assert e.value.status == 404 and e.value.error == "not_found"
|
||
|
||
|
||
def test_wiki_agent_token_after_upgrade(tmp_path):
|
||
"""Миграция v6: токен jira сохранён, токен wiki создан; строка agent_status есть у обоих."""
|
||
make_v6_db(tmp_path / "hub.sqlite")
|
||
conn = db.connect(tmp_path / "hub.sqlite")
|
||
try:
|
||
settings.ensure_defaults(conn)
|
||
agents = settings.tokens(conn)["agents"]
|
||
assert agents["jira"] == "T-agent" and agents["wiki"] and agents["wiki"] != agents["jira"]
|
||
names = {r["agent"] for r in conn.execute("SELECT agent FROM agent_status")}
|
||
assert names == {"jira", "wiki"}
|
||
finally:
|
||
conn.close()
|