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/
|
||||
__pycache__/
|
||||
*.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