Config file dual-run (env override), writeback_push, bulk-status facade on State Hub, pilot example config, evidence and finished workplan.
208 lines
5.6 KiB
Python
208 lines
5.6 KiB
Python
"""Dual-run flags and checkout-mutation meter (RMGR-WP-0002/0003).
|
|
|
|
Precedence (highest first):
|
|
1. Environment variables when set
|
|
2. Config file (~/.repo-manager/dual-run.yaml or RM_DUAL_RUN_CONFIG)
|
|
3. Defaults (all off)
|
|
|
|
Environment variables:
|
|
RM_WRITEBACK, RM_RECONCILE, RM_WRITEBACK_PUSH, RM_PILOT_REPOS, RM_METER_PATH,
|
|
RM_DUAL_RUN_CONFIG
|
|
|
|
Rollback: writeback/reconcile false in config and unset env → State Hub native.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from typing import Any, Literal
|
|
|
|
import yaml
|
|
|
|
Source = Literal["state-hub", "repo-manager"]
|
|
|
|
_TRUE = frozenset({"1", "true", "yes", "on"})
|
|
_FALSE = frozenset({"0", "false", "no", "off"})
|
|
|
|
|
|
def config_path() -> Path:
|
|
raw = os.environ.get("RM_DUAL_RUN_CONFIG", "~/.repo-manager/dual-run.yaml")
|
|
return Path(raw).expanduser()
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _load_file_config() -> dict[str, Any]:
|
|
path = config_path()
|
|
if not path.is_file():
|
|
return {}
|
|
try:
|
|
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
except (OSError, yaml.YAMLError):
|
|
return {}
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
|
|
def reload_config() -> None:
|
|
"""Clear cached config (tests / SIGHUP-style)."""
|
|
_load_file_config.cache_clear()
|
|
|
|
|
|
def _env_bool(name: str) -> bool | None:
|
|
raw = os.environ.get(name)
|
|
if raw is None or raw.strip() == "":
|
|
return None
|
|
v = raw.strip().lower()
|
|
if v in _TRUE:
|
|
return True
|
|
if v in _FALSE:
|
|
return False
|
|
return None
|
|
|
|
|
|
def _cfg_bool(key: str, default: bool = False) -> bool:
|
|
env_map = {
|
|
"writeback": "RM_WRITEBACK",
|
|
"reconcile": "RM_RECONCILE",
|
|
"writeback_push": "RM_WRITEBACK_PUSH",
|
|
}
|
|
env_name = env_map.get(key)
|
|
if env_name:
|
|
ev = _env_bool(env_name)
|
|
if ev is not None:
|
|
return ev
|
|
cfg = _load_file_config()
|
|
if key in cfg:
|
|
val = cfg[key]
|
|
if isinstance(val, bool):
|
|
return val
|
|
if isinstance(val, str):
|
|
return val.strip().lower() in _TRUE
|
|
return default
|
|
|
|
|
|
def writeback_enabled() -> bool:
|
|
return _cfg_bool("writeback", False)
|
|
|
|
|
|
def reconcile_enabled() -> bool:
|
|
return _cfg_bool("reconcile", False)
|
|
|
|
|
|
def writeback_push_enabled() -> bool:
|
|
return _cfg_bool("writeback_push", False)
|
|
|
|
|
|
def pilot_slugs() -> set[str] | None:
|
|
"""None = all repos; empty set = none."""
|
|
raw = os.environ.get("RM_PILOT_REPOS")
|
|
if raw is not None:
|
|
raw = raw.strip()
|
|
if not raw:
|
|
return set()
|
|
return {s.strip() for s in raw.split(",") if s.strip()}
|
|
cfg = _load_file_config()
|
|
if "pilot_repos" not in cfg:
|
|
return None
|
|
val = cfg["pilot_repos"]
|
|
if val is None:
|
|
return None
|
|
if isinstance(val, str):
|
|
if not val.strip():
|
|
return set()
|
|
return {s.strip() for s in val.split(",") if s.strip()}
|
|
if isinstance(val, list):
|
|
return {str(s).strip() for s in val if str(s).strip()}
|
|
return None
|
|
|
|
|
|
def slug_allowed(slug: str | None) -> bool:
|
|
if not slug:
|
|
return False
|
|
pilots = pilot_slugs()
|
|
if pilots is None:
|
|
return True
|
|
return slug in pilots
|
|
|
|
|
|
def writeback_for_repo(slug: str | None) -> bool:
|
|
return writeback_enabled() and slug_allowed(slug)
|
|
|
|
|
|
def reconcile_for_repo(slug: str | None) -> bool:
|
|
return reconcile_enabled() and slug_allowed(slug)
|
|
|
|
|
|
def meter_path() -> Path:
|
|
raw = os.environ.get("RM_METER_PATH")
|
|
if raw:
|
|
return Path(raw).expanduser()
|
|
cfg = _load_file_config()
|
|
if cfg.get("meter_path"):
|
|
return Path(str(cfg["meter_path"])).expanduser()
|
|
return Path("~/.repo-manager/mutation-meter.jsonl").expanduser()
|
|
|
|
|
|
def record_mutation(
|
|
*,
|
|
source: Source,
|
|
kind: str,
|
|
repo_slug: str | None,
|
|
detail: dict[str, Any] | None = None,
|
|
) -> None:
|
|
try:
|
|
path = meter_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
row = {
|
|
"ts": datetime.now(timezone.utc).isoformat(),
|
|
"source": source,
|
|
"kind": kind,
|
|
"repo_slug": repo_slug,
|
|
"detail": detail or {},
|
|
}
|
|
with path.open("a", encoding="utf-8") as fh:
|
|
fh.write(json.dumps(row, default=str) + "\n")
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def meter_summary(path: Path | None = None) -> dict[str, int]:
|
|
path = path or meter_path()
|
|
counts: dict[str, int] = {"state-hub": 0, "repo-manager": 0, "total": 0}
|
|
if not path.is_file():
|
|
return counts
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
row = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
src = row.get("source")
|
|
if src in counts:
|
|
counts[src] += 1
|
|
counts["total"] += 1
|
|
return counts
|
|
|
|
|
|
def flags_status() -> dict[str, Any]:
|
|
return {
|
|
"config_path": str(config_path()),
|
|
"config_exists": config_path().is_file(),
|
|
"writeback": writeback_enabled(),
|
|
"reconcile": reconcile_enabled(),
|
|
"writeback_push": writeback_push_enabled(),
|
|
"pilot_repos": sorted(pilot_slugs()) if pilot_slugs() is not None else None,
|
|
"meter_path": str(meter_path()),
|
|
"meter": meter_summary(),
|
|
# legacy env-style keys for operators
|
|
"RM_WRITEBACK": writeback_enabled(),
|
|
"RM_RECONCILE": reconcile_enabled(),
|
|
"RM_WRITEBACK_PUSH": writeback_push_enabled(),
|
|
"RM_PILOT_REPOS": sorted(pilot_slugs()) if pilot_slugs() is not None else None,
|
|
"RM_METER_PATH": str(meter_path()),
|
|
}
|