"""Настройки хаба: пары ключ → JSON в таблице setting. Любая правка поднимает settings_version, агент перечитывает настройки, когда видит новую версию в ответе claim.""" import json import os import secrets DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30, "ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "refresh": 2} DEFAULTS = { "base_jql": "project = AURORA AND labels = AWPCA", "project_key": "AURORA", "pacing": DEFAULT_PACING, "backup_keep": 30, "auto_refresh_minutes": 60, "min_agent_version": 1, "port": 8790, "settings_version": 1, "initialized": False, "issue_types": {}, "epic_name_field_id": None, "field_ids": {}, "epic_link": {}, "priority_map": {}, "priority_value_key": "name", "labels": [], "status_labels": {}, "kind_labels": {}, "comment_marker": "", "transitions_map": {}, "link_types": {}, "board_id": None, "relay_dir": None, } # Что получает агент (без токенов и служебных ключей хаба) AGENT_KEYS = ["base_jql", "project_key", "issue_types", "epic_name_field_id", "field_ids", "epic_link", "priority_map", "priority_value_key", "labels", "status_labels", "kind_labels", "comment_marker", "transitions_map", "link_types", "board_id", "pacing", "min_agent_version", "settings_version"] PROTECTED = {"settings_version", "schema_version", "tokens"} def _is_int(v) -> bool: return isinstance(v, int) and not isinstance(v, bool) def validate(key, value) -> None: """Проверить значение настройки до записи; ValueError — на 422 в API. pacing — объект {тип команды: целое >= 0} только с известными типами команд; backup_keep, auto_refresh_minutes, min_agent_version — целые >= 0; port — целое 1..65535; relay_dir — null или абсолютный путь строкой; остальные ключи — по типу значения по умолчанию (объект, список, строка, флаг), если оно не None; неизвестные ключи и ключи с умолчанием None принимаются как есть.""" if key == "pacing": if not isinstance(value, dict): raise ValueError("pacing — объект {тип команды: пауза в секундах}") for ctype, pause in value.items(): if ctype not in DEFAULT_PACING: raise ValueError(f"pacing: неизвестный тип команды «{ctype}»") if not _is_int(pause) or pause < 0: raise ValueError(f"pacing.{ctype}: ожидается целое >= 0") return if key == "port": if not _is_int(value) or not 1 <= value <= 65535: raise ValueError("port — целое 1..65535") return if key in ("backup_keep", "auto_refresh_minutes", "min_agent_version"): if not _is_int(value) or value < 0: raise ValueError(f"{key} — целое >= 0") return if key == "relay_dir": if value is not None and (not isinstance(value, str) or not os.path.isabs(value)): raise ValueError("relay_dir — абсолютный путь папки реле или null") return default = DEFAULTS.get(key) if default is not None and type(value) is not type(default): names = {dict: "объект", list: "список", str: "строка", bool: "true/false", int: "целое"} raise ValueError(f"{key} — {names.get(type(default), type(default).__name__)}") def ensure_defaults(conn) -> None: for key, value in DEFAULTS.items(): conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES (?, ?)", (key, _dump(value))) if get(conn, "tokens") is None: conn.execute("INSERT INTO setting(key, value) VALUES ('tokens', ?)", (_dump({"agent": secrets.token_urlsafe(24), "client": secrets.token_urlsafe(24)}),)) def _dump(value) -> str: return json.dumps(value, ensure_ascii=False) def get(conn, key, default=None): row = conn.execute("SELECT value FROM setting WHERE key = ?", (key,)).fetchone() return default if row is None else json.loads(row["value"]) def get_all(conn, hide_tokens=True) -> dict: out = {r["key"]: json.loads(r["value"]) for r in conn.execute("SELECT key, value FROM setting ORDER BY key")} if hide_tokens: out.pop("tokens", None) return out def set_(conn, key, value, bump=True) -> int: """Записать настройку; bump=True поднимает settings_version. Защищённые ключи снаружи не меняются.""" if key in PROTECTED: raise ValueError(f"настройка «{key}» не редактируется") conn.execute("INSERT INTO setting(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, _dump(value))) if bump: version = get(conn, "settings_version", 1) + 1 conn.execute("UPDATE setting SET value = ? WHERE key = 'settings_version'", (_dump(version),)) return version return get(conn, "settings_version", 1) def for_agent(conn) -> dict: return {key: get(conn, key) for key in AGENT_KEYS} def tokens(conn) -> dict: return get(conn, "tokens") def pacing(conn) -> dict: return {**DEFAULT_PACING, **(get(conn, "pacing") or {})} def pacing_from_config(p: dict) -> dict: """Старая форма pacing (issue_seconds / comment_seconds) → паузы по типам команд.""" issue, comment = p.get("issue_seconds", 120), p.get("comment_seconds", 30) return {"ensure_issue": issue, "ensure_fields": issue, "ensure_comment": comment, "ensure_status": comment, "ensure_link": comment, "ensure_sprint": comment, "ensure_attachment": comment, "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, "base_jql": f"project = {project} AND labels = AWPCA", "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", {}), "board_id": cfg.get("sprints", {}).get("board_id"), "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)