diff --git a/src/jira_hub/core/api.py b/src/jira_hub/core/api.py index 3118b98..a0e51a5 100644 --- a/src/jira_hub/core/api.py +++ b/src/jira_hub/core/api.py @@ -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: diff --git a/src/jira_hub/core/cli.py b/src/jira_hub/core/cli.py index 33a83d5..9ef1c35 100644 --- a/src/jira_hub/core/cli.py +++ b/src/jira_hub/core/cli.py @@ -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": diff --git a/src/jira_hub/core/db.py b/src/jira_hub/core/db.py index dcb089b..094c5ed 100644 --- a/src/jira_hub/core/db.py +++ b/src/jira_hub/core/db.py @@ -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) diff --git a/src/jira_hub/core/errors.py b/src/jira_hub/core/errors.py new file mode 100644 index 0000000..b771270 --- /dev/null +++ b/src/jira_hub/core/errors.py @@ -0,0 +1,11 @@ +"""Ошибки, общие для ядра и модулей.""" + + +class CommandError(ValueError): + """Ошибка валидации пакета команд — наружу уходит как 422.""" + + +class HttpMapped(Exception): + """Примесь: исключение модуля, которому соответствует свой код HTTP.""" + status: int = 500 + error: str = "internal" diff --git a/src/jira_hub/core/modules.py b/src/jira_hub/core/modules.py new file mode 100644 index 0000000..fe824cb --- /dev/null +++ b/src/jira_hub/core/modules.py @@ -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 — не своя подкоманда diff --git a/src/jira_hub/core/projects.py b/src/jira_hub/core/projects.py index 7e0d4bb..07562e3 100644 --- a/src/jira_hub/core/projects.py +++ b/src/jira_hub/core/projects.py @@ -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: - """Записать типы досок {"": "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\S+)|in\s*\((?P[^()]*)\))\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)) diff --git a/src/jira_hub/core/queue.py b/src/jira_hub/core/queue.py index f160d3d..09a939f 100644 --- a/src/jira_hub/core/queue.py +++ b/src/jira_hub/core/queue.py @@ -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) diff --git a/src/jira_hub/core/registry.py b/src/jira_hub/core/registry.py new file mode 100644 index 0000000..302f1a8 --- /dev/null +++ b/src/jira_hub/core/registry.py @@ -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 diff --git a/src/jira_hub/core/settings.py b/src/jira_hub/core/settings.py index 5cca068..806c3fa 100644 --- a/src/jira_hub/core/settings.py +++ b/src/jira_hub/core/settings.py @@ -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) diff --git a/src/jira_hub/modules/jira/__init__.py b/src/jira_hub/modules/jira/__init__.py index e69de29..fe1ff7f 100644 --- a/src/jira_hub/modules/jira/__init__.py +++ b/src/jira_hub/modules/jira/__init__.py @@ -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, +) diff --git a/src/jira_hub/modules/jira/commands.py b/src/jira_hub/modules/jira/commands.py index f6d3c87..a924645 100644 --- a/src/jira_hub/modules/jira/commands.py +++ b/src/jira_hub/modules/jira/commands.py @@ -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)) diff --git a/src/jira_hub/modules/jira/history.py b/src/jira_hub/modules/jira/history.py index a67e5cf..7721b01 100644 --- a/src/jira_hub/modules/jira/history.py +++ b/src/jira_hub/modules/jira/history.py @@ -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 diff --git a/src/jira_hub/modules/jira/mirror.py b/src/jira_hub/modules/jira/mirror.py index 6184cfc..0637105 100644 --- a/src/jira_hub/modules/jira/mirror.py +++ b/src/jira_hub/modules/jira/mirror.py @@ -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 (?, ?, ?)", diff --git a/src/jira_hub/modules/jira/projects.py b/src/jira_hub/modules/jira/projects.py new file mode 100644 index 0000000..3c97813 --- /dev/null +++ b/src/jira_hub/modules/jira/projects.py @@ -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: + """Записать типы досок {"": "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\S+)|in\s*\((?P[^()]*)\))\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)) diff --git a/src/jira_hub/modules/jira/schema.py b/src/jira_hub/modules/jira/schema.py new file mode 100644 index 0000000..8766782 --- /dev/null +++ b/src/jira_hub/modules/jira/schema.py @@ -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 +); +""" diff --git a/src/jira_hub/modules/jira/settings.py b/src/jira_hub/modules/jira/settings.py new file mode 100644 index 0000000..bb893b6 --- /dev/null +++ b/src/jira_hub/modules/jira/settings.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index d937127..88a37f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_baseline.py b/tests/test_baseline.py index 9313145..7b09b31 100644 --- a/tests/test_baseline.py +++ b/tests/test_baseline.py @@ -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)}) diff --git a/tests/test_db.py b/tests/test_db.py index 95001f8..9a25ddd 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -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): diff --git a/tests/test_projects.py b/tests/test_projects.py index 04f254c..2d2f937 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -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 diff --git a/tests/test_queue.py b/tests/test_queue.py index 4249a37..8b599c9 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -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"] diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..ca51eae --- /dev/null +++ b/tests/test_registry.py @@ -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)}" diff --git a/tests/test_settings.py b/tests/test_settings.py index 0444ca1..e243ec3 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -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})