feat(hub): настройки, токены, init из config.example.json

Модуль settings: значения по умолчанию, версия настроек, защищённые ключи,
перенос проектных настроек из config.example.json (учётные данные остаются у агента).
Фикстура tests/fixtures/config.example.json — копия admin/jira-sync/config.example.json.
.pytest_cache/ добавлен в .gitignore.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
Andrey Epifancev 2026-10-11 21:50:46 +04:00
parent 81850783d4
commit f082541566
4 changed files with 318 additions and 0 deletions

1
.gitignore vendored
View File

@ -10,3 +10,4 @@ tests/fixtures/*.webm
.venv/
__pycache__/
*.sqlite*
.pytest_cache/

126
src/jira_hub/settings.py Normal file
View File

@ -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)

136
tests/fixtures/config.example.json vendored Normal file
View File

@ -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 — единственная доска проекта, при нескольких скрипт просит указать"
}
}

55
tests/test_settings.py Normal file
View File

@ -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)