From 43c43ade815b14c61d1516052a00527b37d36050 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 00:45:44 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20v7:=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D1=8B=20=D0=B8=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20=D0=BF=D0=BE=20=D0=B0?= =?UTF-8?q?=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=D0=BC=20=D0=BC=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BB=D0=B5=D0=B9=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tokens -> {client, agents: {модуль: токен}}, agent_status по агентам (ключ agent, last_call_at), настройки агента и маршруты agent-scope по модулю токена, min_agent_version переехал в модуль jira. Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/core/api.py | 35 +++++-- src/jira_hub/core/cli.py | 7 +- src/jira_hub/core/db.py | 44 ++++++++- src/jira_hub/core/http.py | 8 ++ src/jira_hub/core/modules.py | 1 + src/jira_hub/core/queue.py | 37 +++++--- src/jira_hub/core/registry.py | 5 + src/jira_hub/core/settings.py | 45 +++++---- src/jira_hub/modules/jira/__init__.py | 1 + src/jira_hub/modules/jira/settings.py | 9 +- tests/conftest.py | 83 +++++++++++++++- tests/fixtures/baseline/schema.expected.json | 4 +- .../fixtures/baseline/settings.expected.json | 1 - tests/fixtures/schema_v6.json | 95 +++++++++++++++++++ tests/fixtures/settings_v6.json | 63 ++++++++++++ tests/test_agents.py | 93 ++++++++++++++++++ tests/test_api.py | 6 +- tests/test_baseline.py | 2 +- tests/test_cli.py | 2 +- tests/test_db.py | 16 ++-- tests/test_e2e_hub.py | 2 +- tests/test_mcp.py | 2 +- tests/test_queue.py | 6 +- tests/test_relay.py | 4 +- tests/test_settings.py | 14 +-- tests/v6db.py | 28 ++++++ 26 files changed, 533 insertions(+), 80 deletions(-) create mode 100644 tests/fixtures/schema_v6.json create mode 100644 tests/fixtures/settings_v6.json create mode 100644 tests/test_agents.py create mode 100644 tests/v6db.py diff --git a/src/jira_hub/core/api.py b/src/jira_hub/core/api.py index 570b221..0be83fa 100644 --- a/src/jira_hub/core/api.py +++ b/src/jira_hub/core/api.py @@ -15,6 +15,7 @@ from urllib.parse import parse_qs, unquote, urlparse from . import backup, db, files, projects, queue, registry, relay, release, settings from .errors import CommandError, HttpMapped +from . import http from .http import ApiError, Raw, int_group, int_param, mapped, not_found from .paths import backups_dir as default_backups_dir from .paths import secure_dir @@ -117,8 +118,9 @@ def h_claim(hub, conn, m, q, body): hub.note_agent_update(body) deadline = time.monotonic() + hub.claim_wait while True: - cmd = queue.claim(conn, body.get("agent_version"), body.get("settings_version"), body.get("last_jira_call_at"), - backup=hub.make_backup, files_dir=hub.files_dir) + cmd = queue.claim(conn, body.get("agent_version"), body.get("settings_version"), + body.get("last_call_at", body.get("last_jira_call_at")), + backup=hub.make_backup, files_dir=hub.files_dir, agent=http.agent_of(q)) if cmd or time.monotonic() >= deadline: break time.sleep(1) @@ -148,7 +150,7 @@ def h_agent_file(hub, conn, m, q, body): def h_agent_settings(hub, conn, m, q, body): - return 200, settings.for_agent(conn) + return 200, settings.for_agent(conn, http.agent_of(q)) def h_agent_command(hub, conn, m, q, body): @@ -384,7 +386,9 @@ CORE_ROUTES = [ # маршрут ядра и модуля не делят метод + шаблон: пересечение — ошибка старта; ROUTES собирается после проверки registry.check(registry.modules(), CORE_ROUTES, settings.CORE_DEFAULTS) ROUTES = CORE_ROUTES + [r for mod in registry.modules() for r in mod.routes] -_COMPILED = [(m, re.compile(p), fn, scope) for m, p, fn, scope in ROUTES] +# (метод, regex, обработчик, scope, модуль-владелец маршрута; None — маршрут ядра) +_COMPILED = [(m, re.compile(p), fn, scope, None) for m, p, fn, scope in CORE_ROUTES] + \ + [(m, re.compile(p), fn, scope, mod.name) for mod in registry.modules() for m, p, fn, scope in mod.routes] class Handler(BaseHTTPRequestHandler): @@ -403,16 +407,27 @@ class Handler(BaseHTTPRequestHandler): def log_message(self, fmt, *args): # тише стандартного: без адреса клиента print(f"[{self.log_date_time_string()}] {fmt % args}") - def _auth(self, scope): + def _auth(self, scope, owner=None) -> str | None: + """Проверить токен для scope маршрута. Для agent возвращает модуль агента по токену; маршрут модуля пускает + только агента этого модуля. Для client возвращает None.""" token = (self.headers.get("Authorization") or "").removeprefix("Bearer ").strip() conn = self.hub.conn() try: tokens = settings.tokens(conn) finally: conn.close() - expected = tokens["agent" if scope == "agent" else "client"] - if not token or not hmac.compare_digest(token.encode("utf-8"), expected.encode("utf-8")): + raw = token.encode("utf-8") + if scope != "agent": + if not token or not hmac.compare_digest(raw, tokens["client"].encode("utf-8")): + raise ApiError(401, "unauthorized", "неверный токен для этого маршрута") + return None + found = None + for name, expected in tokens["agents"].items(): # сравниваем со всеми: время не зависит от совпавшего + if token and hmac.compare_digest(raw, expected.encode("utf-8")): + found = name + if found is None or (owner is not None and owner != found): raise ApiError(401, "unauthorized", "неверный токен для этого маршрута") + return found def _dispatch(self, method): url = urlparse(self.path) @@ -425,14 +440,16 @@ class Handler(BaseHTTPRequestHandler): if length > MAX_BODY: raise ApiError(413, "too_large", f"тело больше {MAX_BODY} байт") raw = self.rfile.read(length) if length else b"" - for route_method, rx, fn, scope in _COMPILED: + for route_method, rx, fn, scope, owner in _COMPILED: if route_method != method: continue match = rx.match(url.path) if not match: continue if scope is not None: - self._auth(scope) + agent = self._auth(scope, owner) + if agent is not None: + query[http.AGENT] = agent # перезаписывает значение из адреса try: body = json.loads(raw.decode("utf-8")) if raw else {} except ValueError as e: # битый JSON или не UTF-8 diff --git a/src/jira_hub/core/cli.py b/src/jira_hub/core/cli.py index 93b6d6d..734ea51 100644 --- a/src/jira_hub/core/cli.py +++ b/src/jira_hub/core/cli.py @@ -22,7 +22,7 @@ def main(argv=None) -> int: for m in registry.modules(): if m.cli_register: m.cli_register(sub) - sub.add_parser("tokens", help="показать токены агента и клиента") + sub.add_parser("tokens", help="показать токены клиента и агентов") p = sub.add_parser("agent", help="версия агента для самообновления") agent_sub = p.add_subparsers(dest="agent_cmd", required=True) p = agent_sub.add_parser("publish", help="опубликовать agent.py (версия только вверх)") @@ -84,7 +84,10 @@ def main(argv=None) -> int: def _print_tokens(conn) -> None: tok = settings.tokens(conn) - print(f"агентский токен: {tok['agent']}\nклиентский токен: {tok['client']}\nбаза: {paths.db_path()}") + print(f"клиентский токен: {tok['client']}") + for m in registry.agent_modules(): + print(f"агент {m.name}: {tok['agents'][m.name]}") + print(f"база: {paths.db_path()}") if __name__ == "__main__": diff --git a/src/jira_hub/core/db.py b/src/jira_hub/core/db.py index 094c5ed..70ba84b 100644 --- a/src/jira_hub/core/db.py +++ b/src/jira_hub/core/db.py @@ -8,7 +8,7 @@ from pathlib import Path from . import backup, paths -SCHEMA_VERSION = 6 +SCHEMA_VERSION = 7 CORE_SCHEMA = """ CREATE TABLE IF NOT EXISTS setting ( @@ -50,12 +50,12 @@ CREATE TABLE IF NOT EXISTS project ( created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS agent_status ( - id INTEGER PRIMARY KEY CHECK (id = 1), + agent TEXT PRIMARY KEY, last_seen_at TEXT, agent_version TEXT, settings_version INTEGER, current_command_id INTEGER, - last_jira_call_at TEXT + last_call_at TEXT ); """ @@ -69,6 +69,39 @@ def _fill_comment_utc(conn) -> None: history.fill_comment_utc(conn) +def _tokens_v7(conn) -> None: + """tokens {agent, client} → {client, agents: {jira: agent}}: прежний агентский токен остаётся токеном агента jira + (jira-agent v14 работает без обновления). Имя jira — данные истории, как в _migrate_projects.""" + row = conn.execute("SELECT value FROM setting WHERE key = 'tokens'").fetchone() + if row is None: + return + tokens = json.loads(row["value"]) + if "agents" in tokens or "agent" not in tokens: + return + new = {"client": tokens["client"], "agents": {"jira": tokens["agent"]}} + conn.execute("UPDATE setting SET value = ? WHERE key = 'tokens'", (json.dumps(new, ensure_ascii=False),)) + + +def _agent_status_v7(conn) -> None: + """agent_status по агентам: ключ agent вместо id = 1, last_jira_call_at → last_call_at; строка id = 1 уходит в + агента jira. Уже пересозданную таблицу (база собрана из схемы v7) не трогает.""" + if "agent" in {r["name"] for r in conn.execute("PRAGMA table_info(agent_status)")}: + return + conn.execute("""CREATE TABLE agent_status_new ( + agent TEXT PRIMARY KEY, + last_seen_at TEXT, + agent_version TEXT, + settings_version INTEGER, + current_command_id INTEGER, + last_call_at TEXT + )""") + conn.execute("INSERT INTO agent_status_new(agent, last_seen_at, agent_version, settings_version, " + "current_command_id, last_call_at) SELECT 'jira', last_seen_at, agent_version, settings_version, " + "current_command_id, last_jira_call_at FROM agent_status WHERE id = 1") + conn.execute("DROP TABLE agent_status") + conn.execute("ALTER TABLE agent_status_new RENAME TO agent_status") + + def schema() -> str: """Схема ядра и модулей: CREATE TABLE IF NOT EXISTS, порядок не важен.""" from . import registry # внутри функции: реестр импортирует модули лениво @@ -92,6 +125,7 @@ MIGRATIONS = { 4: [_add_column("issue_comment", "created_at"), _add_column("sync_run", "history_at"), # v4 → v5: журнал изменений _fill_comment_utc], 5: [], # v5 → v6: issue_attachment и attachment_file создаются схемой (CREATE TABLE IF NOT EXISTS), данных переносить не нужно + 6: [_tokens_v7, _agent_status_v7], # v6 → v7: токены и статус по агентам модулей (wiki-module, решение 15) } @@ -145,7 +179,9 @@ def migrate(conn: sqlite3.Connection, path=None) -> None: step(conn) if callable(step) else conn.execute(step) current += 1 conn.execute("UPDATE setting SET value = ? WHERE key = 'schema_version'", (json.dumps(current),)) - conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)") + from . import registry # внутри функции: реестр импортирует модули лениво + for m in registry.agent_modules(): # при каждом вызове: модуль, подключённый позже, получает свою строку (R-1) + conn.execute("INSERT OR IGNORE INTO agent_status(agent) VALUES (?)", (m.name,)) _savepoints = itertools.count(1) diff --git a/src/jira_hub/core/http.py b/src/jira_hub/core/http.py index 1f9db6b..76c590f 100644 --- a/src/jira_hub/core/http.py +++ b/src/jira_hub/core/http.py @@ -5,6 +5,14 @@ from . import projects from .errors import HttpMapped +AGENT = "_agent" # ключ query: имя модуля агента запроса; диспетчер кладёт его для scope "agent" + + +def agent_of(q) -> str: + """Модуль агента, сделавшего запрос (по его токену).""" + return q[AGENT] + + class Raw(NamedTuple): """Не-JSON ответ обработчика (веб-приложение, перенаправление, файл). headers=None — Cache-Control: no-cache.""" content_type: str diff --git a/src/jira_hub/core/modules.py b/src/jira_hub/core/modules.py index 4d9d290..20dd6cb 100644 --- a/src/jira_hub/core/modules.py +++ b/src/jira_hub/core/modules.py @@ -38,6 +38,7 @@ class Module: schema: str = "" settings_defaults: dict = field(default_factory=dict) agent_keys: tuple[str, ...] = () + agent_min_version_key: str | None = None # ключ настроек с минимальной версией агента модуля validate_setting: Callable[[str, object], bool] | None = None # True — ключ модуля проверен (ValueError — плохо) settings_view: Callable[[Connection], dict] | None = None # вычисляемые поля GET/PUT /settings on_init: Callable[[Connection, dict, bool], list[str]] | None = None # init --config: строки для вывода diff --git a/src/jira_hub/core/queue.py b/src/jira_hub/core/queue.py index 0131edc..69aed09 100644 --- a/src/jira_hub/core/queue.py +++ b/src/jira_hub/core/queue.py @@ -115,9 +115,14 @@ def has_active(conn) -> bool: return conn.execute("SELECT 1 FROM command WHERE status IN ('queued', 'claimed') LIMIT 1").fetchone() is not None -def agent_version(conn): +def _agent(agent: str | None) -> str: + """Имя агента; без имени — первый агент реестра (временно, пока claim не получает агента из запроса).""" + return agent or registry.agent_modules()[0].name + + +def agent_version(conn, agent: str | None = None): """Версия агента из его последней выдачи; None — агент не отчитывался.""" - row = conn.execute("SELECT agent_version FROM agent_status WHERE id = 1").fetchone() + row = conn.execute("SELECT agent_version FROM agent_status WHERE agent = ?", (_agent(agent),)).fetchone() return row["agent_version"] if row else None @@ -213,7 +218,7 @@ def _redeliver(conn, now) -> dict | None: h = _hooks(cmd["type"]) if h: h.on_failed(conn, cmd, now) - conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1 AND current_command_id = ?", (cmd["id"],)) + conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE current_command_id = ?", (cmd["id"],)) log.warning("команда %s: агент не отчитался после %s выдач — failed", cmd["id"], MAX_DELIVERIES) @@ -224,13 +229,15 @@ def deliver_params(conn, cmd, files_dir) -> dict: return h.deliver_params(conn, cmd, files_dir) if h else cmd["params"] -def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None, files_dir=None) -> dict | None: +def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None, files_dir=None, + agent: str | None = None) -> dict | None: now = db.now() + agent = _agent(agent) to_backup = None with db.transaction(conn): conn.execute("UPDATE agent_status SET last_seen_at = ?, agent_version = COALESCE(?, agent_version), " - "settings_version = COALESCE(?, settings_version), last_jira_call_at = COALESCE(?, last_jira_call_at) WHERE id = 1", - (now, None if agent_version is None else str(agent_version), settings_version, last_jira_call_at)) + "settings_version = COALESCE(?, settings_version), last_call_at = COALESCE(?, last_call_at) WHERE agent = ?", + (now, None if agent_version is None else str(agent_version), settings_version, last_jira_call_at, agent)) cmd = _redeliver(conn, now) if cmd is not None: pause = _pause(conn, cmd["type"]) @@ -239,12 +246,12 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non h = _hooks(cmd["type"]) if h: h.on_claim(conn, cmd, now, True) - conn.execute("UPDATE agent_status SET current_command_id = ? WHERE id = 1", (cmd["id"],)) + conn.execute("UPDATE agent_status SET current_command_id = ? WHERE agent = ?", (cmd["id"], agent)) log.info("команда %s выдана снова (выдача %s)", cmd["id"], cmd["deliveries"] + 1) else: cmd = _next(conn) if cmd is None: - conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1") + conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE agent = ?", (agent,)) return None pause = _pause(conn, cmd["type"]) first = conn.execute("SELECT COUNT(*) AS n FROM command WHERE batch_id = ? AND status != 'queued'", @@ -254,7 +261,7 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non (cmd["batch_id"], *read)).fetchone() is not None conn.execute("UPDATE command SET status = 'claimed', claimed_at = ?, pause_seconds = ?, params = ?, deliveries = 1 " "WHERE id = ?", (now, pause, _dump(cmd["params"]), cmd["id"])) - conn.execute("UPDATE agent_status SET current_command_id = ? WHERE id = 1", (cmd["id"],)) + conn.execute("UPDATE agent_status SET current_command_id = ? WHERE agent = ?", (cmd["id"], agent)) h = _hooks(cmd["type"]) if h: h.on_claim(conn, cmd, now, False) @@ -295,7 +302,7 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls= error = why[:ERROR_LIMIT] conn.execute("UPDATE command SET status = ?, finished_at = ?, result = ?, error = ?, jira_calls = ?, rate_limited = ? WHERE id = ?", (status, now, _dump(result) if result is not None else None, error, int(jira_calls or 0), int(rate_limited or 0), command_id)) - conn.execute("UPDATE agent_status SET current_command_id = NULL, last_seen_at = ? WHERE id = 1 AND current_command_id = ?", + conn.execute("UPDATE agent_status SET current_command_id = NULL, last_seen_at = ? WHERE current_command_id = ?", (now, command_id)) if h: h.on_result(conn, cmd, status, result, jira_calls, rate_limited, now, files_dir) @@ -351,7 +358,7 @@ def cancel_command(conn, command_id, force=False) -> dict: raise ClaimedError(cmd["claimed_at"]) if cmd["status"] in ("queued", "claimed"): conn.execute("UPDATE command SET status = 'cancelled', finished_at = ? WHERE id = ?", (db.now(), command_id)) - conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1 AND current_command_id = ?", (command_id,)) + conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE current_command_id = ?", (command_id,)) return get_command(conn, command_id) @@ -370,11 +377,13 @@ def retry_command(conn, command_id, author, files_dir=None, project=None) -> dic # --- агент и автообновление ---------------------------------------------------- -def agent_view(conn) -> dict: - d = dict(conn.execute("SELECT * FROM agent_status WHERE id = 1").fetchone()) +def agent_view(conn, agent: str | None = None) -> dict: + agent = _agent(agent) + d = dict(conn.execute("SELECT * FROM agent_status WHERE agent = ?", (agent,)).fetchone()) now = db.utcnow() d["online"] = bool(d["last_seen_at"]) and now - db.parse(d["last_seen_at"]) < timedelta(minutes=ONLINE_MINUTES) - min_version = int(settings.get(conn, "min_agent_version") or 0) + m = registry.module(agent) + min_version = int(settings.get(conn, m.agent_min_version_key) or 0) if m and m.agent_min_version_key else 0 d["warning"] = "обновите агента" if d["agent_version"] and int(d["agent_version"]) < min_version else None d["rate_limited_24h"] = conn.execute("SELECT COALESCE(SUM(rate_limited), 0) AS n FROM command WHERE finished_at >= ?", ((now - timedelta(days=1)).isoformat(),)).fetchone()["n"] diff --git a/src/jira_hub/core/registry.py b/src/jira_hub/core/registry.py index 11a296a..4b566d2 100644 --- a/src/jira_hub/core/registry.py +++ b/src/jira_hub/core/registry.py @@ -16,6 +16,11 @@ def modules() -> list[Module]: return _cache +def agent_modules() -> list[Module]: + """Модули с командами: у каждого свой агент (имя агента = имя модуля), в порядке реестра.""" + return [m for m in modules() if m.commands] + + def module(name: str) -> Module | None: return next((m for m in modules() if m.name == name), None) diff --git a/src/jira_hub/core/settings.py b/src/jira_hub/core/settings.py index 806c3fa..fe921ee 100644 --- a/src/jira_hub/core/settings.py +++ b/src/jira_hub/core/settings.py @@ -8,7 +8,6 @@ from . import registry CORE_DEFAULTS = { "backup_keep": 30, - "min_agent_version": 1, "port": 8790, "settings_version": 1, "initialized": False, @@ -16,7 +15,7 @@ CORE_DEFAULTS = { } # Ключи ядра в ответе агенту: идут после ключей модулей -CORE_AGENT_KEYS = ["min_agent_version", "settings_version"] +CORE_AGENT_KEYS = ["settings_version"] PROTECTED = {"settings_version", "schema_version", "tokens"} @@ -33,17 +32,15 @@ def defaults() -> dict: return out -def agent_keys() -> list[str]: - """Ключи настроек для агента: сначала модулей, затем ядра.""" - out: list[str] = [] - for m in registry.modules(): - out += m.agent_keys - return out + CORE_AGENT_KEYS +def agent_keys(module: str) -> list[str]: + """Ключи настроек для агента модуля: сначала ключи модуля, затем ядра. Неизвестный модуль — только ключи ядра.""" + m = registry.module(module) + return (list(m.agent_keys) if m else []) + CORE_AGENT_KEYS def validate(key, value) -> None: """Проверить значение настройки до записи; ValueError — на 422 в API. - Сначала ключи ядра: backup_keep, min_agent_version — целые >= 0; port — целое 1..65535; + Сначала ключи ядра: backup_keep — целое >= 0; port — целое 1..65535; relay_dir — null или абсолютный путь строкой. Затем validate_setting модулей (их ключи). Остальные ключи — по типу значения по умолчанию (объект, список, строка, флаг), если оно не None; неизвестные ключи и ключи с умолчанием None принимаются как есть.""" @@ -51,9 +48,9 @@ def validate(key, value) -> None: if not _is_int(value) or not 1 <= value <= 65535: raise ValueError("port — целое 1..65535") return - if key in ("backup_keep", "min_agent_version"): + if key == "backup_keep": if not _is_int(value) or value < 0: - raise ValueError(f"{key} — целое >= 0") + raise ValueError("backup_keep — целое >= 0") return if key == "relay_dir": if value is not None and (not isinstance(value, str) or not os.path.isabs(value)): @@ -71,9 +68,19 @@ def validate(key, value) -> None: def ensure_defaults(conn) -> None: for key, value in defaults().items(): conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES (?, ?)", (key, _dump(value))) - if get(conn, "tokens") is None: - conn.execute("INSERT INTO setting(key, value) VALUES ('tokens', ?)", - (_dump({"agent": secrets.token_urlsafe(24), "client": secrets.token_urlsafe(24)}),)) + tokens = get(conn, "tokens") or {} + changed = False + if "client" not in tokens: + tokens["client"] = secrets.token_urlsafe(24) + changed = True + agents = tokens.setdefault("agents", {}) + for m in registry.agent_modules(): # недостающий токен агента модуля создаётся при каждом вызове + if m.name not in agents: + agents[m.name] = secrets.token_urlsafe(24) + changed = True + if changed: + conn.execute("INSERT INTO setting(key, value) VALUES ('tokens', ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", (_dump(tokens),)) def _dump(value) -> str: @@ -105,9 +112,15 @@ def set_(conn, key, value, bump=True) -> int: return get(conn, "settings_version", 1) -def for_agent(conn) -> dict: - return {key: get(conn, key) for key in agent_keys()} +def for_agent(conn, module: str) -> dict: + """Настройки агента модуля: его ключи и ключи ядра, чужих ключей нет.""" + return {key: get(conn, key) for key in agent_keys(module)} def tokens(conn) -> dict: + """{"client": токен, "agents": {модуль: токен}}.""" return get(conn, "tokens") + + +def agent_token(conn, module: str) -> str: + return tokens(conn)["agents"][module] diff --git a/src/jira_hub/modules/jira/__init__.py b/src/jira_hub/modules/jira/__init__.py index 8591fe2..59225d6 100644 --- a/src/jira_hub/modules/jira/__init__.py +++ b/src/jira_hub/modules/jira/__init__.py @@ -14,6 +14,7 @@ MODULE = Module( schema=schema.SQL, settings_defaults=settings.DEFAULTS, agent_keys=tuple(settings.AGENT_KEYS), + agent_min_version_key="min_agent_version", validate_setting=settings.validate_setting, settings_view=settings.settings_view, on_init=settings.on_init, diff --git a/src/jira_hub/modules/jira/settings.py b/src/jira_hub/modules/jira/settings.py index bb893b6..1c1e996 100644 --- a/src/jira_hub/modules/jira/settings.py +++ b/src/jira_hub/modules/jira/settings.py @@ -7,6 +7,7 @@ DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 3 "ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "fetch_attachment": 2, "refresh": 2} DEFAULTS = { + "min_agent_version": 1, "project_key": "AURORA", "pacing": DEFAULT_PACING, "auto_refresh_minutes": 60, @@ -25,10 +26,10 @@ DEFAULTS = { "closed_statuses": ["Готово", "Снято"], # статусы Jira закрытых задач: список и доска приложения (hide-closed) } -# Что получает агент от модуля (без токенов и служебных ключей хаба); min_agent_version и settings_version добавляет ядро +# Что получает агент от модуля (без токенов и служебных ключей хаба); settings_version добавляет ядро AGENT_KEYS = ["project_key", "issue_types", "epic_name_field_id", "field_ids", "epic_link", "priority_map", "priority_value_key", "labels", "status_labels", "kind_labels", "comment_marker", - "transitions_map", "link_types", "board_id", "pacing"] + "transitions_map", "link_types", "pacing", "min_agent_version"] def _is_int(v) -> bool: @@ -39,7 +40,7 @@ def validate_setting(key, value) -> bool: """True — ключ принадлежит модулю и проверен; ValueError — значение плохое (422 в API). base_jql и board_id не пишутся: JQL собирается из проектов, доска — свойство проекта; pacing — объект {тип команды: целое >= 0} только с известными типами команд; - auto_refresh_minutes — целое >= 0.""" + auto_refresh_minutes и min_agent_version — целые >= 0.""" if key == "base_jql": raise ValueError("базовая JQL собирается из проектов (GET /projects)") if key == "board_id": @@ -53,7 +54,7 @@ def validate_setting(key, value) -> bool: if not _is_int(pause) or pause < 0: raise ValueError(f"pacing.{ctype}: ожидается целое >= 0") return True - if key == "auto_refresh_minutes": + if key in ("auto_refresh_minutes", "min_agent_version"): if not _is_int(value) or value < 0: raise ValueError(f"{key} — целое >= 0") return True diff --git a/tests/conftest.py b/tests/conftest.py index 88a37f6..e9b0a29 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,14 @@ """Общие фикстуры тестов. Соединения SQLite, открытые тестом, закрываются после него — в тестах нет ResourceWarning «unclosed database» (hub-hygiene-d, решение 14; M-9/O-4 #4).""" +import re +import threading + import pytest -from jira_hub.core import db, projects +from jira_hub.core import api, db, projects, registry, settings +from jira_hub.core.client import HubClient +from jira_hub.core.modules import CommandHooks, Module +from jira_hub.modules.jira import MODULE as JIRA_MODULE from jira_hub.modules.jira import projects as jira_projects @@ -32,3 +38,78 @@ def add_project(): jira_projects.record_board_types(conn, {str(board_id): board_type}) return projects.get(conn, label) return make + + +def _stub_route(hub, conn, m, q, body): + return 200, {"agent": q["_agent"]} + + +def _stub_hooks() -> CommandHooks: + """Заглушка хуков второго модуля: типы stub_cmd (только чтение), остальное — пустые функции.""" + noop = lambda *a, **k: None # noqa: E731 + return CommandHooks( + types=frozenset({"stub_cmd"}), read_types=frozenset({"stub_cmd"}), validate=lambda cmds: cmds, + check_batch=lambda conn, cmds, project, files_dir: cmds, tail=lambda cmds: None, pause=lambda conn, t: 0, + deliver_params=lambda conn, cmd, files_dir: cmd["params"], temp_definer=None, temp_keys_used=lambda c: [], + substitute=lambda t, p, mapping: p, drop_unresolved=lambda c, u: u, retry_params=lambda c: c["params"], + check_confirmed=lambda conn, c: (True, None), on_claim=noop, on_failed=noop, + check_result=lambda conn, c, status, result, files_dir: (status, None), on_result=noop, after_result=noop, + on_late_result=lambda *a: False) + + +@pytest.fixture +def stub_module(monkeypatch) -> Module: + """Второй модуль с командами и своим маршрутом агента; реестр подменён на [jira, stub]. Маршрут добавлен и + в скомпилированную таблицу api (она собирается при импорте).""" + stub = Module(name="stub", commands=_stub_hooks(), agent_keys=("stub_key",), settings_defaults={"stub_key": 1}, + routes=(("POST", r"^/agent/stub$", _stub_route, "agent"),)) + monkeypatch.setattr(registry, "_cache", [JIRA_MODULE, stub]) + route = stub.routes[0] + monkeypatch.setattr(api, "_COMPILED", api._COMPILED + [(route[0], re.compile(route[1]), route[2], route[3], "stub")]) + return stub + + +@pytest.fixture +def conn_two_modules(stub_module, open_db): + """База в памяти при двух модулях: строки agent_status и токены на каждый.""" + conn = open_db() + settings.ensure_defaults(conn) + return conn + + +@pytest.fixture +def hub_server_two_modules(stub_module, tmp_path): + """HTTP-хаб на порту 0 при двух модулях: {"hub", "base", "client", "agents": {модуль: HubClient}}.""" + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + h.claim_wait = 0 + conn = h.conn() + tok = settings.tokens(conn) + conn.close() + srv = api.make_server(h, "127.0.0.1", 0) + th = threading.Thread(target=srv.serve_forever, daemon=True) + th.start() + base = f"http://127.0.0.1:{srv.server_port}" + yield {"hub": h, "base": base, "client": HubClient(base, tok["client"]), + "agents": {name: HubClient(base, token) for name, token in tok["agents"].items()}} + srv.shutdown() + srv.server_close() + th.join(5) + + +@pytest.fixture +def hub_server(tmp_path): + """HTTP-хаб на порту 0 с одним модулем: {"hub", "base", "client", "agent"} (агент — токеном jira).""" + h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") + h.claim_wait = 0 + conn = h.conn() + tok = settings.tokens(conn) + conn.close() + srv = api.make_server(h, "127.0.0.1", 0) + th = threading.Thread(target=srv.serve_forever, daemon=True) + th.start() + base = f"http://127.0.0.1:{srv.server_port}" + yield {"hub": h, "base": base, "client": HubClient(base, tok["client"]), + "agent": HubClient(base, tok["agents"]["jira"])} + srv.shutdown() + srv.server_close() + th.join(5) diff --git a/tests/fixtures/baseline/schema.expected.json b/tests/fixtures/baseline/schema.expected.json index d25a3a5..935f3ef 100644 --- a/tests/fixtures/baseline/schema.expected.json +++ b/tests/fixtures/baseline/schema.expected.json @@ -28,7 +28,7 @@ [ "table", "agent_status", - "CREATE TABLE agent_status (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n last_seen_at TEXT,\n agent_version TEXT,\n settings_version INTEGER,\n current_command_id INTEGER,\n last_jira_call_at TEXT\n)" + "CREATE TABLE agent_status (\n agent TEXT PRIMARY KEY,\n last_seen_at TEXT,\n agent_version TEXT,\n settings_version INTEGER,\n current_command_id INTEGER,\n last_call_at TEXT\n)" ], [ "table", @@ -91,5 +91,5 @@ "CREATE TABLE sync_run (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n started_at TEXT NOT NULL,\n finished_at TEXT,\n kind TEXT NOT NULL,\n query TEXT,\n issues_count INTEGER NOT NULL DEFAULT 0,\n links_count INTEGER NOT NULL DEFAULT 0,\n jira_calls INTEGER NOT NULL DEFAULT 0,\n rate_limited INTEGER NOT NULL DEFAULT 0,\n command_id INTEGER,\n history_at TEXT\n)" ] ], - "schema_version": 6 + "schema_version": 7 } diff --git a/tests/fixtures/baseline/settings.expected.json b/tests/fixtures/baseline/settings.expected.json index dcfe1d1..5a1f5fa 100644 --- a/tests/fixtures/baseline/settings.expected.json +++ b/tests/fixtures/baseline/settings.expected.json @@ -13,7 +13,6 @@ "comment_marker", "transitions_map", "link_types", - "board_id", "pacing", "min_agent_version", "settings_version" diff --git a/tests/fixtures/schema_v6.json b/tests/fixtures/schema_v6.json new file mode 100644 index 0000000..d25a3a5 --- /dev/null +++ b/tests/fixtures/schema_v6.json @@ -0,0 +1,95 @@ +{ + "objects": [ + [ + "index", + "issue_attachment_key", + "CREATE INDEX issue_attachment_key ON issue_attachment(key)" + ], + [ + "index", + "ix_command_status", + "CREATE INDEX ix_command_status ON command(status, batch_id, seq)" + ], + [ + "index", + "ix_issue_change_at", + "CREATE INDEX ix_issue_change_at ON issue_change(at)" + ], + [ + "index", + "ix_issue_change_key", + "CREATE INDEX ix_issue_change_key ON issue_change(key, at)" + ], + [ + "index", + "ix_issue_link_target", + "CREATE INDEX ix_issue_link_target ON issue_link(target_key)" + ], + [ + "table", + "agent_status", + "CREATE TABLE agent_status (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n last_seen_at TEXT,\n agent_version TEXT,\n settings_version INTEGER,\n current_command_id INTEGER,\n last_jira_call_at TEXT\n)" + ], + [ + "table", + "attachment_file", + "CREATE TABLE attachment_file (\n attachment_id TEXT PRIMARY KEY,\n file TEXT NOT NULL,\n size INTEGER NOT NULL,\n fetched_at TEXT NOT NULL\n)" + ], + [ + "table", + "batch", + "CREATE TABLE batch (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n title TEXT NOT NULL,\n author TEXT NOT NULL,\n created_at TEXT NOT NULL,\n cancelled_at TEXT,\n estimate_seconds INTEGER NOT NULL DEFAULT 0,\n project TEXT\n)" + ], + [ + "table", + "command", + "CREATE TABLE command (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n batch_id INTEGER NOT NULL REFERENCES batch(id),\n seq INTEGER NOT NULL,\n type TEXT NOT NULL,\n params TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'queued',\n claimed_at TEXT,\n deliveries INTEGER NOT NULL DEFAULT 0,\n finished_at TEXT,\n result TEXT,\n error TEXT,\n jira_calls INTEGER NOT NULL DEFAULT 0,\n rate_limited INTEGER NOT NULL DEFAULT 0,\n pause_seconds INTEGER,\n depends_on INTEGER REFERENCES command(id)\n)" + ], + [ + "table", + "issue", + "CREATE TABLE issue (\n key TEXT PRIMARY KEY,\n id TEXT,\n issue_type TEXT,\n summary TEXT,\n description TEXT,\n status TEXT,\n priority TEXT,\n assignee TEXT,\n reporter TEXT,\n duedate TEXT,\n planned_start TEXT,\n planned_end TEXT,\n epic_key TEXT,\n labels TEXT NOT NULL DEFAULT '[]',\n sprint TEXT,\n created TEXT,\n jira_updated TEXT,\n fetched_at TEXT NOT NULL,\n neighbor INTEGER NOT NULL DEFAULT 0\n)" + ], + [ + "table", + "issue_attachment", + "CREATE TABLE issue_attachment (\n attachment_id TEXT PRIMARY KEY,\n key TEXT NOT NULL,\n filename TEXT NOT NULL,\n size INTEGER,\n mime_type TEXT,\n author TEXT,\n created TEXT,\n created_at TEXT\n)" + ], + [ + "table", + "issue_change", + "CREATE TABLE issue_change (\n history_id TEXT NOT NULL,\n seq INTEGER NOT NULL,\n key TEXT NOT NULL,\n at TEXT,\n created TEXT NOT NULL,\n author TEXT,\n field TEXT NOT NULL,\n field_id TEXT,\n field_name TEXT NOT NULL,\n from_value TEXT,\n from_string TEXT,\n to_value TEXT,\n to_string TEXT,\n fetched_at TEXT NOT NULL,\n PRIMARY KEY (history_id, seq)\n)" + ], + [ + "table", + "issue_comment", + "CREATE TABLE issue_comment (\n key TEXT NOT NULL,\n comment_id TEXT NOT NULL,\n author TEXT,\n created TEXT,\n updated TEXT,\n body TEXT NOT NULL DEFAULT '',\n created_at TEXT,\n PRIMARY KEY (key, comment_id)\n)" + ], + [ + "table", + "issue_link", + "CREATE TABLE issue_link (\n source_key TEXT NOT NULL,\n target_key TEXT NOT NULL,\n link_type TEXT NOT NULL,\n PRIMARY KEY (source_key, target_key, link_type)\n)" + ], + [ + "table", + "issue_note", + "CREATE TABLE issue_note (\n key TEXT PRIMARY KEY,\n fact_status TEXT,\n work_kind TEXT,\n done_text TEXT,\n rest_text TEXT,\n owner_note TEXT,\n updated_at TEXT,\n updated_by TEXT\n)" + ], + [ + "table", + "project", + "CREATE TABLE project (\n label TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n board_id INTEGER,\n board_type TEXT,\n created_at TEXT NOT NULL\n)" + ], + [ + "table", + "setting", + "CREATE TABLE setting (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n)" + ], + [ + "table", + "sync_run", + "CREATE TABLE sync_run (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n started_at TEXT NOT NULL,\n finished_at TEXT,\n kind TEXT NOT NULL,\n query TEXT,\n issues_count INTEGER NOT NULL DEFAULT 0,\n links_count INTEGER NOT NULL DEFAULT 0,\n jira_calls INTEGER NOT NULL DEFAULT 0,\n rate_limited INTEGER NOT NULL DEFAULT 0,\n command_id INTEGER,\n history_at TEXT\n)" + ] + ], + "schema_version": 6 +} diff --git a/tests/fixtures/settings_v6.json b/tests/fixtures/settings_v6.json new file mode 100644 index 0000000..dcfe1d1 --- /dev/null +++ b/tests/fixtures/settings_v6.json @@ -0,0 +1,63 @@ +{ + "agent_keys": [ + "project_key", + "issue_types", + "epic_name_field_id", + "field_ids", + "epic_link", + "priority_map", + "priority_value_key", + "labels", + "status_labels", + "kind_labels", + "comment_marker", + "transitions_map", + "link_types", + "board_id", + "pacing", + "min_agent_version", + "settings_version" + ], + "defaults": { + "auto_refresh_minutes": 60, + "backup_keep": 30, + "closed_statuses": [ + "Готово", + "Снято" + ], + "comment_marker": "", + "epic_link": {}, + "epic_name_field_id": null, + "field_ids": {}, + "initialized": false, + "issue_types": {}, + "kind_labels": {}, + "labels": [], + "link_types": {}, + "min_agent_version": 1, + "pacing": { + "ensure_attachment": 30, + "ensure_comment": 30, + "ensure_fields": 120, + "ensure_issue": 120, + "ensure_link": 30, + "ensure_sprint": 30, + "ensure_status": 30, + "fetch_attachment": 2, + "refresh": 2 + }, + "port": 8790, + "priority_map": {}, + "priority_value_key": "name", + "project_key": "AURORA", + "relay_dir": null, + "settings_version": 1, + "status_labels": {}, + "transitions_map": {} + }, + "protected": [ + "schema_version", + "settings_version", + "tokens" + ] +} diff --git a/tests/test_agents.py b/tests/test_agents.py new file mode 100644 index 0000000..916c475 --- /dev/null +++ b/tests/test_agents.py @@ -0,0 +1,93 @@ +"""Агенты модулей: токен определяет модуль, миграция v7 сохраняет агента jira (wiki-module, решения 1–3, 6, 15).""" +import pytest + +from jira_hub.core import cli, db, queue, 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") + conn = db.connect(tmp_path / "hub.sqlite") + try: + 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"] == 1 + 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 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("база: ") + + +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 diff --git a/tests/test_api.py b/tests/test_api.py index 06dd59e..d37b7ee 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -29,7 +29,7 @@ def hub(tmp_path): th = threading.Thread(target=srv.serve_forever, daemon=True) th.start() base = f"http://127.0.0.1:{srv.server_port}" - yield {"hub": h, "agent": HubClient(base, tok["agent"]), "client": HubClient(base, tok["client"]), "base": base} + yield {"hub": h, "agent": HubClient(base, tok["agents"]["jira"]), "client": HubClient(base, tok["client"]), "base": base} srv.shutdown() srv.server_close() th.join(5) @@ -530,7 +530,7 @@ def test_agent_reported_after_hub_restart(hub, tmp_path): """M-2 #25, спека «Хаб перезапущен, агент ещё не выходил на связь»: в базе агент онлайн, claim с запуска не было.""" _publish(hub, tmp_path, 10) conn = hub["hub"].conn() - conn.execute("UPDATE agent_status SET last_seen_at = ?, agent_version = '9' WHERE id = 1", (db.now(),)) + conn.execute("UPDATE agent_status SET last_seen_at = ?, agent_version = '9' WHERE agent = 'jira'", (db.now(),)) conn.close() v = hub["client"].get("/agent") assert v["online"] is True and v["agent_reported"] is False @@ -602,7 +602,7 @@ def test_agent_warning_and_offline(hub): assert c.get("/agent")["warning"] is None old = (db.utcnow() - timedelta(minutes=queue.ONLINE_MINUTES + 1)).isoformat() conn = hub["hub"].conn() - conn.execute("UPDATE agent_status SET last_seen_at = ? WHERE id = 1", (old,)) + conn.execute("UPDATE agent_status SET last_seen_at = ? WHERE agent = 'jira'", (old,)) conn.close() assert c.get("/agent")["online"] is False diff --git a/tests/test_baseline.py b/tests/test_baseline.py index 1c3daa9..f2e8915 100644 --- a/tests/test_baseline.py +++ b/tests/test_baseline.py @@ -32,7 +32,7 @@ def test_routes(): def test_settings(): - _check("settings", {"defaults": settings.defaults(), "agent_keys": settings.agent_keys(), + _check("settings", {"defaults": settings.defaults(), "agent_keys": settings.agent_keys("jira"), "protected": sorted(settings.PROTECTED)}) diff --git a/tests/test_cli.py b/tests/test_cli.py index 3cd7ed9..90171e2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -11,7 +11,7 @@ def test_init_import_tokens(tmp_path, monkeypatch, capsys, open_db): monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) assert cli.main(["init", "--config", str(FIXTURE)]) == 0 out = capsys.readouterr().out - assert "transitions_map" in out and "агентский токен" in out + assert "transitions_map" in out and "агент jira: " in out assert cli.main(["init", "--config", str(FIXTURE)]) == 1 assert "уже инициализирован" in capsys.readouterr().err conn = open_db(paths.db_path()) diff --git a/tests/test_db.py b/tests/test_db.py index 9a25ddd..39576ed 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -19,7 +19,7 @@ def test_connect_creates_schema(open_db): assert "created_at" in {r["name"] for r in conn.execute("PRAGMA table_info(issue_comment)")} assert "history_at" in {r["name"] for r in conn.execute("PRAGMA table_info(sync_run)")} assert "project" in {r["name"] for r in conn.execute("PRAGMA table_info(batch)")} - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7" assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")} assert "deliveries" in cols @@ -59,7 +59,7 @@ def test_migrates_v1_with_backup(tmp_path): path = tmp_path / "hub.sqlite" _v1_database(path) conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7" rows = conn.execute("SELECT status, deliveries FROM command ORDER BY id").fetchall() assert [(r["status"], r["deliveries"]) for r in rows] == [("claimed", 1), ("done", 0), ("queued", 0)] assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе" @@ -206,7 +206,7 @@ def test_parallel_connect_makes_one_backup(tmp_path): assert errors == [] assert len(list((tmp_path / "backups").glob("*-schema-v1.sqlite"))) == 1 conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7" conn.close() @@ -223,7 +223,7 @@ def test_migrates_v2_with_backup(tmp_path): conn.execute("UPDATE setting SET value = '2' WHERE key = 'schema_version'") conn.close() conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7" assert conn.execute("SELECT COUNT(*) AS n FROM issue_comment").fetchone()["n"] == 0 assert conn.execute("SELECT summary FROM issue WHERE key = 'A-1'").fetchone()["summary"] == "Т" assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе" @@ -291,7 +291,7 @@ def test_migrates_v3_creates_awpca(tmp_path): queued = [json.loads(r["params"])["jql"] for r in conn.execute("SELECT params FROM command WHERE status = 'queued'")] assert sorted(queued) == sorted([new, "key = A-1"]) assert conn.execute("SELECT project FROM batch").fetchall()[0]["project"] is None - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7" assert len(list((tmp_path / "backups").glob("*-schema-v3.sqlite"))) == 1 conn.close() @@ -310,7 +310,7 @@ def test_migrates_v3_unparsed_jql_keeps_running(tmp_path, caplog): conn = db.connect(tmp_path / "hub.sqlite") assert projects.list_projects(conn) == [] assert "ORDER BY key" in caplog.text - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7" conn.close() @@ -344,7 +344,7 @@ def test_migrates_v4(tmp_path): path = tmp_path / "hub.sqlite" _v4_database(path) conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7" rows = {r["comment_id"]: (r["created"], r["created_at"]) for r in conn.execute("SELECT * FROM issue_comment")} assert rows == {"1": ("2026-10-22T13:05:11.000+0300", "2026-10-22T10:05:11+00:00"), "2": ("мусор", None)} assert conn.execute("SELECT history_at FROM sync_run").fetchone()["history_at"] is None @@ -360,7 +360,7 @@ def test_migrate_v5_to_v6(tmp_path): conn.execute("UPDATE setting SET value = '5' WHERE key = 'schema_version'") conn.close() conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7" assert conn.execute("SELECT COUNT(*) AS n FROM issue_attachment").fetchone()["n"] == 0 assert conn.execute("SELECT COUNT(*) AS n FROM attachment_file").fetchone()["n"] == 0 assert len(list((tmp_path / "backups").glob("*-schema-v5.sqlite"))) == 1 diff --git a/tests/test_e2e_hub.py b/tests/test_e2e_hub.py index a473180..4953ae1 100644 --- a/tests/test_e2e_hub.py +++ b/tests/test_e2e_hub.py @@ -19,7 +19,7 @@ def live(tmp_path, add_project): th = threading.Thread(target=srv.serve_forever, daemon=True) th.start() base = f"http://127.0.0.1:{srv.server_port}" - yield HubClient(base, tok["agent"]), HubClient(base, tok["client"]) + yield HubClient(base, tok["agents"]["jira"]), HubClient(base, tok["client"]) srv.shutdown() srv.server_close() th.join(5) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index b4572e1..17f4c64 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -30,7 +30,7 @@ def live(tmp_path, monkeypatch, add_project): 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"])} + yield {"agent": HubClient(base, tok["agents"]["jira"]), "client": HubClient(base, tok["client"])} srv.shutdown() srv.server_close() diff --git a/tests/test_queue.py b/tests/test_queue.py index aa23cff..23d7d85 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -1148,13 +1148,13 @@ def test_request_fetch_ready(fconn, tmp_path): def test_request_fetch_agent_too_old(fconn, tmp_path): - fconn.execute("UPDATE agent_status SET agent_version = '13' WHERE id = 1") + fconn.execute("UPDATE agent_status SET agent_version = '13' WHERE agent = 'jira'") with pytest.raises(jira_fetch.AgentTooOld) as e: jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x") assert str(e.value) == "агент v13 не умеет скачивать вложения — нужен v14" - fconn.execute("UPDATE agent_status SET agent_version = '14' WHERE id = 1") + fconn.execute("UPDATE agent_status SET agent_version = '14' WHERE agent = 'jira'") assert jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching" - fconn.execute("UPDATE agent_status SET agent_version = NULL WHERE id = 1") + fconn.execute("UPDATE agent_status SET agent_version = NULL WHERE agent = 'jira'") assert jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching" diff --git a/tests/test_relay.py b/tests/test_relay.py index 9f38241..a351d4e 100644 --- a/tests/test_relay.py +++ b/tests/test_relay.py @@ -27,7 +27,7 @@ def env(tmp_path): th.start() r = relay.Relay(h, srv.server_port) client = HubClient(f"http://127.0.0.1:{srv.server_port}", tok["client"]) - yield {"hub": h, "root": root, "relay": r, "agent": "Bearer " + tok["agent"], "client": client, + yield {"hub": h, "root": root, "relay": r, "agent": "Bearer " + tok["agents"]["jira"], "client": client, "port": srv.server_port} srv.shutdown() srv.server_close() @@ -251,7 +251,7 @@ def test_serve_runs_relay(tmp_path): root.mkdir() conn = h.conn() settings.set_(conn, "relay_dir", str(root)) - auth = "Bearer " + settings.tokens(conn)["agent"] + auth = "Bearer " + settings.tokens(conn)["agents"]["jira"] conn.close() srv, stop = api.start(h, "127.0.0.1", 0) # реле стучится на тот же хост, что слушает HTTP th = threading.Thread(target=srv.serve_forever, daemon=True) diff --git a/tests/test_settings.py b/tests/test_settings.py index e243ec3..70a82fb 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -21,15 +21,15 @@ def conn(open_db): def test_defaults_and_tokens(conn): assert settings.get(conn, "pacing")["ensure_fields"] == 120 tok = settings.tokens(conn) - assert len(tok["agent"]) > 20 and tok["agent"] != tok["client"] + assert len(tok["agents"]["jira"]) > 20 and tok["agents"]["jira"] != tok["client"] assert "tokens" not in settings.get_all(conn) def test_defaults_without_jql_and_board(conn): assert "base_jql" not in settings.defaults() and "board_id" not in settings.defaults() assert "base_jql" not in settings.get_all(conn) and "board_id" not in settings.get_all(conn) - assert "base_jql" not in settings.agent_keys() and "board_id" in settings.agent_keys() - assert settings.for_agent(conn)["board_id"] is None + assert "base_jql" not in settings.agent_keys("jira") and "board_id" not in settings.agent_keys("jira") + assert "board_id" not in settings.for_agent(conn, "jira") # мёртвый ключ: агент читает его через .get (design 6) def test_jql_and_board_not_writable(): @@ -60,7 +60,7 @@ def test_init_from_config(conn): assert settings.get(conn, "field_ids")["planned_end"] == "customfield_10802" assert settings.get(conn, "issue_types")["epic"] == {"name": "Фича", "id": "12300"} assert settings.get(conn, "pacing") == jira_settings.DEFAULT_PACING - agent_view = settings.for_agent(conn) + agent_view = settings.for_agent(conn, "jira") assert "tokens" not in agent_view and "auth" not in agent_view and "base_url" not in agent_view assert agent_view["link_types"]["связанные"] == "ASUPR_Связанные" with pytest.raises(RuntimeError): @@ -77,7 +77,7 @@ def test_relay_dir_default_and_validation(tmp_path, open_db): for bad in ("rshb/jira-bridge", "", 5, ["/x"]): with pytest.raises(ValueError): settings.validate("relay_dir", bad) - assert "relay_dir" not in settings.agent_keys() + assert "relay_dir" not in settings.agent_keys("jira") def test_init_without_bug_keys_rejects_bug(conn): @@ -112,8 +112,8 @@ def test_attachment_pacing(conn): def test_closed_statuses_default(conn): assert settings.get(conn, "closed_statuses") == ["Готово", "Снято"] - assert "closed_statuses" not in settings.agent_keys() - assert "closed_statuses" not in settings.for_agent(conn) + assert "closed_statuses" not in settings.agent_keys("jira") + assert "closed_statuses" not in settings.for_agent(conn, "jira") def test_closed_statuses_must_be_list(): diff --git a/tests/v6db.py b/tests/v6db.py new file mode 100644 index 0000000..f919ec6 --- /dev/null +++ b/tests/v6db.py @@ -0,0 +1,28 @@ +"""База хаба версии 6 для тестов миграции v7: схема и умолчания настроек берутся из замороженных эталонов +(tests/fixtures/schema_v6.json, settings_v6.json), а не из кода, который со временем уйдёт вперёд.""" +import json +import sqlite3 +from pathlib import Path + +FIXTURES = Path(__file__).parent / "fixtures" + + +def make_v6_db(path, *, agent_token="T-agent", client_token="T-client") -> None: + schema = json.loads((FIXTURES / "schema_v6.json").read_text(encoding="utf-8")) + defaults = json.loads((FIXTURES / "settings_v6.json").read_text(encoding="utf-8"))["defaults"] + conn = sqlite3.connect(str(path), isolation_level=None) + try: + for kind in ("table", "index"): # индексы — после таблиц + for typ, _name, sql in schema["objects"]: + if typ == kind and sql: + conn.execute(sql) + values = {**defaults, "schema_version": 6, "tokens": {"agent": agent_token, "client": client_token}} + for key, value in values.items(): + conn.execute("INSERT INTO setting(key, value) VALUES (?, ?)", (key, json.dumps(value, ensure_ascii=False))) + conn.execute("INSERT INTO agent_status(id, agent_version) VALUES (1, '14')") + conn.execute("INSERT INTO project(label, name, board_id, board_type, created_at) VALUES " + "('AWPCA', 'АРМ КА', 4216, 'scrum', '2026-10-01T00:00:00+00:00')") + conn.execute("INSERT INTO project(label, name, board_id, board_type, created_at) VALUES " + "('sfm_kedr', 'sfm_kedr', NULL, NULL, '2026-10-02T00:00:00+00:00')") + finally: + conn.close()