App shell with sidebar and theme switch; attribute-set management; project rename; new pages for projects, runbooks and activity
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 5s
Work Records / validate (push) Successful in 12s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 295952@bnt-lap001
Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
This commit is contained in:
tegwick 2026-09-29 17:38:06 +02:00
parent a6ee6a0a6e
commit 6579618737
9 changed files with 663 additions and 13 deletions

View file

@ -144,3 +144,88 @@ def check_value(pack: P.Pack, name: str, value) -> str | None:
except (ValueError, KeyError) as e:
return str(e)
return None
# ---- managing attribute sets ---------------------------------------------------------------------------------------
import re as _re
SET_ID = _re.compile(r"[a-z0-9]+([.-][a-z0-9]+)*")
ATTR_NAME = _re.compile(r"[a-z][a-z0-9_]*")
def used_by(reg: Registry, set_id: str) -> dict[str, list[str]]:
"""Who reuses an attribute set: projects and templates (also through targets), and sets that extend it."""
out = {"projects": [], "templates": [], "sets": []}
for kind, docs in (("projects", reg.projects), ("templates", reg.templates)):
for did, d in docs.items():
refs = list(d.data.get("attribute_sets", []))
for t in d.data.get("targets", {}).values():
refs += t.get("attribute_sets", [])
if set_id in refs:
out[kind].append(did)
for sid, d in reg.sets.items():
if sid != set_id and set_id in d.data.get("extends", []):
out["sets"].append(sid)
return out
def _would_cycle(reg: Registry, set_id: str, extends: list[str]) -> bool:
def reaches(start: str, seen: set) -> bool:
if start == set_id:
return True
if start in seen or start not in reg.sets:
return False
seen.add(start)
return any(reaches(x, seen) for x in reg.sets[start].data.get("extends", []))
return any(reaches(x, set()) for x in extends)
def save_set(reg: Registry, root: Path, set_id: str, title: str, extends: list[str],
attributes: dict[str, dict], existing_path: Path | None = None) -> Path:
"""Write an attribute set file. `attributes` maps name -> {value, requirement, note} (any part may be empty).
Validates the id, title, names, secrets, that extended sets exist and that nothing becomes circular.
"""
if not SET_ID.fullmatch(set_id or ""):
raise ValueError("the id is lower-case letters, digits, dots and hyphens")
if len((title or "").strip()) < 3:
raise ValueError("the title needs at least 3 characters")
extends = [x for x in extends if x]
for x in extends:
if x == set_id:
raise ValueError("a set cannot extend itself")
if x not in reg.sets:
raise ValueError(f"unknown attribute set {x} in extends")
if _would_cycle(reg, set_id, extends):
raise ValueError("that extends list would make the sets circular")
out: dict = {}
for name, a in attributes.items():
if not ATTR_NAME.fullmatch(name):
raise ValueError(f"bad attribute name {name!r}: lower-case letters, digits and underscores, starting with a letter")
value = a.get("value")
value = None if value is None or str(value).strip() == "" else str(value).strip()
req = a.get("requirement") or None
if req not in (None, "required", "optional"):
raise ValueError(f"{name}: requirement is required or optional")
note = (a.get("note") or "").strip() or None
if value is None and req is None and note is None:
raise ValueError(f"{name}: give a value, a requirement or a note")
for text in (value or "", note or ""):
if SECRET_RE.search(text):
raise ValueError(f"{name}: looks like a secret; attribute sets are never secret")
entry = {k: v for k, v in (("value", value), ("requirement", req), ("note", note)) if v is not None}
out[name] = entry["value"] if list(entry) == ["value"] else entry
if SECRET_RE.search(title):
raise ValueError("the title looks like a secret")
doc: dict = {"spec": "attribute-set/v0.1", "id": set_id, "title": title.strip()}
if extends:
doc["extends"] = extends
doc["attributes"] = out
errs = _schema_errors(doc, "attribute-set.schema.json")
if errs:
raise ValueError(errs[0])
path = existing_path or (Path(root) / "attribute-sets" / f"{set_id}.yaml")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml.safe_dump(doc, sort_keys=False))
return path