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:
parent
81850783d4
commit
f082541566
1
.gitignore
vendored
1
.gitignore
vendored
@ -10,3 +10,4 @@ tests/fixtures/*.webm
|
|||||||
.venv/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.sqlite*
|
*.sqlite*
|
||||||
|
.pytest_cache/
|
||||||
|
|||||||
126
src/jira_hub/settings.py
Normal file
126
src/jira_hub/settings.py
Normal 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
136
tests/fixtures/config.example.json
vendored
Normal 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
55
tests/test_settings.py
Normal 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)
|
||||||
Loading…
x
Reference in New Issue
Block a user