refactor: контракт модуля, реестр, схема и настройки по модулям (#87)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-23 22:01:06 +04:00
parent 6d7aebe01c
commit 01daaa8638
23 changed files with 687 additions and 391 deletions

View File

@ -17,7 +17,8 @@ from urllib.parse import parse_qs, quote, unquote, urlparse
from . import backup, db, files, projects, queue, relay, release, settings
from ..modules.jira import attachments, history, mirror
from ..modules.jira.commands import CommandError
from ..modules.jira import projects as jira_projects
from .errors import CommandError
from .paths import backups_dir as default_backups_dir
from .paths import secure_dir
from .queue import ClaimedError
@ -268,7 +269,7 @@ def _time_param(q, name, default):
raw = q.get(name)
if raw in (None, ""):
return default
value = db.jira_utc(raw) if _OFFSET.search(raw) else None # без смещения — не угадываем пояс (M-2)
value = history.jira_utc(raw) if _OFFSET.search(raw) else None # без смещения — не угадываем пояс (M-2)
if value is None:
raise ApiError(422, "bad_request", f"{name}: ожидается время ISO-8601 со смещением или Z")
return value
@ -294,7 +295,7 @@ def h_changes(hub, conn, m, q, body):
def h_board(hub, conn, m, q, body):
return 200, mirror.board(conn, projects.base_jql(conn), _project_param(conn, q))
return 200, mirror.board(conn, jira_projects.base_jql(conn), _project_param(conn, q))
def h_app(hub, conn, m, q, body):
@ -472,7 +473,7 @@ def h_refresh(hub, conn, m, q, body):
if body.get("jql") is not None and not isinstance(body["jql"], str):
raise ApiError(422, "bad_refresh", "jql — строка")
if body.get("full"):
jql = projects.base_jql(conn)
jql = jira_projects.base_jql(conn)
if jql is None:
raise ApiError(422, "bad_refresh", "нет проектов")
params = {"jql": jql}
@ -501,7 +502,7 @@ def h_sync_runs(hub, conn, m, q, body):
def h_settings_get(hub, conn, m, q, body):
return 200, {**settings.get_all(conn), "base_jql": projects.base_jql(conn)}
return 200, {**settings.get_all(conn), "base_jql": jira_projects.base_jql(conn)}
def h_settings_put(hub, conn, m, q, body):
@ -519,7 +520,7 @@ def h_settings_put(hub, conn, m, q, body):
with db.transaction(conn):
for key, value in body.items():
settings.set_(conn, key, value)
return 200, {**settings.get_all(conn), "base_jql": projects.base_jql(conn)}
return 200, {**settings.get_all(conn), "base_jql": jira_projects.base_jql(conn)}
def h_projects_get(hub, conn, m, q, body):
@ -538,7 +539,7 @@ def h_projects_post(hub, conn, m, q, body):
with db.transaction(conn):
project = projects.create(conn, body.get("label"), body.get("name"), body.get("board_id"))
batch = queue.create_batch(conn, f"Новый проект {project['label']}", author,
[{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
[{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
except projects.ProjectExists as e:
raise ApiError(409, "project_exists", str(e))
except projects.ProjectError as e:

View File

@ -4,7 +4,7 @@ import argparse
import json
import sys
from . import db, paths, projects, settings
from . import db, paths, registry, settings
from ..modules.jira import importer
@ -57,15 +57,16 @@ def main(argv=None) -> int:
if args.cmd == "init":
with open(args.config, encoding="utf-8") as f:
cfg = json.load(f)
lines: list[str] = []
try:
keys = settings.init_from_config(conn, cfg, force=args.force)
for m in registry.modules():
if m.on_init:
lines += m.on_init(conn, cfg, args.force)
except RuntimeError as e:
print(e, file=sys.stderr)
return 1
print("перенесено: " + ", ".join(keys))
if projects.ensure_legacy(conn, cfg.get("sprints", {}).get("board_id")):
board = projects.get(conn, projects.LEGACY_LABEL)["board_id"]
print(f"проект {projects.LEGACY_LABEL} «{projects.LEGACY_NAME}», доска {board}")
for line in lines:
print(line)
_print_tokens(conn)
return 0
if args.cmd == "import":

View File

@ -10,95 +10,11 @@ from . import backup, paths
SCHEMA_VERSION = 6
SCHEMA = """
CORE_SCHEMA = """
CREATE TABLE IF NOT EXISTS setting (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS issue (
key TEXT PRIMARY KEY,
id TEXT,
issue_type TEXT,
summary TEXT,
description TEXT,
status TEXT,
priority TEXT,
assignee TEXT,
reporter TEXT,
duedate TEXT,
planned_start TEXT,
planned_end TEXT,
epic_key TEXT,
labels TEXT NOT NULL DEFAULT '[]',
sprint TEXT,
created TEXT,
jira_updated TEXT,
fetched_at TEXT NOT NULL,
neighbor INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS issue_link (
source_key TEXT NOT NULL,
target_key TEXT NOT NULL,
link_type TEXT NOT NULL,
PRIMARY KEY (source_key, target_key, link_type)
);
CREATE INDEX IF NOT EXISTS ix_issue_link_target ON issue_link(target_key);
CREATE TABLE IF NOT EXISTS issue_comment (
key TEXT NOT NULL,
comment_id TEXT NOT NULL,
author TEXT,
created TEXT,
updated TEXT,
body TEXT NOT NULL DEFAULT '',
created_at TEXT,
PRIMARY KEY (key, comment_id)
);
CREATE TABLE IF NOT EXISTS issue_attachment (
attachment_id TEXT PRIMARY KEY,
key TEXT NOT NULL,
filename TEXT NOT NULL,
size INTEGER,
mime_type TEXT,
author TEXT,
created TEXT,
created_at TEXT
);
CREATE INDEX IF NOT EXISTS issue_attachment_key ON issue_attachment(key);
CREATE TABLE IF NOT EXISTS attachment_file (
attachment_id TEXT PRIMARY KEY,
file TEXT NOT NULL,
size INTEGER NOT NULL,
fetched_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS issue_change (
history_id TEXT NOT NULL,
seq INTEGER NOT NULL,
key TEXT NOT NULL,
at TEXT,
created TEXT NOT NULL,
author TEXT,
field TEXT NOT NULL,
field_id TEXT,
field_name TEXT NOT NULL,
from_value TEXT,
from_string TEXT,
to_value TEXT,
to_string TEXT,
fetched_at TEXT NOT NULL,
PRIMARY KEY (history_id, seq)
);
CREATE INDEX IF NOT EXISTS ix_issue_change_at ON issue_change(at);
CREATE INDEX IF NOT EXISTS ix_issue_change_key ON issue_change(key, at);
CREATE TABLE IF NOT EXISTS issue_note (
key TEXT PRIMARY KEY,
fact_status TEXT,
work_kind TEXT,
done_text TEXT,
rest_text TEXT,
owner_note TEXT,
updated_at TEXT,
updated_by TEXT
);
CREATE TABLE IF NOT EXISTS batch (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
@ -126,19 +42,6 @@ CREATE TABLE IF NOT EXISTS command (
depends_on INTEGER REFERENCES command(id)
);
CREATE INDEX IF NOT EXISTS ix_command_status ON command(status, batch_id, seq);
CREATE TABLE IF NOT EXISTS sync_run (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
finished_at TEXT,
kind TEXT NOT NULL,
query TEXT,
issues_count INTEGER NOT NULL DEFAULT 0,
links_count INTEGER NOT NULL DEFAULT 0,
jira_calls INTEGER NOT NULL DEFAULT 0,
rate_limited INTEGER NOT NULL DEFAULT 0,
command_id INTEGER,
history_at TEXT
);
CREATE TABLE IF NOT EXISTS project (
label TEXT PRIMARY KEY,
name TEXT NOT NULL,
@ -157,33 +60,38 @@ CREATE TABLE IF NOT EXISTS agent_status (
"""
def _migrate_projects(conn) -> None:
from . import projects # внутри функции: projects импортирует db
from ..modules.jira import projects # внутри шага: ядро не импортирует модули при импорте
projects.migrate_v3(conn)
def _fill_comment_utc(conn) -> None:
from ..modules.jira import history
history.fill_comment_utc(conn)
def schema() -> str:
"""Схема ядра и модулей: CREATE TABLE IF NOT EXISTS, порядок не важен."""
from . import registry # внутри функции: реестр импортирует модули лениво
return CORE_SCHEMA + "".join(m.schema for m in registry.modules())
def _add_column(table: str, column: str):
"""Шаг миграции: добавить колонку, если её нет. Базы v1/v2 получают issue_comment из SCHEMA уже с новой колонкой."""
"""Шаг миграции: добавить колонку, если её нет. Базы v1/v2 получают issue_comment из схемы уже с новой колонкой."""
def step(conn) -> None:
if column not in {r["name"] for r in conn.execute(f"PRAGMA table_info({table})")}:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} TEXT")
return step
def _fill_comment_utc(conn) -> None:
for r in conn.execute("SELECT key, comment_id, created FROM issue_comment").fetchall():
conn.execute("UPDATE issue_comment SET created_at = ? WHERE key = ? AND comment_id = ?",
(jira_utc(r["created"]), r["key"], r["comment_id"]))
# Шаги миграции: версия → SQL, переводящий схему в версию + 1 (design.md bridge-reliability, решение 5)
MIGRATIONS = {
1: ["ALTER TABLE command ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0",
"UPDATE command SET deliveries = 1 WHERE status = 'claimed'"], # уже выданные — одна выдача
2: [], # v2 → v3: issue_comment создаётся SCHEMA (CREATE TABLE IF NOT EXISTS), данных переносить не нужно
2: [], # v2 → v3: issue_comment создаётся схемой (CREATE TABLE IF NOT EXISTS), данных переносить не нужно
3: ["ALTER TABLE batch ADD COLUMN project TEXT", _migrate_projects], # v3 → v4: проекты-метки
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 создаются SCHEMA (CREATE TABLE IF NOT EXISTS), данных переносить не нужно
5: [], # v5 → v6: issue_attachment и attachment_file создаются схемой (CREATE TABLE IF NOT EXISTS), данных переносить не нужно
}
@ -218,7 +126,7 @@ def migrate(conn: sqlite3.Connection, path=None) -> None:
connect дождётся её, перечитает версию и не станет ни копировать, ни мигрировать повторно (M-2 #12). Копию делает
отдельное соединение: VACUUM INTO внутри транзакции SQLite запрещает, а читатель при WAL видит базу до миграции.
Вызывается только вне транзакции."""
conn.executescript(SCHEMA)
conn.executescript(schema())
version = _schema_version(conn)
if version is None:
conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES ('schema_version', ?)", (json.dumps(SCHEMA_VERSION),))
@ -272,22 +180,6 @@ def transaction(conn: sqlite3.Connection):
conn.execute("COMMIT")
def jira_utc(value) -> str | None:
"""Время Jira (`2026-10-22T13:05:11.000+0300`, `+03:00`, `Z`, с дробной частью или без) → ISO-8601 UTC в формате
now(); без смещения — UTC; неразбираемое — None."""
if not isinstance(value, str) or not value:
return None
s = value[:-1] + "+00:00" if value.endswith("Z") else value
if len(s) > 5 and s[-5] in "+-" and s[-4:].isdigit(): # ±HHMM → ±HH:MM
s = s[:-2] + ":" + s[-2:]
try:
d = datetime.fromisoformat(s)
d = d if d.tzinfo else d.replace(tzinfo=timezone.utc)
return d.astimezone(timezone.utc).replace(microsecond=0).isoformat()
except (ValueError, OverflowError): # время за краем диапазона datetime — тоже «не разобралось»
return None
def utcnow() -> datetime:
return datetime.now(timezone.utc).replace(microsecond=0)

View File

@ -0,0 +1,11 @@
"""Ошибки, общие для ядра и модулей."""
class CommandError(ValueError):
"""Ошибка валидации пакета команд — наружу уходит как 422."""
class HttpMapped(Exception):
"""Примесь: исключение модуля, которому соответствует свой код HTTP."""
status: int = 500
error: str = "internal"

View File

@ -0,0 +1,51 @@
"""Контракт модуля хаба: что модуль отдаёт ядру. Ядро знает только эти структуры; поля, не нужные модулю,
остаются по умолчанию."""
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from sqlite3 import Connection
from typing import Any
Route = tuple[str, str, Callable, str | None] # method, regex, handler(hub, conn, m, q, body), scope
@dataclass(frozen=True)
class CommandHooks:
types: frozenset[str]
read_types: frozenset[str] # не пишут во внешнюю систему: без хвоста и копии базы
validate: Callable[[list], list[dict]] # CommandError
check_batch: Callable[[Connection, list[dict], str | None, Path | None], list[dict]] # project, files_dir
tail: Callable[[list[dict]], dict | None] # хвостовая команда пишущего пакета
pause: Callable[[Connection, str], int] # пауза типа, с
deliver_params: Callable[[Connection, dict, Path | None], dict]
temp_definer: tuple[str, str, str] | None # (тип, поле params с временным ключом, поле result с ключом)
temp_keys_used: Callable[[dict], list[str]]
substitute: Callable[[str, dict, dict], dict]
drop_unresolved: Callable[[dict, list[str]], list[str]] # поправить params команды, вернуть неразрешённые
retry_params: Callable[[dict], dict]
check_confirmed: Callable[[Connection, dict], tuple[bool, dict | None]]
on_claim: Callable[[Connection, dict, str, bool], None] # cmd, now, redelivery
on_failed: Callable[[Connection, dict, str], None] # исчерпаны выдачи
check_result: Callable[[Connection, dict, str, dict | None, Path | None], tuple[str, str | None]]
on_result: Callable[[Connection, dict, str, dict | None, int, int, str, Path | None], None] # в транзакции
after_result: Callable[[Connection, dict, str], None] # вне транзакции
on_late_result: Callable[[Connection, dict, str, dict | None, int, int], bool]
@dataclass(frozen=True)
class Module:
name: str
schema: str = ""
settings_defaults: dict = field(default_factory=dict)
agent_keys: tuple[str, ...] = ()
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: строки для вывода
commands: CommandHooks | None = None
routes: tuple[Route, ...] = ()
tick: tuple[Callable[[Any, Connection], None], ...] = () # (hub, conn)
file_refs: Callable[[Connection], set[str]] | None = None # sha файлов, нужных командам
on_project_created: Callable[[Connection, dict, str], dict | None] | None = None # project, author → пакет
mcp_server: Callable[[], Any] | None = None # FastMCP модуля
cli_register: Callable[[Any], None] | None = None # subparsers
cli_run: Callable[[Any], int | None] | None = None # None — не своя подкоманда

View File

@ -1,18 +1,11 @@
"""Проекты-метки моста: таблица project, проверка метки, базовая JQL из меток, доски, миграция v3 → v4."""
import logging
import re
"""Проекты-метки: таблица project, проверка метки, создание и список."""
import unicodedata
from . import db, settings
from . import db
log = logging.getLogger(__name__)
LEGACY_LABEL = "AWPCA"
LEGACY_NAME = "АРМ КА"
MAX_LEN = 100
BOARD_TYPES = ("scrum", "kanban")
_FORBIDDEN = ',"\'()\\'
_FIELDS = "label, name, board_id, board_type, created_at"
_FIELDS = "label, name, board_id, board_type, created_at" # долг hub-modules: проекты → модуль jira (change wiki)
class ProjectError(ValueError):
@ -47,6 +40,7 @@ def validate(label, name, board_id) -> tuple[str, str, int | None]:
raise ProjectError("name", "ожидается непустая строка")
if len(name.strip()) > MAX_LEN:
raise ProjectError("name", f"длиннее {MAX_LEN} символов")
# долг hub-modules: проекты → модуль jira (change wiki)
if board_id is not None and (not isinstance(board_id, int) or isinstance(board_id, bool) or board_id <= 0):
raise ProjectError("board_id", "ожидается целое > 0 или null")
return label, name.strip(), board_id
@ -62,12 +56,14 @@ def create(conn, label: str, name: str, board_id: int | None = None) -> dict:
with db.transaction(conn):
if label.casefold() in {x.casefold() for x in labels(conn)}:
raise ProjectExists(label)
# долг hub-modules: проекты → модуль jira (change wiki)
conn.execute("INSERT INTO project(label, name, board_id, board_type, created_at) VALUES (?, ?, ?, NULL, ?)",
(label, name, board_id, db.now()))
return get(conn, label)
def list_projects(conn) -> list[dict]:
# долг hub-modules: проекты → модуль jira (change wiki)
rows = conn.execute(
f"SELECT {_FIELDS}, (SELECT COUNT(*) FROM issue i WHERE EXISTS "
"(SELECT 1 FROM json_each(i.labels) WHERE value = project.label)) AS issues_count "
@ -77,96 +73,9 @@ def list_projects(conn) -> list[dict]:
def light_list(conn) -> list[dict]:
"""Проекты без issues_count (label, board_id, board_type), порядок как у list_projects."""
# долг hub-modules: проекты → модуль jira (change wiki)
return [dict(r) for r in conn.execute("SELECT label, board_id, board_type FROM project ORDER BY created_at, label")]
def labels(conn) -> list[str]:
return [r["label"] for r in conn.execute("SELECT label FROM project ORDER BY created_at, label")]
def base_jql(conn) -> str | None:
"""Общая JQL обновления: все метки проектов; без проектов — None."""
found = labels(conn)
if not found:
return None
key = settings.get(conn, "project_key") or "AURORA"
return f"project = {key} AND labels in ({', '.join(f'"{x}"' for x in found)})"
def boards(conn) -> list[dict]:
"""Доски проектов без повторов (порядок первого проекта). type — None, если тип неизвестен хотя бы у одного
проекта доски (агент тогда запросит его, record_board_types проставит всем); иначе первый известный."""
out: dict[int, str | None] = {}
unknown: set[int] = set()
for r in conn.execute("SELECT board_id, board_type FROM project WHERE board_id IS NOT NULL ORDER BY created_at, label"):
out.setdefault(r["board_id"], r["board_type"])
if r["board_type"] is None:
unknown.add(r["board_id"])
return [{"id": i, "type": None if i in unknown else t} for i, t in out.items()]
def record_board_types(conn, found) -> int:
"""Записать типы досок {"<id>": "scrum" | "kanban"} всем проектам с этой доской; прочее пропустить."""
if not isinstance(found, dict):
return 0
updated = 0
for raw, kind in found.items():
if kind not in BOARD_TYPES:
continue
try:
board = int(raw)
except (TypeError, ValueError):
continue
updated += conn.execute("UPDATE project SET board_type = ? WHERE board_id = ?", (kind, board)).rowcount
return updated
_LEGACY = re.compile(r"\blabels\s*(?:=\s*(?P<one>\S+)|in\s*\((?P<many>[^()]*)\))\s*$", re.IGNORECASE)
def parse_legacy_labels(jql) -> list[str] | None:
"""Метки из прежней base_jql (`labels = X` / `labels in (X, Y)` в конце); не разобрана — None."""
m = _LEGACY.search(jql) if isinstance(jql, str) else None
if not m:
return None
out: list[str] = []
for raw in ([m["one"]] if m["one"] is not None else m["many"].split(",")):
label = raw.strip().strip('"')
if _label_error(label):
return None
if label.casefold() not in {x.casefold() for x in out}:
out.append(label)
return out or None
def ensure_legacy(conn, board_id: int | None) -> bool:
"""Завести AWPCA «АРМ КА» с доской, если проекта с меткой awpca (без учёта регистра) нет; True — заведён.
Неверная доска из конфига — проект без доски."""
if LEGACY_LABEL.casefold() in {x.casefold() for x in labels(conn)}:
return False
try:
validate(LEGACY_LABEL, LEGACY_NAME, board_id)
except ProjectError:
board_id = None
create(conn, LEGACY_LABEL, LEGACY_NAME, board_id)
return True
def migrate_v3(conn) -> None:
"""Миграция v3 → v4: проекты из прежней base_jql, доска — единственному проекту, очередь и журнал — на новую JQL."""
old = settings.get(conn, "base_jql")
found = parse_legacy_labels(old)
if found is None:
log.warning("миграция v4: base_jql не разобрана (%r) — проектов нет, заведите их POST /projects", old)
return
board = settings.get(conn, "board_id")
board = board if isinstance(board, int) and not isinstance(board, bool) and board > 0 else None
if board is not None and len(found) > 1:
log.warning("миграция v4: доска %s не отдана ни одному проекту — меток несколько: %s", board, ", ".join(found))
board = None
for label in found:
create(conn, label, LEGACY_NAME if label == LEGACY_LABEL else label, board)
new = base_jql(conn)
conn.execute("UPDATE sync_run SET query = ? WHERE query = ?", (new, old))
conn.execute("UPDATE command SET params = json_set(params, '$.jql', ?) WHERE type = 'refresh' "
"AND status IN ('queued', 'claimed') AND json_extract(params, '$.jql') = ?", (new, old))

View File

@ -9,6 +9,8 @@ from . import backup as _backup # noqa: F401 (имя модуля занято
from ..modules.jira import commands as C
from . import db, files, projects, settings
from ..modules.jira import attachments, history, mirror
from ..modules.jira import projects as jira_projects
from ..modules.jira import settings as jira_settings # временно: R-1, до Task 4
log = logging.getLogger(__name__)
@ -179,7 +181,7 @@ def create_batch(conn, title, author, cmds, files_dir=None, project=None) -> dic
def _insert_batch(conn, title, author, cmds, project=None) -> dict:
"""Вставка пакета без проверок и хвоста; транзакция своя (вложенная идёт через SAVEPOINT)."""
est = C.estimate([c["type"] for c in cmds], settings.pacing(conn))
est = C.estimate([c["type"] for c in cmds], jira_settings.pacing(conn))
with db.transaction(conn):
cur = conn.execute("INSERT INTO batch(title, author, created_at, estimate_seconds, project) VALUES (?, ?, ?, ?, ?)",
(title, author or "api", db.now(), est, project))
@ -231,7 +233,7 @@ def batch_view(conn, batch_id, with_commands=True) -> dict | None:
if b is None:
return None
cmds = [_cmd(r) for r in conn.execute("SELECT * FROM command WHERE batch_id = ? ORDER BY seq", (batch_id,))]
pacing = settings.pacing(conn)
pacing = jira_settings.pacing(conn)
d = dict(b)
d["status"] = _status_of(cmds, b["cancelled_at"])
d["counts"] = {s: sum(1 for c in cmds if c["status"] == s) for s in sorted({c["status"] for c in cmds})}
@ -315,7 +317,7 @@ def deliver_params(conn, cmd, files_dir) -> dict:
если файла уже нет (решение 5); refresh — boards, доски проектов (design #7), и history — отметка журнала
(change-history). В базе params остаются без них: повтор команды проходит проверку как новая."""
if cmd["type"] == "refresh":
out = {**cmd["params"], "boards": projects.boards(conn)}
out = {**cmd["params"], "boards": jira_projects.boards(conn)}
hist = history.delivery_param(conn, cmd["params"]) # отметка журнала (change-history, design #5)
if hist is not None:
out["history"] = hist
@ -335,7 +337,7 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non
(now, None if agent_version is None else str(agent_version), settings_version, last_jira_call_at))
cmd = _redeliver(conn, now)
if cmd is not None:
pause = int(settings.pacing(conn).get(cmd["type"], 0))
pause = int(jira_settings.pacing(conn).get(cmd["type"], 0))
conn.execute("UPDATE command SET claimed_at = ?, pause_seconds = ?, deliveries = deliveries + 1 WHERE id = ?",
(now, pause, cmd["id"]))
if cmd["type"] == "refresh":
@ -349,7 +351,7 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non
if cmd is None:
conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1")
return None
pause = int(settings.pacing(conn).get(cmd["type"], 0))
pause = int(jira_settings.pacing(conn).get(cmd["type"], 0))
first = conn.execute("SELECT COUNT(*) AS n FROM command WHERE batch_id = ? AND status != 'queued'",
(cmd["batch_id"],)).fetchone()["n"] == 0
has_writes = conn.execute("SELECT 1 FROM command WHERE batch_id = ? AND type NOT IN ('refresh', 'fetch_attachment') LIMIT 1",
@ -379,7 +381,7 @@ def is_full_refresh(conn, command_id) -> bool:
if not command_id:
return False
cmd = get_command(conn, command_id)
base = projects.base_jql(conn)
base = jira_projects.base_jql(conn)
return bool(base and cmd and cmd["type"] == "refresh" and cmd["params"].get("jql") == base)
@ -433,7 +435,7 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls=
conn.execute("UPDATE sync_run SET finished_at = ?, jira_calls = ?, rate_limited = ? WHERE command_id = ?",
(now, int(jira_calls or 0), int(rate_limited or 0), command_id))
if isinstance(result, dict) and "boards" in result:
projects.record_board_types(conn, result["boards"]) # в любом статусе; без ключа (агент v11) типы не трогаем
jira_projects.record_board_types(conn, result["boards"]) # в любом статусе; без ключа (агент v11) типы не трогаем
if status == "done" and is_full_refresh(conn, command_id) and history.accepted(result):
conn.execute("UPDATE sync_run SET history_at = ? WHERE command_id = ?", (now, command_id))
if status == "done" and is_full_refresh(conn, command_id):
@ -524,7 +526,7 @@ def agent_view(conn) -> dict:
def maybe_auto_refresh(conn) -> dict | None:
jql = projects.base_jql(conn)
jql = jira_projects.base_jql(conn)
if jql is None:
return None
minutes = int(settings.get(conn, "auto_refresh_minutes") or 0)

View File

@ -0,0 +1,45 @@
"""Реестр модулей хаба. Список собирается лениво: ядро не импортирует модули при импорте."""
from .modules import Module
_cache: list[Module] | None = None
def modules() -> list[Module]:
global _cache
if _cache is None:
from ..modules import jira # внутри функции: ядро не зависит от модулей при импорте
from . import settings
mods = [jira.MODULE]
# маршруты ядра сюда попадут в Task 5, когда api перестанет быть владельцем всех маршрутов
check(mods, [], settings.CORE_DEFAULTS)
_cache = mods
return _cache
def module(name: str) -> Module | None:
return next((m for m in modules() if m.name == name), None)
def module_of(ctype: str) -> Module | None:
"""Модуль, владеющий типом команды."""
return next((m for m in modules() if m.commands and ctype in m.commands.types), None)
def check(mods: list[Module], core_routes, core_settings: dict) -> None:
"""Проверить, что модули не делят тип команды, ключ настроек и маршрут между собой и с ядром."""
types: dict[str, str] = {}
keys: dict[str, str] = {key: "ядро" for key in core_settings}
routes: dict[tuple[str, str], str] = {(r[0], r[1]): "ядро" for r in core_routes}
for m in mods:
for ctype in sorted(m.commands.types) if m.commands else ():
if ctype in types:
raise RuntimeError(f"тип {ctype}: модули {types[ctype]} и {m.name}")
types[ctype] = m.name
for key in m.settings_defaults:
if key in keys:
raise RuntimeError(f"ключ настроек {key}: {keys[key]} и модуль {m.name}")
keys[key] = m.name
for r in m.routes:
if (r[0], r[1]) in routes:
raise RuntimeError(f"маршрут {r[0]} {r[1]}: {routes[(r[0], r[1])]} и модуль {m.name}")
routes[(r[0], r[1])] = m.name

View File

@ -1,41 +1,22 @@
"""Настройки хаба: пары ключ → JSON в таблице setting. Любая правка поднимает settings_version,
агент перечитывает настройки, когда видит новую версию в ответе claim."""
агент перечитывает настройки, когда видит новую версию в ответе claim. Ключи модулей приходят из реестра."""
import json
import os
import secrets
DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30,
"ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "fetch_attachment": 2, "refresh": 2}
from . import registry
DEFAULTS = {
"project_key": "AURORA",
"pacing": DEFAULT_PACING,
CORE_DEFAULTS = {
"backup_keep": 30,
"auto_refresh_minutes": 60,
"min_agent_version": 1,
"port": 8790,
"settings_version": 1,
"initialized": False,
"issue_types": {},
"epic_name_field_id": None,
"field_ids": {},
"epic_link": {},
"priority_map": {},
"priority_value_key": "name",
"labels": [],
"status_labels": {},
"kind_labels": {},
"comment_marker": "",
"transitions_map": {},
"link_types": {},
"relay_dir": None,
"closed_statuses": ["Готово", "Снято"], # статусы Jira закрытых задач: список и доска приложения (hide-closed)
}
# Что получает агент (без токенов и служебных ключей хаба)
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"]
# Ключи ядра в ответе агенту: идут после ключей модулей
CORE_AGENT_KEYS = ["min_agent_version", "settings_version"]
PROTECTED = {"settings_version", "schema_version", "tokens"}
@ -44,32 +25,33 @@ def _is_int(v) -> bool:
return isinstance(v, int) and not isinstance(v, bool)
def defaults() -> dict:
"""Умолчания ядра и всех модулей."""
out = dict(CORE_DEFAULTS)
for m in registry.modules():
out.update(m.settings_defaults)
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 validate(key, value) -> None:
"""Проверить значение настройки до записи; ValueError — на 422 в API.
base_jql и board_id не пишутся: JQL собирается из проектов, доска — свойство проекта;
pacing — объект {тип команды: целое >= 0} только с известными типами команд;
backup_keep, auto_refresh_minutes, min_agent_version — целые >= 0; port — целое 1..65535;
relay_dir — null или абсолютный путь строкой;
остальные ключи — по типу значения по умолчанию (объект, список, строка, флаг), если оно не None;
Сначала ключи ядра: backup_keep, min_agent_version — целые >= 0; port — целое 1..65535;
relay_dir — null или абсолютный путь строкой. Затем validate_setting модулей (их ключи).
Остальные ключи — по типу значения по умолчанию (объект, список, строка, флаг), если оно не None;
неизвестные ключи и ключи с умолчанием None принимаются как есть."""
if key == "base_jql":
raise ValueError("базовая JQL собирается из проектов (GET /projects)")
if key == "board_id":
raise ValueError("доска Jira — свойство проекта (POST /projects)")
if key == "pacing":
if not isinstance(value, dict):
raise ValueError("pacing — объект {тип команды: пауза в секундах}")
for ctype, pause in value.items():
if ctype not in DEFAULT_PACING:
raise ValueError(f"pacing: неизвестный тип команды «{ctype}»")
if not _is_int(pause) or pause < 0:
raise ValueError(f"pacing.{ctype}: ожидается целое >= 0")
return
if key == "port":
if not _is_int(value) or not 1 <= value <= 65535:
raise ValueError("port — целое 1..65535")
return
if key in ("backup_keep", "auto_refresh_minutes", "min_agent_version"):
if key in ("backup_keep", "min_agent_version"):
if not _is_int(value) or value < 0:
raise ValueError(f"{key} — целое >= 0")
return
@ -77,14 +59,17 @@ def validate(key, value) -> None:
if value is not None and (not isinstance(value, str) or not os.path.isabs(value)):
raise ValueError("relay_dir — абсолютный путь папки реле или null")
return
default = DEFAULTS.get(key)
for m in registry.modules():
if m.validate_setting and m.validate_setting(key, value):
return
default = defaults().get(key)
if default is not None and type(value) is not type(default):
names = {dict: "объект", list: "список", str: "строка", bool: "true/false", int: "целое"}
raise ValueError(f"{key} — {names.get(type(default), type(default).__name__)}")
def ensure_defaults(conn) -> None:
for key, value in DEFAULTS.items():
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', ?)",
@ -121,56 +106,8 @@ def set_(conn, key, value, bump=True) -> int:
def for_agent(conn) -> dict:
return {key: get(conn, key) for key in AGENT_KEYS}
return {key: get(conn, key) for key in agent_keys()}
def tokens(conn) -> dict:
return get(conn, "tokens")
def pacing(conn) -> dict:
return {**DEFAULT_PACING, **(get(conn, "pacing") or {})}
def pacing_from_config(p: dict) -> dict:
"""Старая форма pacing (issue_seconds / comment_seconds) → паузы по типам команд."""
issue, comment = p.get("issue_seconds", 120), p.get("comment_seconds", 30)
return {"ensure_issue": issue, "ensure_fields": issue, "ensure_comment": comment, "ensure_status": comment,
"ensure_link": comment, "ensure_sprint": comment, "ensure_attachment": comment, "fetch_attachment": 2, "refresh": 2}
def _issue_types(cfg: dict) -> dict:
"""issue_types из ключей конфига issue_type_<тип>[_id]: task и epic — всегда, bug — только если в конфиге есть
issue_type_bug или issue_type_bug_id (bug-and-attachments, data-model «Настройки»)."""
out = {t: {"name": cfg.get(f"issue_type_{t}"), "id": cfg.get(f"issue_type_{t}_id")} for t in ("task", "epic")}
if "issue_type_bug" in cfg or "issue_type_bug_id" in cfg:
out["bug"] = {"name": cfg.get("issue_type_bug"), "id": cfg.get("issue_type_bug_id")}
return out
def init_from_config(conn, cfg: dict, force=False) -> list[str]:
"""Перенести из config.example.json всё, что относится к проекту Jira. Учётные данные и адрес Jira
(auth, base_url, verify_ssl, ca_bundle) остаются у агента и сюда не попадают."""
if get(conn, "initialized") and not force:
raise RuntimeError("хаб уже инициализирован; повторный init — с --force")
project = cfg["project_key"]
values = {
"project_key": project,
"issue_types": _issue_types(cfg),
"epic_name_field_id": cfg.get("epic_name_field_id"),
"field_ids": cfg.get("fields", {}),
"epic_link": cfg.get("epic_link", {}),
"priority_map": cfg.get("priority_map", {}),
"priority_value_key": cfg.get("priority_value_key", "name"),
"labels": cfg.get("labels", []),
"status_labels": cfg.get("status_labels", {}),
"kind_labels": cfg.get("kind_labels", {}),
"comment_marker": cfg.get("comment_marker", ""),
"transitions_map": cfg.get("transitions", {}).get("map", {}),
"link_types": cfg.get("link_types", {}),
"pacing": pacing_from_config(cfg.get("pacing", {})),
}
for key, value in values.items():
set_(conn, key, value, bump=False)
set_(conn, "initialized", True)
return sorted(values)

View File

@ -0,0 +1,13 @@
"""Модуль jira: зеркало задач Jira, очередь команд агенту, вложения, журнал изменений."""
from ...core.modules import Module
from . import schema, settings
MODULE = Module(
name="jira",
schema=schema.SQL,
settings_defaults=settings.DEFAULTS,
agent_keys=tuple(settings.AGENT_KEYS),
validate_setting=settings.validate_setting,
settings_view=settings.settings_view,
on_init=settings.on_init,
)

View File

@ -3,6 +3,7 @@
проверка «результат уже виден в зеркале» для подтверждения по состоянию."""
import re
from ...core.errors import CommandError # noqa: F401 (реэкспорт: прежнее место)
from . import mirror
TEMP_KEY = re.compile(r"^new:\d+$")
@ -44,10 +45,6 @@ LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"}
BOOL_PARAMS = {"replace"}
class CommandError(ValueError):
"""Ошибка валидации пакета — наружу уходит как 422."""
def is_temp(key) -> bool:
return isinstance(key, str) and bool(TEMP_KEY.match(key))

View File

@ -1,11 +1,35 @@
"""Журнал изменений задач (issue_change): приём от агента, логическое имя поля, отметка «с какого момента» для
refresh. Пишет таблицу только приём журнала (POST /agent/history); API и MCP её только читают (spec data-model)."""
from datetime import timedelta
from datetime import datetime, timedelta, timezone
from ...core import db, projects, settings
from . import projects
from ...core import db, settings
SINCE_MARGIN = timedelta(minutes=15) # расхождение часов Mac mini и Jira и длительность прогона (design #5)
def jira_utc(value) -> str | None:
"""Время Jira (`2026-10-22T13:05:11.000+0300`, `+03:00`, `Z`, с дробной частью или без) → ISO-8601 UTC в формате
now(); без смещения — UTC; неразбираемое — None."""
if not isinstance(value, str) or not value:
return None
s = value[:-1] + "+00:00" if value.endswith("Z") else value
if len(s) > 5 and s[-5] in "+-" and s[-4:].isdigit(): # ±HHMM → ±HH:MM
s = s[:-2] + ":" + s[-2:]
try:
d = datetime.fromisoformat(s)
d = d if d.tzinfo else d.replace(tzinfo=timezone.utc)
return d.astimezone(timezone.utc).replace(microsecond=0).isoformat()
except (ValueError, OverflowError): # время за краем диапазона datetime — тоже «не разобралось»
return None
def fill_comment_utc(conn) -> None:
for r in conn.execute("SELECT key, comment_id, created FROM issue_comment").fetchall():
conn.execute("UPDATE issue_comment SET created_at = ? WHERE key = ? AND comment_id = ?",
(jira_utc(r["created"]), r["key"], r["comment_id"]))
BUILTIN_FIELDS = ("status", "description", "summary", "assignee", "priority", "duedate", "labels", "resolution")
_REQUIRED = ("history_id", "key", "created", "field_name")
_OPTIONAL = ("author", "field_id", "from", "from_string", "to", "to_string")
@ -56,7 +80,7 @@ def record_changes(conn, changes: list[dict]) -> dict:
cur = conn.execute(
"INSERT OR IGNORE INTO issue_change(history_id, seq, key, at, created, author, field, field_id, field_name, "
"from_value, from_string, to_value, to_string, fetched_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(c["history_id"], c["seq"], c["key"], db.jira_utc(c["created"]), c["created"], c.get("author"),
(c["history_id"], c["seq"], c["key"], jira_utc(c["created"]), c["created"], c.get("author"),
logical_field(c, field_ids, epic_link), c.get("field_id"), c["field_name"], c.get("from"),
c.get("from_string"), c.get("to"), c.get("to_string"), now))
new += cur.rowcount

View File

@ -86,14 +86,14 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authorit
"INSERT OR REPLACE INTO issue_comment(key, comment_id, author, created, updated, body, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
[(it["key"], c["id"], c.get("author"), c.get("created"), c.get("updated"), c.get("body") or "",
db.jira_utc(c.get("created"))) for c in it["comments"]])
history.jira_utc(c.get("created"))) for c in it["comments"]])
if isinstance(it.get("attachments"), list): # без поля (агент до v14) вложения не трогаем; attachment_file не трогаем никогда
conn.execute("DELETE FROM issue_attachment WHERE key = ?", (it["key"],))
conn.executemany( # REPLACE по attachment_id: вложение, переехавшее на другой ключ, перезаписывается
"INSERT OR REPLACE INTO issue_attachment(attachment_id, key, filename, size, mime_type, author, created, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[(a["id"], it["key"], a["filename"], a.get("size"), a.get("mime_type"), a.get("author"), a.get("created"),
db.jira_utc(a.get("created"))) for a in it["attachments"]])
history.jira_utc(a.get("created"))) for a in it["attachments"]])
conn.executemany("DELETE FROM issue_link WHERE source_key = ?", [(k,) for k in keys])
for link in links:
conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)",

View File

@ -0,0 +1,100 @@
"""Проекты-метки в модуле jira: базовая JQL из меток, доски, миграция v3 → v4, проект по умолчанию."""
import logging
import re
from ...core import settings
from ...core.projects import ProjectError, _label_error, create, labels, validate
log = logging.getLogger(__name__)
LEGACY_LABEL = "AWPCA"
LEGACY_NAME = "АРМ КА"
BOARD_TYPES = ("scrum", "kanban")
def base_jql(conn) -> str | None:
"""Общая JQL обновления: все метки проектов; без проектов — None."""
found = labels(conn)
if not found:
return None
key = settings.get(conn, "project_key") or "AURORA"
return f"project = {key} AND labels in ({', '.join(f'"{x}"' for x in found)})"
def boards(conn) -> list[dict]:
"""Доски проектов без повторов (порядок первого проекта). type — None, если тип неизвестен хотя бы у одного
проекта доски (агент тогда запросит его, record_board_types проставит всем); иначе первый известный."""
out: dict[int, str | None] = {}
unknown: set[int] = set()
for r in conn.execute("SELECT board_id, board_type FROM project WHERE board_id IS NOT NULL ORDER BY created_at, label"):
out.setdefault(r["board_id"], r["board_type"])
if r["board_type"] is None:
unknown.add(r["board_id"])
return [{"id": i, "type": None if i in unknown else t} for i, t in out.items()]
def record_board_types(conn, found) -> int:
"""Записать типы досок {"<id>": "scrum" | "kanban"} всем проектам с этой доской; прочее пропустить."""
if not isinstance(found, dict):
return 0
updated = 0
for raw, kind in found.items():
if kind not in BOARD_TYPES:
continue
try:
board = int(raw)
except (TypeError, ValueError):
continue
updated += conn.execute("UPDATE project SET board_type = ? WHERE board_id = ?", (kind, board)).rowcount
return updated
_LEGACY = re.compile(r"\blabels\s*(?:=\s*(?P<one>\S+)|in\s*\((?P<many>[^()]*)\))\s*$", re.IGNORECASE)
def parse_legacy_labels(jql) -> list[str] | None:
"""Метки из прежней base_jql (`labels = X` / `labels in (X, Y)` в конце); не разобрана — None."""
m = _LEGACY.search(jql) if isinstance(jql, str) else None
if not m:
return None
out: list[str] = []
for raw in ([m["one"]] if m["one"] is not None else m["many"].split(",")):
label = raw.strip().strip('"')
if _label_error(label):
return None
if label.casefold() not in {x.casefold() for x in out}:
out.append(label)
return out or None
def ensure_legacy(conn, board_id: int | None) -> bool:
"""Завести AWPCA «АРМ КА» с доской, если проекта с меткой awpca (без учёта регистра) нет; True — заведён.
Неверная доска из конфига — проект без доски."""
if LEGACY_LABEL.casefold() in {x.casefold() for x in labels(conn)}:
return False
try:
validate(LEGACY_LABEL, LEGACY_NAME, board_id)
except ProjectError:
board_id = None
create(conn, LEGACY_LABEL, LEGACY_NAME, board_id)
return True
def migrate_v3(conn) -> None:
"""Миграция v3 → v4: проекты из прежней base_jql, доска — единственному проекту, очередь и журнал — на новую JQL."""
old = settings.get(conn, "base_jql")
found = parse_legacy_labels(old)
if found is None:
log.warning("миграция v4: base_jql не разобрана (%r) — проектов нет, заведите их POST /projects", old)
return
board = settings.get(conn, "board_id")
board = board if isinstance(board, int) and not isinstance(board, bool) and board > 0 else None
if board is not None and len(found) > 1:
log.warning("миграция v4: доска %s не отдана ни одному проекту — меток несколько: %s", board, ", ".join(found))
board = None
for label in found:
create(conn, label, LEGACY_NAME if label == LEGACY_LABEL else label, board)
new = base_jql(conn)
conn.execute("UPDATE sync_run SET query = ? WHERE query = ?", (new, old))
conn.execute("UPDATE command SET params = json_set(params, '$.jql', ?) WHERE type = 'refresh' "
"AND status IN ('queued', 'claimed') AND json_extract(params, '$.jql') = ?", (new, old))

View File

@ -0,0 +1,101 @@
"""Схема базы модуля jira: зеркало задач, журнал изменений, вложения, заметки, прогоны обновления."""
SQL = """
CREATE TABLE IF NOT EXISTS issue (
key TEXT PRIMARY KEY,
id TEXT,
issue_type TEXT,
summary TEXT,
description TEXT,
status TEXT,
priority TEXT,
assignee TEXT,
reporter TEXT,
duedate TEXT,
planned_start TEXT,
planned_end TEXT,
epic_key TEXT,
labels TEXT NOT NULL DEFAULT '[]',
sprint TEXT,
created TEXT,
jira_updated TEXT,
fetched_at TEXT NOT NULL,
neighbor INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS issue_link (
source_key TEXT NOT NULL,
target_key TEXT NOT NULL,
link_type TEXT NOT NULL,
PRIMARY KEY (source_key, target_key, link_type)
);
CREATE INDEX IF NOT EXISTS ix_issue_link_target ON issue_link(target_key);
CREATE TABLE IF NOT EXISTS issue_comment (
key TEXT NOT NULL,
comment_id TEXT NOT NULL,
author TEXT,
created TEXT,
updated TEXT,
body TEXT NOT NULL DEFAULT '',
created_at TEXT,
PRIMARY KEY (key, comment_id)
);
CREATE TABLE IF NOT EXISTS issue_attachment (
attachment_id TEXT PRIMARY KEY,
key TEXT NOT NULL,
filename TEXT NOT NULL,
size INTEGER,
mime_type TEXT,
author TEXT,
created TEXT,
created_at TEXT
);
CREATE INDEX IF NOT EXISTS issue_attachment_key ON issue_attachment(key);
CREATE TABLE IF NOT EXISTS attachment_file (
attachment_id TEXT PRIMARY KEY,
file TEXT NOT NULL,
size INTEGER NOT NULL,
fetched_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS issue_change (
history_id TEXT NOT NULL,
seq INTEGER NOT NULL,
key TEXT NOT NULL,
at TEXT,
created TEXT NOT NULL,
author TEXT,
field TEXT NOT NULL,
field_id TEXT,
field_name TEXT NOT NULL,
from_value TEXT,
from_string TEXT,
to_value TEXT,
to_string TEXT,
fetched_at TEXT NOT NULL,
PRIMARY KEY (history_id, seq)
);
CREATE INDEX IF NOT EXISTS ix_issue_change_at ON issue_change(at);
CREATE INDEX IF NOT EXISTS ix_issue_change_key ON issue_change(key, at);
CREATE TABLE IF NOT EXISTS issue_note (
key TEXT PRIMARY KEY,
fact_status TEXT,
work_kind TEXT,
done_text TEXT,
rest_text TEXT,
owner_note TEXT,
updated_at TEXT,
updated_by TEXT
);
CREATE TABLE IF NOT EXISTS sync_run (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
finished_at TEXT,
kind TEXT NOT NULL,
query TEXT,
issues_count INTEGER NOT NULL DEFAULT 0,
links_count INTEGER NOT NULL DEFAULT 0,
jira_calls INTEGER NOT NULL DEFAULT 0,
rate_limited INTEGER NOT NULL DEFAULT 0,
command_id INTEGER,
history_at TEXT
);
"""

View File

@ -0,0 +1,122 @@
"""Настройки модуля jira: умолчания, ключи для агента, проверка значений, перенос из config.example.json."""
from ...core import projects as core_projects
from ...core import settings as core_settings
from . import projects
DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30,
"ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "fetch_attachment": 2, "refresh": 2}
DEFAULTS = {
"project_key": "AURORA",
"pacing": DEFAULT_PACING,
"auto_refresh_minutes": 60,
"issue_types": {},
"epic_name_field_id": None,
"field_ids": {},
"epic_link": {},
"priority_map": {},
"priority_value_key": "name",
"labels": [],
"status_labels": {},
"kind_labels": {},
"comment_marker": "",
"transitions_map": {},
"link_types": {},
"closed_statuses": ["Готово", "Снято"], # статусы Jira закрытых задач: список и доска приложения (hide-closed)
}
# Что получает агент от модуля (без токенов и служебных ключей хаба); min_agent_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"]
def _is_int(v) -> bool:
return isinstance(v, int) and not isinstance(v, bool)
def validate_setting(key, value) -> bool:
"""True — ключ принадлежит модулю и проверен; ValueError — значение плохое (422 в API).
base_jql и board_id не пишутся: JQL собирается из проектов, доска — свойство проекта;
pacing — объект {тип команды: целое >= 0} только с известными типами команд;
auto_refresh_minutes — целое >= 0."""
if key == "base_jql":
raise ValueError("базовая JQL собирается из проектов (GET /projects)")
if key == "board_id":
raise ValueError("доска Jira — свойство проекта (POST /projects)")
if key == "pacing":
if not isinstance(value, dict):
raise ValueError("pacing — объект {тип команды: пауза в секундах}")
for ctype, pause in value.items():
if ctype not in DEFAULT_PACING:
raise ValueError(f"pacing: неизвестный тип команды «{ctype}»")
if not _is_int(pause) or pause < 0:
raise ValueError(f"pacing.{ctype}: ожидается целое >= 0")
return True
if key == "auto_refresh_minutes":
if not _is_int(value) or value < 0:
raise ValueError(f"{key} — целое >= 0")
return True
return False
def settings_view(conn) -> dict:
"""Вычисляемые поля GET/PUT /settings."""
return {"base_jql": projects.base_jql(conn)}
def pacing(conn) -> dict:
return {**DEFAULT_PACING, **(core_settings.get(conn, "pacing") or {})}
def pacing_from_config(p: dict) -> dict:
"""Старая форма pacing (issue_seconds / comment_seconds) → паузы по типам команд."""
issue, comment = p.get("issue_seconds", 120), p.get("comment_seconds", 30)
return {"ensure_issue": issue, "ensure_fields": issue, "ensure_comment": comment, "ensure_status": comment,
"ensure_link": comment, "ensure_sprint": comment, "ensure_attachment": comment, "fetch_attachment": 2, "refresh": 2}
def _issue_types(cfg: dict) -> dict:
"""issue_types из ключей конфига issue_type_<тип>[_id]: task и epic — всегда, bug — только если в конфиге есть
issue_type_bug или issue_type_bug_id (bug-and-attachments, data-model «Настройки»)."""
out = {t: {"name": cfg.get(f"issue_type_{t}"), "id": cfg.get(f"issue_type_{t}_id")} for t in ("task", "epic")}
if "issue_type_bug" in cfg or "issue_type_bug_id" in cfg:
out["bug"] = {"name": cfg.get("issue_type_bug"), "id": cfg.get("issue_type_bug_id")}
return out
def init_from_config(conn, cfg: dict, force=False) -> list[str]:
"""Перенести из config.example.json всё, что относится к проекту Jira. Учётные данные и адрес Jira
(auth, base_url, verify_ssl, ca_bundle) остаются у агента и сюда не попадают."""
if core_settings.get(conn, "initialized") and not force:
raise RuntimeError("хаб уже инициализирован; повторный init — с --force")
project = cfg["project_key"]
values = {
"project_key": project,
"issue_types": _issue_types(cfg),
"epic_name_field_id": cfg.get("epic_name_field_id"),
"field_ids": cfg.get("fields", {}),
"epic_link": cfg.get("epic_link", {}),
"priority_map": cfg.get("priority_map", {}),
"priority_value_key": cfg.get("priority_value_key", "name"),
"labels": cfg.get("labels", []),
"status_labels": cfg.get("status_labels", {}),
"kind_labels": cfg.get("kind_labels", {}),
"comment_marker": cfg.get("comment_marker", ""),
"transitions_map": cfg.get("transitions", {}).get("map", {}),
"link_types": cfg.get("link_types", {}),
"pacing": pacing_from_config(cfg.get("pacing", {})),
}
for key, value in values.items():
core_settings.set_(conn, key, value, bump=False)
core_settings.set_(conn, "initialized", True)
return sorted(values)
def on_init(conn, cfg: dict, force: bool) -> list[str]:
"""init --config: настройки Jira и проект AWPCA; строки для вывода."""
lines = ["перенесено: " + ", ".join(init_from_config(conn, cfg, force=force))]
if projects.ensure_legacy(conn, cfg.get("sprints", {}).get("board_id")):
board = core_projects.get(conn, projects.LEGACY_LABEL)["board_id"]
lines.append(f"проект {projects.LEGACY_LABEL} «{projects.LEGACY_NAME}», доска {board}")
return lines

View File

@ -3,6 +3,7 @@ ResourceWarning «unclosed database» (hub-hygiene-d, решение 14; M-9/O-4
import pytest
from jira_hub.core import db, projects
from jira_hub.modules.jira import projects as jira_projects
@pytest.fixture
@ -25,9 +26,9 @@ def add_project():
"""add_project(conn, label="AWPCA", name=None, board_id=4216, board_type="scrum") -> dict: завести проект."""
def make(conn, label="AWPCA", name=None, board_id=4216, board_type="scrum"):
if name is None:
name = projects.LEGACY_NAME if label == projects.LEGACY_LABEL else label
name = jira_projects.LEGACY_NAME if label == jira_projects.LEGACY_LABEL else label
projects.create(conn, label, name, board_id)
if board_id is not None and board_type is not None:
projects.record_board_types(conn, {str(board_id): board_type})
jira_projects.record_board_types(conn, {str(board_id): board_type})
return projects.get(conn, label)
return make

View File

@ -31,7 +31,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(),
"protected": sorted(settings.PROTECTED)})

View File

@ -8,6 +8,7 @@ import threading
import pytest
from jira_hub.core import backup, db, paths, projects, queue, settings
from jira_hub.modules.jira import history
def test_connect_creates_schema(open_db):
@ -324,7 +325,7 @@ def test_migrates_v3_unparsed_jql_keeps_running(tmp_path, caplog):
("9999-12-31T23:00:00-0300", None), ("0001-01-01T00:00:00+0300", None), # M-1: выход за диапазон datetime
])
def test_jira_utc(raw, want):
assert db.jira_utc(raw) == want
assert history.jira_utc(raw) == want
def _v4_database(path):

View File

@ -2,6 +2,7 @@ import pytest
from jira_hub.core import projects, settings
from jira_hub.modules.jira import mirror
from jira_hub.modules.jira import projects as jira_projects
@pytest.fixture
@ -13,12 +14,12 @@ def conn(open_db):
def test_boards_type_unknown_if_any_project_unknown(conn): # I-1: новый проект на известной доске
projects.create(conn, "AWPCA", "a", 4216)
projects.record_board_types(conn, {"4216": "scrum"})
jira_projects.record_board_types(conn, {"4216": "scrum"})
projects.create(conn, "NEW", "n", 4216)
assert projects.get(conn, "NEW")["board_type"] is None
assert projects.boards(conn) == [{"id": 4216, "type": None}]
projects.record_board_types(conn, {"4216": "scrum"})
assert projects.boards(conn) == [{"id": 4216, "type": "scrum"}]
assert jira_projects.boards(conn) == [{"id": 4216, "type": None}]
jira_projects.record_board_types(conn, {"4216": "scrum"})
assert jira_projects.boards(conn) == [{"id": 4216, "type": "scrum"}]
assert [p["board_type"] for p in projects.list_projects(conn)] == ["scrum", "scrum"]
@ -26,7 +27,7 @@ def test_boards_diverged_types_first_by_order(conn):
projects.create(conn, "A", "a", 7); projects.create(conn, "B", "b", 7)
conn.execute("UPDATE project SET board_type = 'kanban' WHERE label = 'B'")
conn.execute("UPDATE project SET board_type = 'scrum' WHERE label = 'A'")
assert projects.boards(conn) == [{"id": 7, "type": "scrum"}]
assert jira_projects.boards(conn) == [{"id": 7, "type": "scrum"}]
def test_light_list_same_order_no_count(conn):
@ -84,32 +85,32 @@ def test_same_label_other_case(conn):
def test_base_jql(conn):
assert projects.base_jql(conn) is None
assert jira_projects.base_jql(conn) is None
projects.create(conn, "AWPCA", "АРМ КА"); projects.create(conn, "STOCKS_DRRK", "Запасы ДРРК")
assert projects.base_jql(conn) == 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")'
assert jira_projects.base_jql(conn) == 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")'
settings.set_(conn, "project_key", "AUR")
assert projects.base_jql(conn).startswith("project = AUR AND")
assert jira_projects.base_jql(conn).startswith("project = AUR AND")
def test_base_jql_single_and_reserved_words(conn):
projects.create(conn, "AWPCA", "АРМ КА")
assert projects.base_jql(conn) == 'project = AURORA AND labels in ("AWPCA")'
assert jira_projects.base_jql(conn) == 'project = AURORA AND labels in ("AWPCA")'
projects.create(conn, "EMPTY", "Пустой") # зарезервированное слово JQL — в кавычках
assert projects.base_jql(conn) == 'project = AURORA AND labels in ("AWPCA", "EMPTY")'
assert jira_projects.base_jql(conn) == 'project = AURORA AND labels in ("AWPCA", "EMPTY")'
def test_boards_dedup_and_types(conn):
projects.create(conn, "AWPCA", "a", 4216); projects.create(conn, "B", "b", 5120)
projects.create(conn, "C", "c", 4216); projects.create(conn, "D", "d")
projects.record_board_types(conn, {"4216": "scrum"})
assert projects.boards(conn) == [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}]
jira_projects.record_board_types(conn, {"4216": "scrum"})
assert jira_projects.boards(conn) == [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}]
assert projects.get(conn, "C")["board_type"] == "scrum" # тип — у всех проектов с этой доской
@pytest.mark.parametrize("boards", [{"5120": None}, {"5120": "simple"}, {"x": "kanban"}, ["5120"], None, {}])
def test_record_board_types_ignores(conn, boards):
projects.create(conn, "B", "b", 5120)
assert projects.record_board_types(conn, boards) == 0
assert jira_projects.record_board_types(conn, boards) == 0
assert projects.get(conn, "B")["board_type"] is None
@ -125,7 +126,7 @@ def test_record_board_types_ignores(conn, boards):
("", None), (None, None), (5, None),
])
def test_parse_legacy_labels(jql, labels):
assert projects.parse_legacy_labels(jql) == labels
assert jira_projects.parse_legacy_labels(jql) == labels
def test_add_project_fixture(conn, add_project):
@ -134,16 +135,16 @@ def test_add_project_fixture(conn, add_project):
def test_ensure_legacy(conn):
assert projects.ensure_legacy(conn, 4216) is True
assert jira_projects.ensure_legacy(conn, 4216) is True
assert projects.get(conn, "AWPCA")["board_id"] == 4216 and projects.get(conn, "AWPCA")["name"] == "АРМ КА"
assert projects.ensure_legacy(conn, 1) is False
assert jira_projects.ensure_legacy(conn, 1) is False
def test_ensure_legacy_skips_case_variant_and_bad_board(open_db):
c = open_db()
settings.ensure_defaults(c)
projects.create(c, "awpca", "x")
assert projects.ensure_legacy(c, 4216) is False
assert jira_projects.ensure_legacy(c, 4216) is False
c2 = open_db()
settings.ensure_defaults(c2)
assert projects.ensure_legacy(c2, "oops") is True and projects.get(c2, "AWPCA")["board_id"] is None
assert jira_projects.ensure_legacy(c2, "oops") is True and projects.get(c2, "AWPCA")["board_id"] is None

View File

@ -4,6 +4,7 @@ import pytest
from jira_hub.core import db, files, projects, queue, settings
from jira_hub.modules.jira import attachments, mirror
from jira_hub.modules.jira import projects as jira_projects
from jira_hub.modules.jira.commands import CommandError
from jira_hub.core.queue import ClaimedError
@ -424,7 +425,7 @@ def test_cancelled_batch_with_done_command_is_cancelled(conn):
def test_empty_full_refresh_keeps_neighbors(conn, add_project):
add_project(conn)
jql = projects.base_jql(conn)
jql = jira_projects.base_jql(conn)
mirror.apply_state(conn, [{"key": "A-1", "summary": "x", "labels": []}], [],
fetched_at="2020-01-01T00:00:00+00:00") # прочитана давно — до старта прогона
queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}])
@ -444,7 +445,7 @@ def test_cancel_finished_batch_stays_done(conn):
def test_redelivered_full_refresh_marks_neighbors_once(conn, add_project):
add_project(conn)
jql = projects.base_jql(conn)
jql = jira_projects.base_jql(conn)
mirror.apply_state(conn, [{"key": "OLD", "summary": "x", "labels": []}], [], fetched_at="2020-01-01T00:00:00+00:00")
queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}])
r = queue.claim(conn)
@ -948,7 +949,7 @@ def test_refresh_failed_result_records_board_types(conn, add_project): # R-3
def _full_run(conn, result, started_at=None, status="done"):
"""Полный refresh: поставить, выдать, отчитаться; started_at — подменить время начала прогона."""
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
c = queue.claim(conn)
if started_at:
conn.execute("UPDATE sync_run SET started_at = ? WHERE command_id = ?", (started_at, c["id"]))
@ -958,7 +959,7 @@ def _full_run(conn, result, started_at=None, status="done"):
def test_first_full_refresh_gets_backfill(conn, add_project): # сценарий «Первый refresh после выкатки»
add_project(conn)
b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
got = queue.claim(conn)
assert got["params"]["history"] == {
"jql": '(project = AURORA AND labels in ("AWPCA")) AND sprint in openSprints() AND statusCategory != Done'}
@ -969,7 +970,7 @@ def test_regular_refresh_gets_since(conn, add_project): # «Р
add_project(conn)
_full_run(conn, {"history": {"issues": 1, "changes": 2, "incomplete": []}}, "2026-10-22T09:00:00+00:00")
_full_run(conn, {"history": {"error": "HTTP 500"}}, "2026-10-22T10:00:00+00:00")
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
assert queue.claim(conn)["params"]["history"] == {"since": "2026-10-22T08:45:00Z"}
@ -977,10 +978,10 @@ def test_new_project_gets_since_and_backfill(conn, add_project): # «Н
add_project(conn)
_full_run(conn, {"history": {"issues": 0, "changes": 0, "incomplete": []}}, "2026-10-22T09:00:00+00:00")
add_project(conn, "STOCKS_DRRK", board_id=5120)
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
h = queue.claim(conn)["params"]["history"]
assert h["since"] == "2026-10-22T08:45:00Z"
assert h["jql"] == "(" + projects.base_jql(conn) + ") AND sprint in openSprints() AND statusCategory != Done"
assert h["jql"] == "(" + jira_projects.base_jql(conn) + ") AND sprint in openSprints() AND statusCategory != Done"
assert "STOCKS_DRRK" in h["jql"]
@ -1007,7 +1008,7 @@ def test_no_projects_no_history(conn):
def test_redelivery_recomputes_history(conn, add_project): # Review Focus 3
add_project(conn)
b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
first = queue.claim(conn)
again = queue.claim(conn) # отчёта нет — довыдача
assert again["id"] == first["id"] and again["params"]["history"] == first["params"]["history"]

85
tests/test_registry.py Normal file
View File

@ -0,0 +1,85 @@
"""Реестр модулей: состав, конфликты имён, границы импортов ядра."""
import ast
from pathlib import Path
import pytest
from jira_hub.core import registry
from jira_hub.core.modules import CommandHooks, Module
CORE = Path(__file__).resolve().parent.parent / "src" / "jira_hub" / "core"
# временно: R-1, сокращается в Task 4–6
PENDING = {"api.py", "cli.py", "mcp_server.py", "queue.py"}
def _noop(*a, **k):
return None
def _hooks(types):
names = [f for f in CommandHooks.__dataclass_fields__ if f not in ("types", "read_types")]
return CommandHooks(types=frozenset(types), read_types=frozenset(), **{n: _noop for n in names})
def test_jira_registered():
assert [m.name for m in registry.modules()] == ["jira"]
assert registry.module("jira").name == "jira"
assert registry.module("nope") is None
def test_command_type_conflict():
a = Module(name="jira", commands=_hooks({"refresh"}))
b = Module(name="x", commands=_hooks({"refresh"}))
with pytest.raises(RuntimeError) as e:
registry.check([a, b], [], {})
assert "refresh" in str(e.value) and "jira" in str(e.value) and "x" in str(e.value)
def test_settings_conflict():
with pytest.raises(RuntimeError, match="port"):
registry.check([Module(name="x", settings_defaults={"port": 1})], [], {"port": 8790})
def test_route_conflict():
core = [("GET", r"^/projects$", _noop, "client")]
m = Module(name="x", routes=(("GET", r"^/projects$", _noop, "client"),))
with pytest.raises(RuntimeError, match="/projects"):
registry.check([m], core, {})
def test_module_of(monkeypatch):
a = Module(name="a", commands=_hooks({"t1"}))
monkeypatch.setattr(registry, "_cache", [a])
assert registry.module_of("t1") is a and registry.module_of("t2") is None
def _module_imports(tree, top_level_only):
"""Импорты пакета jira_hub.modules (абсолютные и относительные из core/)."""
found = []
for n in (tree.body if top_level_only else ast.walk(tree)):
if isinstance(n, ast.Import):
names = [a.name for a in n.names]
elif isinstance(n, ast.ImportFrom):
prefix = {0: "", 1: "jira_hub.core.", 2: "jira_hub."}[n.level]
base = (prefix + (n.module or "")).rstrip(".")
names = [base] + [base + "." + a.name for a in n.names]
else:
continue
if any(x == "jira_hub.modules" or x.startswith("jira_hub.modules.") for x in names):
found.append(n)
return found
def test_core_imports():
dirty = set()
for path in sorted(CORE.glob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"))
if path.name in ("registry.py", "db.py"):
# только внутри функций: на верхнем уровне модуля импортов модулей нет
assert not _module_imports(tree, top_level_only=True), path.name
continue
if _module_imports(tree, top_level_only=False):
dirty.add(path.name)
assert dirty - PENDING == set(), f"ядро импортирует модули: {sorted(dirty - PENDING)}"
assert PENDING - dirty == set(), f"PENDING устарел, уберите: {sorted(PENDING - dirty)}"

View File

@ -4,6 +4,7 @@ from pathlib import Path
import pytest
from jira_hub.core import queue, settings
from jira_hub.modules.jira import settings as jira_settings
from jira_hub.modules.jira.commands import CommandError
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"
@ -25,9 +26,9 @@ def test_defaults_and_tokens(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.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 "base_jql" not in settings.agent_keys() and "board_id" in settings.agent_keys()
assert settings.for_agent(conn)["board_id"] is None
@ -53,18 +54,18 @@ def test_protected_keys(conn, key):
def test_init_from_config(conn):
cfg = json.loads(FIXTURE.read_text(encoding="utf-8"))
keys = settings.init_from_config(conn, cfg)
keys = jira_settings.init_from_config(conn, cfg)
assert "transitions_map" in keys and "base_jql" not in keys and "board_id" not in keys
assert "base_jql" not in settings.get_all(conn) and "board_id" not in settings.get_all(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") == settings.DEFAULT_PACING
assert settings.get(conn, "pacing") == jira_settings.DEFAULT_PACING
agent_view = settings.for_agent(conn)
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):
settings.init_from_config(conn, cfg)
settings.init_from_config(conn, cfg, force=True)
jira_settings.init_from_config(conn, cfg)
jira_settings.init_from_config(conn, cfg, force=True)
def test_relay_dir_default_and_validation(tmp_path, open_db):
@ -76,13 +77,13 @@ 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()
def test_init_without_bug_keys_rejects_bug(conn):
"""Спека data-model «Конфиг без ключей ошибки»: bug в issue_types нет, постановка bug — 422."""
cfg = json.loads(FIXTURE.read_text(encoding="utf-8"))
settings.init_from_config(conn, cfg)
jira_settings.init_from_config(conn, cfg)
assert set(settings.get(conn, "issue_types")) == {"task", "epic"}
with pytest.raises(CommandError, match="нет настройки issue_types.bug"):
queue.create_batch(conn, "ошибка", "claude", [{"type": "ensure_issue", "params": {
@ -96,22 +97,22 @@ def test_init_without_bug_keys_rejects_bug(conn):
])
def test_init_bug_from_config(conn, extra, expected):
cfg = {**json.loads(FIXTURE.read_text(encoding="utf-8")), **extra}
settings.init_from_config(conn, cfg)
jira_settings.init_from_config(conn, cfg)
types = settings.get(conn, "issue_types")
assert types["bug"] == expected
assert types["task"] == {"name": "Задача", "id": "10100"} and types["epic"] == {"name": "Фича", "id": "12300"}
def test_attachment_pacing(conn):
assert settings.DEFAULT_PACING["ensure_attachment"] == 30
assert settings.pacing(conn)["ensure_attachment"] == 30
assert settings.pacing_from_config({"issue_seconds": 90, "comment_seconds": 20})["ensure_attachment"] == 20
assert jira_settings.DEFAULT_PACING["ensure_attachment"] == 30
assert jira_settings.pacing(conn)["ensure_attachment"] == 30
assert jira_settings.pacing_from_config({"issue_seconds": 90, "comment_seconds": 20})["ensure_attachment"] == 20
settings.validate("pacing", {"ensure_attachment": 10})
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.agent_keys()
assert "closed_statuses" not in settings.for_agent(conn)
@ -123,5 +124,5 @@ def test_closed_statuses_must_be_list():
def test_pacing_fallback_for_new_type(conn):
settings.set_(conn, "pacing", {"ensure_fields": 5})
assert settings.pacing(conn)["fetch_attachment"] == 2
assert jira_settings.pacing(conn)["fetch_attachment"] == 2
settings.validate("pacing", {"fetch_attachment": 3})