sdlc-hub/src/jira_hub/settings.py
Andrey Epifancev 9b21060d60 feat(commands): команда ensure_attachment — проверка файла при постановке, size при выдаче (#43)
SPEC ensure_attachment {key, file, filename}, пауза 30 с, по состоянию не подтверждается; queue._check_setup: формат file, файл загружен («файл <sha> не загружен: POST /files»), filename без / и \, без кавычек и управляющих символов (R-2); claim, довыдача и GET /agent/commands/{id} дописывают size (null без файла), в базе params без size, retry проходит (решения 17–18).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:46:14 +04:00

175 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Настройки хаба: пары ключ → 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)