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 . import backup, db, files, projects, queue, relay, release, settings
from ..modules.jira import attachments, history, mirror 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 backups_dir as default_backups_dir
from .paths import secure_dir from .paths import secure_dir
from .queue import ClaimedError from .queue import ClaimedError
@ -268,7 +269,7 @@ def _time_param(q, name, default):
raw = q.get(name) raw = q.get(name)
if raw in (None, ""): if raw in (None, ""):
return default 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: if value is None:
raise ApiError(422, "bad_request", f"{name}: ожидается время ISO-8601 со смещением или Z") raise ApiError(422, "bad_request", f"{name}: ожидается время ISO-8601 со смещением или Z")
return value return value
@ -294,7 +295,7 @@ def h_changes(hub, conn, m, q, body):
def h_board(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): 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): if body.get("jql") is not None and not isinstance(body["jql"], str):
raise ApiError(422, "bad_refresh", "jql — строка") raise ApiError(422, "bad_refresh", "jql — строка")
if body.get("full"): if body.get("full"):
jql = projects.base_jql(conn) jql = jira_projects.base_jql(conn)
if jql is None: if jql is None:
raise ApiError(422, "bad_refresh", "нет проектов") raise ApiError(422, "bad_refresh", "нет проектов")
params = {"jql": jql} 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): 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): 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): with db.transaction(conn):
for key, value in body.items(): for key, value in body.items():
settings.set_(conn, key, value) 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): 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): with db.transaction(conn):
project = projects.create(conn, body.get("label"), body.get("name"), body.get("board_id")) project = projects.create(conn, body.get("label"), body.get("name"), body.get("board_id"))
batch = queue.create_batch(conn, f"Новый проект {project['label']}", author, 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: except projects.ProjectExists as e:
raise ApiError(409, "project_exists", str(e)) raise ApiError(409, "project_exists", str(e))
except projects.ProjectError as e: except projects.ProjectError as e:

View File

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

View File

@ -10,95 +10,11 @@ from . import backup, paths
SCHEMA_VERSION = 6 SCHEMA_VERSION = 6
SCHEMA = """ CORE_SCHEMA = """
CREATE TABLE IF NOT EXISTS setting ( CREATE TABLE IF NOT EXISTS setting (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL 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 ( CREATE TABLE IF NOT EXISTS batch (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL, title TEXT NOT NULL,
@ -126,19 +42,6 @@ CREATE TABLE IF NOT EXISTS command (
depends_on INTEGER REFERENCES command(id) depends_on INTEGER REFERENCES command(id)
); );
CREATE INDEX IF NOT EXISTS ix_command_status ON command(status, batch_id, seq); 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 ( CREATE TABLE IF NOT EXISTS project (
label TEXT PRIMARY KEY, label TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
@ -157,33 +60,38 @@ CREATE TABLE IF NOT EXISTS agent_status (
""" """
def _migrate_projects(conn) -> None: def _migrate_projects(conn) -> None:
from . import projects # внутри функции: projects импортирует db from ..modules.jira import projects # внутри шага: ядро не импортирует модули при импорте
projects.migrate_v3(conn) 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): def _add_column(table: str, column: str):
"""Шаг миграции: добавить колонку, если её нет. Базы v1/v2 получают issue_comment из SCHEMA уже с новой колонкой.""" """Шаг миграции: добавить колонку, если её нет. Базы v1/v2 получают issue_comment из схемы уже с новой колонкой."""
def step(conn) -> None: def step(conn) -> None:
if column not in {r["name"] for r in conn.execute(f"PRAGMA table_info({table})")}: 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") conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} TEXT")
return step 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) # Шаги миграции: версия → SQL, переводящий схему в версию + 1 (design.md bridge-reliability, решение 5)
MIGRATIONS = { MIGRATIONS = {
1: ["ALTER TABLE command ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0", 1: ["ALTER TABLE command ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0",
"UPDATE command SET deliveries = 1 WHERE status = 'claimed'"], # уже выданные — одна выдача "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: проекты-метки 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: журнал изменений 4: [_add_column("issue_comment", "created_at"), _add_column("sync_run", "history_at"), # v4 → v5: журнал изменений
_fill_comment_utc], _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). Копию делает connect дождётся её, перечитает версию и не станет ни копировать, ни мигрировать повторно (M-2 #12). Копию делает
отдельное соединение: VACUUM INTO внутри транзакции SQLite запрещает, а читатель при WAL видит базу до миграции. отдельное соединение: VACUUM INTO внутри транзакции SQLite запрещает, а читатель при WAL видит базу до миграции.
Вызывается только вне транзакции.""" Вызывается только вне транзакции."""
conn.executescript(SCHEMA) conn.executescript(schema())
version = _schema_version(conn) version = _schema_version(conn)
if version is None: if version is None:
conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES ('schema_version', ?)", (json.dumps(SCHEMA_VERSION),)) 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") 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: def utcnow() -> datetime:
return datetime.now(timezone.utc).replace(microsecond=0) 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.""" """Проекты-метки: таблица project, проверка метки, создание и список."""
import logging
import re
import unicodedata import unicodedata
from . import db, settings from . import db
log = logging.getLogger(__name__)
LEGACY_LABEL = "AWPCA"
LEGACY_NAME = "АРМ КА"
MAX_LEN = 100 MAX_LEN = 100
BOARD_TYPES = ("scrum", "kanban")
_FORBIDDEN = ',"\'()\\' _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): class ProjectError(ValueError):
@ -47,6 +40,7 @@ def validate(label, name, board_id) -> tuple[str, str, int | None]:
raise ProjectError("name", "ожидается непустая строка") raise ProjectError("name", "ожидается непустая строка")
if len(name.strip()) > MAX_LEN: if len(name.strip()) > MAX_LEN:
raise ProjectError("name", f"длиннее {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): 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") raise ProjectError("board_id", "ожидается целое > 0 или null")
return label, name.strip(), board_id 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): with db.transaction(conn):
if label.casefold() in {x.casefold() for x in labels(conn)}: if label.casefold() in {x.casefold() for x in labels(conn)}:
raise ProjectExists(label) raise ProjectExists(label)
# долг hub-modules: проекты → модуль jira (change wiki)
conn.execute("INSERT INTO project(label, name, board_id, board_type, created_at) VALUES (?, ?, ?, NULL, ?)", conn.execute("INSERT INTO project(label, name, board_id, board_type, created_at) VALUES (?, ?, ?, NULL, ?)",
(label, name, board_id, db.now())) (label, name, board_id, db.now()))
return get(conn, label) return get(conn, label)
def list_projects(conn) -> list[dict]: def list_projects(conn) -> list[dict]:
# долг hub-modules: проекты → модуль jira (change wiki)
rows = conn.execute( rows = conn.execute(
f"SELECT {_FIELDS}, (SELECT COUNT(*) FROM issue i WHERE EXISTS " f"SELECT {_FIELDS}, (SELECT COUNT(*) FROM issue i WHERE EXISTS "
"(SELECT 1 FROM json_each(i.labels) WHERE value = project.label)) AS issues_count " "(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]: def light_list(conn) -> list[dict]:
"""Проекты без issues_count (label, board_id, board_type), порядок как у list_projects.""" """Проекты без 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")] 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]: def labels(conn) -> list[str]:
return [r["label"] for r in conn.execute("SELECT label FROM project ORDER BY created_at, label")] 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 ..modules.jira import commands as C
from . import db, files, projects, settings from . import db, files, projects, settings
from ..modules.jira import attachments, history, mirror 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__) 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: def _insert_batch(conn, title, author, cmds, project=None) -> dict:
"""Вставка пакета без проверок и хвоста; транзакция своя (вложенная идёт через SAVEPOINT).""" """Вставка пакета без проверок и хвоста; транзакция своя (вложенная идёт через 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): with db.transaction(conn):
cur = conn.execute("INSERT INTO batch(title, author, created_at, estimate_seconds, project) VALUES (?, ?, ?, ?, ?)", cur = conn.execute("INSERT INTO batch(title, author, created_at, estimate_seconds, project) VALUES (?, ?, ?, ?, ?)",
(title, author or "api", db.now(), est, project)) (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: if b is None:
return None return None
cmds = [_cmd(r) for r in conn.execute("SELECT * FROM command WHERE batch_id = ? ORDER BY seq", (batch_id,))] 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 = dict(b)
d["status"] = _status_of(cmds, b["cancelled_at"]) 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})} 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 — отметка журнала если файла уже нет (решение 5); refresh — boards, доски проектов (design #7), и history — отметка журнала
(change-history). В базе params остаются без них: повтор команды проходит проверку как новая.""" (change-history). В базе params остаются без них: повтор команды проходит проверку как новая."""
if cmd["type"] == "refresh": 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) hist = history.delivery_param(conn, cmd["params"]) # отметка журнала (change-history, design #5)
if hist is not None: if hist is not None:
out["history"] = hist 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)) (now, None if agent_version is None else str(agent_version), settings_version, last_jira_call_at))
cmd = _redeliver(conn, now) cmd = _redeliver(conn, now)
if cmd is not None: 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 = ?", conn.execute("UPDATE command SET claimed_at = ?, pause_seconds = ?, deliveries = deliveries + 1 WHERE id = ?",
(now, pause, cmd["id"])) (now, pause, cmd["id"]))
if cmd["type"] == "refresh": 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: if cmd is None:
conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1") conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1")
return None 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'", first = conn.execute("SELECT COUNT(*) AS n FROM command WHERE batch_id = ? AND status != 'queued'",
(cmd["batch_id"],)).fetchone()["n"] == 0 (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", 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: if not command_id:
return False return False
cmd = get_command(conn, command_id) 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) 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 = ?", 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)) (now, int(jira_calls or 0), int(rate_limited or 0), command_id))
if isinstance(result, dict) and "boards" in result: 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): 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)) conn.execute("UPDATE sync_run SET history_at = ? WHERE command_id = ?", (now, command_id))
if status == "done" and is_full_refresh(conn, 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: def maybe_auto_refresh(conn) -> dict | None:
jql = projects.base_jql(conn) jql = jira_projects.base_jql(conn)
if jql is None: if jql is None:
return None return None
minutes = int(settings.get(conn, "auto_refresh_minutes") or 0) 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, """Настройки хаба: пары ключ → JSON в таблице setting. Любая правка поднимает settings_version,
агент перечитывает настройки, когда видит новую версию в ответе claim.""" агент перечитывает настройки, когда видит новую версию в ответе claim. Ключи модулей приходят из реестра."""
import json import json
import os import os
import secrets import secrets
DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30, from . import registry
"ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "fetch_attachment": 2, "refresh": 2}
DEFAULTS = { CORE_DEFAULTS = {
"project_key": "AURORA",
"pacing": DEFAULT_PACING,
"backup_keep": 30, "backup_keep": 30,
"auto_refresh_minutes": 60,
"min_agent_version": 1, "min_agent_version": 1,
"port": 8790, "port": 8790,
"settings_version": 1, "settings_version": 1,
"initialized": False, "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, "relay_dir": None,
"closed_statuses": ["Готово", "Снято"], # статусы Jira закрытых задач: список и доска приложения (hide-closed)
} }
# Что получает агент (без токенов и служебных ключей хаба) # Ключи ядра в ответе агенту: идут после ключей модулей
AGENT_KEYS = ["project_key", "issue_types", "epic_name_field_id", "field_ids", "epic_link", CORE_AGENT_KEYS = ["min_agent_version", "settings_version"]
"priority_map", "priority_value_key", "labels", "status_labels", "kind_labels", "comment_marker",
"transitions_map", "link_types", "board_id", "pacing", "min_agent_version", "settings_version"]
PROTECTED = {"settings_version", "schema_version", "tokens"} PROTECTED = {"settings_version", "schema_version", "tokens"}
@ -44,32 +25,33 @@ def _is_int(v) -> bool:
return isinstance(v, int) and not isinstance(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: def validate(key, value) -> None:
"""Проверить значение настройки до записи; ValueError — на 422 в API. """Проверить значение настройки до записи; ValueError — на 422 в API.
base_jql и board_id не пишутся: JQL собирается из проектов, доска — свойство проекта; Сначала ключи ядра: backup_keep, min_agent_version — целые >= 0; port — целое 1..65535;
pacing — объект {тип команды: целое >= 0} только с известными типами команд; relay_dir — null или абсолютный путь строкой. Затем validate_setting модулей (их ключи).
backup_keep, auto_refresh_minutes, min_agent_version — целые >= 0; port — целое 1..65535; Остальные ключи — по типу значения по умолчанию (объект, список, строка, флаг), если оно не None;
relay_dir — null или абсолютный путь строкой;
остальные ключи — по типу значения по умолчанию (объект, список, строка, флаг), если оно не None;
неизвестные ключи и ключи с умолчанием 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 key == "port":
if not _is_int(value) or not 1 <= value <= 65535: if not _is_int(value) or not 1 <= value <= 65535:
raise ValueError("port — целое 1..65535") raise ValueError("port — целое 1..65535")
return 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: if not _is_int(value) or value < 0:
raise ValueError(f"{key} — целое >= 0") raise ValueError(f"{key} — целое >= 0")
return 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)): if value is not None and (not isinstance(value, str) or not os.path.isabs(value)):
raise ValueError("relay_dir — абсолютный путь папки реле или null") raise ValueError("relay_dir — абсолютный путь папки реле или null")
return 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): if default is not None and type(value) is not type(default):
names = {dict: "объект", list: "список", str: "строка", bool: "true/false", int: "целое"} names = {dict: "объект", list: "список", str: "строка", bool: "true/false", int: "целое"}
raise ValueError(f"{key} — {names.get(type(default), type(default).__name__)}") raise ValueError(f"{key} — {names.get(type(default), type(default).__name__)}")
def ensure_defaults(conn) -> None: 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))) conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES (?, ?)", (key, _dump(value)))
if get(conn, "tokens") is None: if get(conn, "tokens") is None:
conn.execute("INSERT INTO setting(key, value) VALUES ('tokens', ?)", 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: 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: def tokens(conn) -> dict:
return get(conn, "tokens") 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 import re
from ...core.errors import CommandError # noqa: F401 (реэкспорт: прежнее место)
from . import mirror from . import mirror
TEMP_KEY = re.compile(r"^new:\d+$") TEMP_KEY = re.compile(r"^new:\d+$")
@ -44,10 +45,6 @@ LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"}
BOOL_PARAMS = {"replace"} BOOL_PARAMS = {"replace"}
class CommandError(ValueError):
"""Ошибка валидации пакета — наружу уходит как 422."""
def is_temp(key) -> bool: def is_temp(key) -> bool:
return isinstance(key, str) and bool(TEMP_KEY.match(key)) return isinstance(key, str) and bool(TEMP_KEY.match(key))

View File

@ -1,11 +1,35 @@
"""Журнал изменений задач (issue_change): приём от агента, логическое имя поля, отметка «с какого момента» для """Журнал изменений задач (issue_change): приём от агента, логическое имя поля, отметка «с какого момента» для
refresh. Пишет таблицу только приём журнала (POST /agent/history); API и MCP её только читают (spec data-model).""" 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) 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") BUILTIN_FIELDS = ("status", "description", "summary", "assignee", "priority", "duedate", "labels", "resolution")
_REQUIRED = ("history_id", "key", "created", "field_name") _REQUIRED = ("history_id", "key", "created", "field_name")
_OPTIONAL = ("author", "field_id", "from", "from_string", "to", "to_string") _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( cur = conn.execute(
"INSERT OR IGNORE INTO issue_change(history_id, seq, key, at, created, author, field, field_id, field_name, " "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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "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"), 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)) c.get("from_string"), c.get("to"), c.get("to_string"), now))
new += cur.rowcount 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) " "INSERT OR REPLACE INTO issue_comment(key, comment_id, author, created, updated, body, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?, ?, ?)",
[(it["key"], c["id"], c.get("author"), c.get("created"), c.get("updated"), c.get("body") or "", [(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 не трогаем никогда if isinstance(it.get("attachments"), list): # без поля (агент до v14) вложения не трогаем; attachment_file не трогаем никогда
conn.execute("DELETE FROM issue_attachment WHERE key = ?", (it["key"],)) conn.execute("DELETE FROM issue_attachment WHERE key = ?", (it["key"],))
conn.executemany( # REPLACE по attachment_id: вложение, переехавшее на другой ключ, перезаписывается conn.executemany( # REPLACE по attachment_id: вложение, переехавшее на другой ключ, перезаписывается
"INSERT OR REPLACE INTO issue_attachment(attachment_id, key, filename, size, mime_type, author, created, created_at) " "INSERT OR REPLACE INTO issue_attachment(attachment_id, key, filename, size, mime_type, author, created, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[(a["id"], it["key"], a["filename"], a.get("size"), a.get("mime_type"), a.get("author"), a.get("created"), [(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]) conn.executemany("DELETE FROM issue_link WHERE source_key = ?", [(k,) for k in keys])
for link in links: for link in links:
conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)", 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 import pytest
from jira_hub.core import db, projects from jira_hub.core import db, projects
from jira_hub.modules.jira import projects as jira_projects
@pytest.fixture @pytest.fixture
@ -25,9 +26,9 @@ def add_project():
"""add_project(conn, label="AWPCA", name=None, board_id=4216, board_type="scrum") -> dict: завести проект.""" """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"): def make(conn, label="AWPCA", name=None, board_id=4216, board_type="scrum"):
if name is None: 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) projects.create(conn, label, name, board_id)
if board_id is not None and board_type is not None: 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 projects.get(conn, label)
return make return make

View File

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

View File

@ -8,6 +8,7 @@ import threading
import pytest import pytest
from jira_hub.core import backup, db, paths, projects, queue, settings 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): 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 ("9999-12-31T23:00:00-0300", None), ("0001-01-01T00:00:00+0300", None), # M-1: выход за диапазон datetime
]) ])
def test_jira_utc(raw, want): def test_jira_utc(raw, want):
assert db.jira_utc(raw) == want assert history.jira_utc(raw) == want
def _v4_database(path): def _v4_database(path):

View File

@ -2,6 +2,7 @@ import pytest
from jira_hub.core import projects, settings from jira_hub.core import projects, settings
from jira_hub.modules.jira import mirror from jira_hub.modules.jira import mirror
from jira_hub.modules.jira import projects as jira_projects
@pytest.fixture @pytest.fixture
@ -13,12 +14,12 @@ def conn(open_db):
def test_boards_type_unknown_if_any_project_unknown(conn): # I-1: новый проект на известной доске def test_boards_type_unknown_if_any_project_unknown(conn): # I-1: новый проект на известной доске
projects.create(conn, "AWPCA", "a", 4216) 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) projects.create(conn, "NEW", "n", 4216)
assert projects.get(conn, "NEW")["board_type"] is None assert projects.get(conn, "NEW")["board_type"] is None
assert projects.boards(conn) == [{"id": 4216, "type": None}] assert jira_projects.boards(conn) == [{"id": 4216, "type": None}]
projects.record_board_types(conn, {"4216": "scrum"}) jira_projects.record_board_types(conn, {"4216": "scrum"})
assert projects.boards(conn) == [{"id": 4216, "type": "scrum"}] assert jira_projects.boards(conn) == [{"id": 4216, "type": "scrum"}]
assert [p["board_type"] for p in projects.list_projects(conn)] == ["scrum", "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) 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 = 'kanban' WHERE label = 'B'")
conn.execute("UPDATE project SET board_type = 'scrum' WHERE label = 'A'") 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): 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): 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", "Запасы ДРРК") 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") 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): def test_base_jql_single_and_reserved_words(conn):
projects.create(conn, "AWPCA", "АРМ КА") 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 — в кавычках 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): def test_boards_dedup_and_types(conn):
projects.create(conn, "AWPCA", "a", 4216); projects.create(conn, "B", "b", 5120) projects.create(conn, "AWPCA", "a", 4216); projects.create(conn, "B", "b", 5120)
projects.create(conn, "C", "c", 4216); projects.create(conn, "D", "d") projects.create(conn, "C", "c", 4216); projects.create(conn, "D", "d")
projects.record_board_types(conn, {"4216": "scrum"}) jira_projects.record_board_types(conn, {"4216": "scrum"})
assert projects.boards(conn) == [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}] assert jira_projects.boards(conn) == [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}]
assert projects.get(conn, "C")["board_type"] == "scrum" # тип — у всех проектов с этой доской assert projects.get(conn, "C")["board_type"] == "scrum" # тип — у всех проектов с этой доской
@pytest.mark.parametrize("boards", [{"5120": None}, {"5120": "simple"}, {"x": "kanban"}, ["5120"], None, {}]) @pytest.mark.parametrize("boards", [{"5120": None}, {"5120": "simple"}, {"x": "kanban"}, ["5120"], None, {}])
def test_record_board_types_ignores(conn, boards): def test_record_board_types_ignores(conn, boards):
projects.create(conn, "B", "b", 5120) 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 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), ("", None), (None, None), (5, None),
]) ])
def test_parse_legacy_labels(jql, labels): 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): 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): 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.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): def test_ensure_legacy_skips_case_variant_and_bad_board(open_db):
c = open_db() c = open_db()
settings.ensure_defaults(c) settings.ensure_defaults(c)
projects.create(c, "awpca", "x") 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() c2 = open_db()
settings.ensure_defaults(c2) 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.core import db, files, projects, queue, settings
from jira_hub.modules.jira import attachments, mirror 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.modules.jira.commands import CommandError
from jira_hub.core.queue import ClaimedError 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): def test_empty_full_refresh_keeps_neighbors(conn, add_project):
add_project(conn) add_project(conn)
jql = projects.base_jql(conn) jql = jira_projects.base_jql(conn)
mirror.apply_state(conn, [{"key": "A-1", "summary": "x", "labels": []}], [], mirror.apply_state(conn, [{"key": "A-1", "summary": "x", "labels": []}], [],
fetched_at="2020-01-01T00:00:00+00:00") # прочитана давно — до старта прогона fetched_at="2020-01-01T00:00:00+00:00") # прочитана давно — до старта прогона
queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}]) 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): def test_redelivered_full_refresh_marks_neighbors_once(conn, add_project):
add_project(conn) 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") 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}}]) queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}])
r = queue.claim(conn) 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"): def _full_run(conn, result, started_at=None, status="done"):
"""Полный refresh: поставить, выдать, отчитаться; started_at — подменить время начала прогона.""" """Полный 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) c = queue.claim(conn)
if started_at: if started_at:
conn.execute("UPDATE sync_run SET started_at = ? WHERE command_id = ?", (started_at, c["id"])) 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 после выкатки» def test_first_full_refresh_gets_backfill(conn, add_project): # сценарий «Первый refresh после выкатки»
add_project(conn) 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) got = queue.claim(conn)
assert got["params"]["history"] == { assert got["params"]["history"] == {
"jql": '(project = AURORA AND labels in ("AWPCA")) AND sprint in openSprints() AND statusCategory != Done'} "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) add_project(conn)
_full_run(conn, {"history": {"issues": 1, "changes": 2, "incomplete": []}}, "2026-10-22T09:00:00+00:00") _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") _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"} 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) add_project(conn)
_full_run(conn, {"history": {"issues": 0, "changes": 0, "incomplete": []}}, "2026-10-22T09:00:00+00:00") _full_run(conn, {"history": {"issues": 0, "changes": 0, "incomplete": []}}, "2026-10-22T09:00:00+00:00")
add_project(conn, "STOCKS_DRRK", board_id=5120) 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"] h = queue.claim(conn)["params"]["history"]
assert h["since"] == "2026-10-22T08:45:00Z" 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"] 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 def test_redelivery_recomputes_history(conn, add_project): # Review Focus 3
add_project(conn) 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) first = queue.claim(conn)
again = queue.claim(conn) # отчёта нет — довыдача again = queue.claim(conn) # отчёта нет — довыдача
assert again["id"] == first["id"] and again["params"]["history"] == first["params"]["history"] 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 import pytest
from jira_hub.core import queue, settings 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 from jira_hub.modules.jira.commands import CommandError
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" 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): 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.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 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): def test_init_from_config(conn):
cfg = json.loads(FIXTURE.read_text(encoding="utf-8")) 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 "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 "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, "field_ids")["planned_end"] == "customfield_10802"
assert settings.get(conn, "issue_types")["epic"] == {"name": "Фича", "id": "12300"} 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) 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 "tokens" not in agent_view and "auth" not in agent_view and "base_url" not in agent_view
assert agent_view["link_types"]["связанные"] == "ASUPR_Связанные" assert agent_view["link_types"]["связанные"] == "ASUPR_Связанные"
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError):
settings.init_from_config(conn, cfg) jira_settings.init_from_config(conn, cfg)
settings.init_from_config(conn, cfg, force=True) jira_settings.init_from_config(conn, cfg, force=True)
def test_relay_dir_default_and_validation(tmp_path, open_db): 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"]): for bad in ("rshb/jira-bridge", "", 5, ["/x"]):
with pytest.raises(ValueError): with pytest.raises(ValueError):
settings.validate("relay_dir", bad) 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): def test_init_without_bug_keys_rejects_bug(conn):
"""Спека data-model «Конфиг без ключей ошибки»: bug в issue_types нет, постановка bug — 422.""" """Спека data-model «Конфиг без ключей ошибки»: bug в issue_types нет, постановка bug — 422."""
cfg = json.loads(FIXTURE.read_text(encoding="utf-8")) 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"} assert set(settings.get(conn, "issue_types")) == {"task", "epic"}
with pytest.raises(CommandError, match="нет настройки issue_types.bug"): with pytest.raises(CommandError, match="нет настройки issue_types.bug"):
queue.create_batch(conn, "ошибка", "claude", [{"type": "ensure_issue", "params": { 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): def test_init_bug_from_config(conn, extra, expected):
cfg = {**json.loads(FIXTURE.read_text(encoding="utf-8")), **extra} 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") types = settings.get(conn, "issue_types")
assert types["bug"] == expected assert types["bug"] == expected
assert types["task"] == {"name": "Задача", "id": "10100"} and types["epic"] == {"name": "Фича", "id": "12300"} assert types["task"] == {"name": "Задача", "id": "10100"} and types["epic"] == {"name": "Фича", "id": "12300"}
def test_attachment_pacing(conn): def test_attachment_pacing(conn):
assert settings.DEFAULT_PACING["ensure_attachment"] == 30 assert jira_settings.DEFAULT_PACING["ensure_attachment"] == 30
assert settings.pacing(conn)["ensure_attachment"] == 30 assert jira_settings.pacing(conn)["ensure_attachment"] == 30
assert settings.pacing_from_config({"issue_seconds": 90, "comment_seconds": 20})["ensure_attachment"] == 20 assert jira_settings.pacing_from_config({"issue_seconds": 90, "comment_seconds": 20})["ensure_attachment"] == 20
settings.validate("pacing", {"ensure_attachment": 10}) settings.validate("pacing", {"ensure_attachment": 10})
def test_closed_statuses_default(conn): def test_closed_statuses_default(conn):
assert settings.get(conn, "closed_statuses") == ["Готово", "Снято"] 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) 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): def test_pacing_fallback_for_new_type(conn):
settings.set_(conn, "pacing", {"ensure_fields": 5}) 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}) settings.validate("pacing", {"fetch_attachment": 3})