Миграция v7: токены и статус по агентам модулей (#91)

tokens -> {client, agents: {модуль: токен}}, agent_status по агентам
(ключ agent, last_call_at), настройки агента и маршруты agent-scope по
модулю токена, min_agent_version переехал в модуль jira.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 00:45:44 +04:00
parent 274e0571eb
commit 43c43ade81
26 changed files with 533 additions and 80 deletions

View File

@ -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

View File

@ -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__":

View File

@ -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)

View File

@ -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

View File

@ -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: строки для вывода

View File

@ -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"]

View File

@ -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)

View File

@ -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]

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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
}

View File

@ -13,7 +13,6 @@
"comment_marker",
"transitions_map",
"link_types",
"board_id",
"pacing",
"min_agent_version",
"settings_version"

95
tests/fixtures/schema_v6.json vendored Normal file
View File

@ -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
}

63
tests/fixtures/settings_v6.json vendored Normal file
View File

@ -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"
]
}

93
tests/test_agents.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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)})

View File

@ -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())

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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"

View File

@ -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)

View File

@ -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():

28
tests/v6db.py Normal file
View File

@ -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()