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 . 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:
|
||||||
|
|||||||
@ -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":
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
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."""
|
"""Проекты-метки: таблица 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))
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
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,
|
"""Настройки хаба: пары ключ → 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)
|
|
||||||
|
|||||||
@ -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
|
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))
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 (?, ?, ?)",
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
@ -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)})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
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
|
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})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user