diff --git a/.gitignore b/.gitignore index f6da44c..a9d9a38 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ tests/fixtures/*.webm .venv/ __pycache__/ *.sqlite* +.pytest_cache/ diff --git a/src/jira_hub/settings.py b/src/jira_hub/settings.py new file mode 100644 index 0000000..8093fe1 --- /dev/null +++ b/src/jira_hub/settings.py @@ -0,0 +1,126 @@ +"""Настройки хаба: пары ключ → JSON в таблице setting. Любая правка поднимает settings_version, +агент перечитывает настройки, когда видит новую версию в ответе claim.""" +import json +import secrets + +DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30, + "ensure_link": 30, "ensure_sprint": 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, +} + +# Что получает агент (без токенов и служебных ключей хаба) +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 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, "refresh": 2} + + +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": {"task": {"name": cfg.get("issue_type_task"), "id": cfg.get("issue_type_task_id")}, + "epic": {"name": cfg.get("issue_type_epic"), "id": cfg.get("issue_type_epic_id")}}, + "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) diff --git a/tests/fixtures/config.example.json b/tests/fixtures/config.example.json new file mode 100644 index 0000000..dde8a72 --- /dev/null +++ b/tests/fixtures/config.example.json @@ -0,0 +1,136 @@ +{ + "base_url": "https://sdlc.go.rshbank.ru/jira", + "auth": { + "type": "basic", + "user": "ваш_логин", + "password": "ваш_пароль", + "token": "", + "token_env": "JIRA_TOKEN", + "user_env": "JIRA_USER", + "password_env": "JIRA_PASSWORD" + }, + "verify_ssl": true, + "ca_bundle": null, + "timeout": 60, + "project_key": "AURORA", + "issue_type_task": "Задача", + "issue_type_task_id": "10100", + "issue_type_epic": "Фича", + "issue_type_epic_id": "12300", + "epic_name_field_id": "customfield_15304", + "fields": { + "description": "description", + "priority": "priority", + "duedate": "duedate", + "labels": "labels", + "planned_start": "customfield_10801", + "planned_end": "customfield_10802" + }, + "priority_value_key": "name", + "extra_fields_task": {}, + "extra_fields_epic": {}, + "epic_link": { + "mode": "field", + "field_id": "customfield_15302" + }, + "priority_map": { + "Наивысший": "Критичный", + "Высокий": "Высокий", + "Средний": "Средний", + "Низкий": "Низкий", + "Самый низкий": "Низкий" + }, + "labels": [ + "AWPCA", + "actualization-2026-10-10" + ], + "status_labels": { + "Готово — закрыть": "fact-done", + "Частично — переформулировать": "fact-partial", + "В работе": "fact-in-progress", + "Ожидает решения владельца/заказчика": "fact-decision", + "К актуализации аналитики": "fact-docs", + "Ожидание разработки": "fact-open", + "План развития (вне этапа)": "fact-roadmap", + "Снято": "fact-cancelled" + }, + "kind_labels": { + "аналитика": "kind-analytics", + "решение": "kind-decision", + "актуализация": "kind-docs", + "разработка": "kind-dev", + "UI": "kind-ui", + "тестирование": "kind-test", + "организационная": "kind-org" + }, + "update_description": true, + "comment_marker": "Актуализация 10.10.2026", + "pacing": { + "issue_seconds": 120, + "comment_seconds": 30 + }, + "transitions": { + "enabled": true, + "map": { + "Готово — закрыть": { + "default": [ + "Разработка", + "Готово к тестированию" + ], + "аналитика": [ + "Разработка", + "Готово" + ], + "решение": [ + "Разработка", + "Готово" + ], + "актуализация": [ + "Разработка", + "Готово" + ], + "организационная": [ + "Разработка", + "Готово" + ] + }, + "В работе": { + "default": "Разработка", + "аналитика": [ + "Ожидает анализа", + "Анализ" + ], + "решение": [ + "Ожидает анализа", + "Анализ" + ], + "актуализация": [ + "Ожидает анализа", + "Анализ" + ] + }, + "Ожидает решения владельца/заказчика": "Ожидает анализа", + "К актуализации аналитики": "Ожидает анализа", + "Ожидание разработки": { + "аналитика": "Ожидает анализа", + "решение": "Ожидает анализа", + "актуализация": "Ожидает анализа" + }, + "Частично — переформулировать": { + "аналитика": "Ожидает анализа", + "решение": "Ожидает анализа", + "актуализация": "Ожидает анализа" + }, + "Снято": "Снято" + } + }, + "link_types": { + "блокирует начало": "ASUPR_Блокер_Начала", + "блокирует завершение": "ASUPR_Блокер_Завершения", + "связанные": "ASUPR_Связанные" + }, + "sprints": { + "board_id": 4216, + "_comment": "id scrum-доски проекта для команды sprints (SCRUM_AWPCA); null — единственная доска проекта, при нескольких скрипт просит указать" + } +} diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..6ec2466 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,55 @@ +import json +from pathlib import Path + +import pytest + +from jira_hub import db, settings + +FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" + + +def fresh(): + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + return conn + + +def test_defaults_and_tokens(): + conn = fresh() + assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA" + assert settings.get(conn, "pacing")["ensure_fields"] == 120 + tok = settings.tokens(conn) + assert len(tok["agent"]) > 20 and tok["agent"] != tok["client"] + assert "tokens" not in settings.get_all(conn) + + +def test_set_bumps_version(): + conn = fresh() + v0 = settings.get(conn, "settings_version") + v1 = settings.set_(conn, "pacing", {"ensure_fields": 5}) + assert v1 == v0 + 1 + assert settings.get(conn, "pacing") == {"ensure_fields": 5} + + +@pytest.mark.parametrize("key", ["tokens", "settings_version", "schema_version"]) +def test_protected_keys(key): + conn = fresh() + with pytest.raises(ValueError): + settings.set_(conn, key, "x") + + +def test_init_from_config(): + conn = fresh() + cfg = json.loads(FIXTURE.read_text(encoding="utf-8")) + keys = settings.init_from_config(conn, cfg) + assert "transitions_map" in keys + assert settings.get(conn, "field_ids")["planned_end"] == "customfield_10802" + assert settings.get(conn, "issue_types")["epic"] == {"name": "Фича", "id": "12300"} + assert settings.get(conn, "board_id") == 4216 + assert settings.get(conn, "pacing") == settings.DEFAULT_PACING + agent_view = settings.for_agent(conn) + assert "tokens" not in agent_view and "auth" not in agent_view and "base_url" not in agent_view + assert agent_view["link_types"]["связанные"] == "ASUPR_Связанные" + with pytest.raises(RuntimeError): + settings.init_from_config(conn, cfg) + settings.init_from_config(conn, cfg, force=True)