refactor: контракт модуля, реестр, схема и настройки по модулям (#87)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6d7aebe01c
commit
01daaa8638
@ -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:
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
11
src/jira_hub/core/errors.py
Normal file
11
src/jira_hub/core/errors.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""Ошибки, общие для ядра и модулей."""
|
||||
|
||||
|
||||
class CommandError(ValueError):
|
||||
"""Ошибка валидации пакета команд — наружу уходит как 422."""
|
||||
|
||||
|
||||
class HttpMapped(Exception):
|
||||
"""Примесь: исключение модуля, которому соответствует свой код HTTP."""
|
||||
status: int = 500
|
||||
error: str = "internal"
|
||||
51
src/jira_hub/core/modules.py
Normal file
51
src/jira_hub/core/modules.py
Normal 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 — не своя подкоманда
|
||||
@ -1,18 +1,11 @@
|
||||
"""Проекты-метки моста: таблица project, проверка метки, базовая JQL из меток, доски, миграция v3 → v4."""
|
||||
import logging
|
||||
import re
|
||||
"""Проекты-метки: таблица project, проверка метки, создание и список."""
|
||||
import unicodedata
|
||||
|
||||
from . import db, settings
|
||||
from . import db
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
LEGACY_LABEL = "AWPCA"
|
||||
LEGACY_NAME = "АРМ КА"
|
||||
MAX_LEN = 100
|
||||
BOARD_TYPES = ("scrum", "kanban")
|
||||
_FORBIDDEN = ',"\'()\\'
|
||||
_FIELDS = "label, name, board_id, board_type, created_at"
|
||||
_FIELDS = "label, name, board_id, board_type, created_at" # долг hub-modules: проекты → модуль jira (change wiki)
|
||||
|
||||
|
||||
class ProjectError(ValueError):
|
||||
@ -47,6 +40,7 @@ def validate(label, name, board_id) -> tuple[str, str, int | None]:
|
||||
raise ProjectError("name", "ожидается непустая строка")
|
||||
if len(name.strip()) > MAX_LEN:
|
||||
raise ProjectError("name", f"длиннее {MAX_LEN} символов")
|
||||
# долг hub-modules: проекты → модуль jira (change wiki)
|
||||
if board_id is not None and (not isinstance(board_id, int) or isinstance(board_id, bool) or board_id <= 0):
|
||||
raise ProjectError("board_id", "ожидается целое > 0 или null")
|
||||
return label, name.strip(), board_id
|
||||
@ -62,12 +56,14 @@ def create(conn, label: str, name: str, board_id: int | None = None) -> dict:
|
||||
with db.transaction(conn):
|
||||
if label.casefold() in {x.casefold() for x in labels(conn)}:
|
||||
raise ProjectExists(label)
|
||||
# долг hub-modules: проекты → модуль jira (change wiki)
|
||||
conn.execute("INSERT INTO project(label, name, board_id, board_type, created_at) VALUES (?, ?, ?, NULL, ?)",
|
||||
(label, name, board_id, db.now()))
|
||||
return get(conn, label)
|
||||
|
||||
|
||||
def list_projects(conn) -> list[dict]:
|
||||
# долг hub-modules: проекты → модуль jira (change wiki)
|
||||
rows = conn.execute(
|
||||
f"SELECT {_FIELDS}, (SELECT COUNT(*) FROM issue i WHERE EXISTS "
|
||||
"(SELECT 1 FROM json_each(i.labels) WHERE value = project.label)) AS issues_count "
|
||||
@ -77,96 +73,9 @@ def list_projects(conn) -> list[dict]:
|
||||
|
||||
def light_list(conn) -> list[dict]:
|
||||
"""Проекты без issues_count (label, board_id, board_type), порядок как у list_projects."""
|
||||
# долг hub-modules: проекты → модуль jira (change wiki)
|
||||
return [dict(r) for r in conn.execute("SELECT label, board_id, board_type FROM project ORDER BY created_at, label")]
|
||||
|
||||
|
||||
def labels(conn) -> list[str]:
|
||||
return [r["label"] for r in conn.execute("SELECT label FROM project ORDER BY created_at, label")]
|
||||
|
||||
|
||||
def base_jql(conn) -> str | None:
|
||||
"""Общая JQL обновления: все метки проектов; без проектов — None."""
|
||||
found = labels(conn)
|
||||
if not found:
|
||||
return None
|
||||
key = settings.get(conn, "project_key") or "AURORA"
|
||||
return f"project = {key} AND labels in ({', '.join(f'"{x}"' for x in found)})"
|
||||
|
||||
|
||||
def boards(conn) -> list[dict]:
|
||||
"""Доски проектов без повторов (порядок первого проекта). type — None, если тип неизвестен хотя бы у одного
|
||||
проекта доски (агент тогда запросит его, record_board_types проставит всем); иначе первый известный."""
|
||||
out: dict[int, str | None] = {}
|
||||
unknown: set[int] = set()
|
||||
for r in conn.execute("SELECT board_id, board_type FROM project WHERE board_id IS NOT NULL ORDER BY created_at, label"):
|
||||
out.setdefault(r["board_id"], r["board_type"])
|
||||
if r["board_type"] is None:
|
||||
unknown.add(r["board_id"])
|
||||
return [{"id": i, "type": None if i in unknown else t} for i, t in out.items()]
|
||||
|
||||
|
||||
def record_board_types(conn, found) -> int:
|
||||
"""Записать типы досок {"<id>": "scrum" | "kanban"} всем проектам с этой доской; прочее пропустить."""
|
||||
if not isinstance(found, dict):
|
||||
return 0
|
||||
updated = 0
|
||||
for raw, kind in found.items():
|
||||
if kind not in BOARD_TYPES:
|
||||
continue
|
||||
try:
|
||||
board = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
updated += conn.execute("UPDATE project SET board_type = ? WHERE board_id = ?", (kind, board)).rowcount
|
||||
return updated
|
||||
|
||||
|
||||
_LEGACY = re.compile(r"\blabels\s*(?:=\s*(?P<one>\S+)|in\s*\((?P<many>[^()]*)\))\s*$", re.IGNORECASE)
|
||||
|
||||
|
||||
def parse_legacy_labels(jql) -> list[str] | None:
|
||||
"""Метки из прежней base_jql (`labels = X` / `labels in (X, Y)` в конце); не разобрана — None."""
|
||||
m = _LEGACY.search(jql) if isinstance(jql, str) else None
|
||||
if not m:
|
||||
return None
|
||||
out: list[str] = []
|
||||
for raw in ([m["one"]] if m["one"] is not None else m["many"].split(",")):
|
||||
label = raw.strip().strip('"')
|
||||
if _label_error(label):
|
||||
return None
|
||||
if label.casefold() not in {x.casefold() for x in out}:
|
||||
out.append(label)
|
||||
return out or None
|
||||
|
||||
|
||||
def ensure_legacy(conn, board_id: int | None) -> bool:
|
||||
"""Завести AWPCA «АРМ КА» с доской, если проекта с меткой awpca (без учёта регистра) нет; True — заведён.
|
||||
Неверная доска из конфига — проект без доски."""
|
||||
if LEGACY_LABEL.casefold() in {x.casefold() for x in labels(conn)}:
|
||||
return False
|
||||
try:
|
||||
validate(LEGACY_LABEL, LEGACY_NAME, board_id)
|
||||
except ProjectError:
|
||||
board_id = None
|
||||
create(conn, LEGACY_LABEL, LEGACY_NAME, board_id)
|
||||
return True
|
||||
|
||||
|
||||
def migrate_v3(conn) -> None:
|
||||
"""Миграция v3 → v4: проекты из прежней base_jql, доска — единственному проекту, очередь и журнал — на новую JQL."""
|
||||
old = settings.get(conn, "base_jql")
|
||||
found = parse_legacy_labels(old)
|
||||
if found is None:
|
||||
log.warning("миграция v4: base_jql не разобрана (%r) — проектов нет, заведите их POST /projects", old)
|
||||
return
|
||||
board = settings.get(conn, "board_id")
|
||||
board = board if isinstance(board, int) and not isinstance(board, bool) and board > 0 else None
|
||||
if board is not None and len(found) > 1:
|
||||
log.warning("миграция v4: доска %s не отдана ни одному проекту — меток несколько: %s", board, ", ".join(found))
|
||||
board = None
|
||||
for label in found:
|
||||
create(conn, label, LEGACY_NAME if label == LEGACY_LABEL else label, board)
|
||||
new = base_jql(conn)
|
||||
conn.execute("UPDATE sync_run SET query = ? WHERE query = ?", (new, old))
|
||||
conn.execute("UPDATE command SET params = json_set(params, '$.jql', ?) WHERE type = 'refresh' "
|
||||
"AND status IN ('queued', 'claimed') AND json_extract(params, '$.jql') = ?", (new, old))
|
||||
|
||||
@ -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)
|
||||
|
||||
45
src/jira_hub/core/registry.py
Normal file
45
src/jira_hub/core/registry.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 (?, ?, ?)",
|
||||
|
||||
100
src/jira_hub/modules/jira/projects.py
Normal file
100
src/jira_hub/modules/jira/projects.py
Normal 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))
|
||||
101
src/jira_hub/modules/jira/schema.py
Normal file
101
src/jira_hub/modules/jira/schema.py
Normal 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
|
||||
);
|
||||
"""
|
||||
122
src/jira_hub/modules/jira/settings.py
Normal file
122
src/jira_hub/modules/jira/settings.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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)})
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -4,6 +4,7 @@ import pytest
|
||||
|
||||
from jira_hub.core import db, files, projects, queue, settings
|
||||
from jira_hub.modules.jira import attachments, mirror
|
||||
from jira_hub.modules.jira import projects as jira_projects
|
||||
from jira_hub.modules.jira.commands import CommandError
|
||||
from jira_hub.core.queue import ClaimedError
|
||||
|
||||
@ -424,7 +425,7 @@ def test_cancelled_batch_with_done_command_is_cancelled(conn):
|
||||
|
||||
def test_empty_full_refresh_keeps_neighbors(conn, add_project):
|
||||
add_project(conn)
|
||||
jql = projects.base_jql(conn)
|
||||
jql = jira_projects.base_jql(conn)
|
||||
mirror.apply_state(conn, [{"key": "A-1", "summary": "x", "labels": []}], [],
|
||||
fetched_at="2020-01-01T00:00:00+00:00") # прочитана давно — до старта прогона
|
||||
queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}])
|
||||
@ -444,7 +445,7 @@ def test_cancel_finished_batch_stays_done(conn):
|
||||
|
||||
def test_redelivered_full_refresh_marks_neighbors_once(conn, add_project):
|
||||
add_project(conn)
|
||||
jql = projects.base_jql(conn)
|
||||
jql = jira_projects.base_jql(conn)
|
||||
mirror.apply_state(conn, [{"key": "OLD", "summary": "x", "labels": []}], [], fetched_at="2020-01-01T00:00:00+00:00")
|
||||
queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}])
|
||||
r = queue.claim(conn)
|
||||
@ -948,7 +949,7 @@ def test_refresh_failed_result_records_board_types(conn, add_project): # R-3
|
||||
|
||||
def _full_run(conn, result, started_at=None, status="done"):
|
||||
"""Полный refresh: поставить, выдать, отчитаться; started_at — подменить время начала прогона."""
|
||||
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
|
||||
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
|
||||
c = queue.claim(conn)
|
||||
if started_at:
|
||||
conn.execute("UPDATE sync_run SET started_at = ? WHERE command_id = ?", (started_at, c["id"]))
|
||||
@ -958,7 +959,7 @@ def _full_run(conn, result, started_at=None, status="done"):
|
||||
|
||||
def test_first_full_refresh_gets_backfill(conn, add_project): # сценарий «Первый refresh после выкатки»
|
||||
add_project(conn)
|
||||
b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
|
||||
b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
|
||||
got = queue.claim(conn)
|
||||
assert got["params"]["history"] == {
|
||||
"jql": '(project = AURORA AND labels in ("AWPCA")) AND sprint in openSprints() AND statusCategory != Done'}
|
||||
@ -969,7 +970,7 @@ def test_regular_refresh_gets_since(conn, add_project): # «Р
|
||||
add_project(conn)
|
||||
_full_run(conn, {"history": {"issues": 1, "changes": 2, "incomplete": []}}, "2026-10-22T09:00:00+00:00")
|
||||
_full_run(conn, {"history": {"error": "HTTP 500"}}, "2026-10-22T10:00:00+00:00")
|
||||
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
|
||||
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
|
||||
assert queue.claim(conn)["params"]["history"] == {"since": "2026-10-22T08:45:00Z"}
|
||||
|
||||
|
||||
@ -977,10 +978,10 @@ def test_new_project_gets_since_and_backfill(conn, add_project): # «Н
|
||||
add_project(conn)
|
||||
_full_run(conn, {"history": {"issues": 0, "changes": 0, "incomplete": []}}, "2026-10-22T09:00:00+00:00")
|
||||
add_project(conn, "STOCKS_DRRK", board_id=5120)
|
||||
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
|
||||
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
|
||||
h = queue.claim(conn)["params"]["history"]
|
||||
assert h["since"] == "2026-10-22T08:45:00Z"
|
||||
assert h["jql"] == "(" + projects.base_jql(conn) + ") AND sprint in openSprints() AND statusCategory != Done"
|
||||
assert h["jql"] == "(" + jira_projects.base_jql(conn) + ") AND sprint in openSprints() AND statusCategory != Done"
|
||||
assert "STOCKS_DRRK" in h["jql"]
|
||||
|
||||
|
||||
@ -1007,7 +1008,7 @@ def test_no_projects_no_history(conn):
|
||||
|
||||
def test_redelivery_recomputes_history(conn, add_project): # Review Focus 3
|
||||
add_project(conn)
|
||||
b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
|
||||
b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
|
||||
first = queue.claim(conn)
|
||||
again = queue.claim(conn) # отчёта нет — довыдача
|
||||
assert again["id"] == first["id"] and again["params"]["history"] == first["params"]["history"]
|
||||
|
||||
85
tests/test_registry.py
Normal file
85
tests/test_registry.py
Normal 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)}"
|
||||
@ -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})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user