"""Governed mutation spine for repository-owned registers.""" from __future__ import annotations import re import uuid from pathlib import Path from typing import Any import yaml from repo_manager import dual_run, idempotency from repo_manager.commands.workplan import CommandResult from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff from repo_manager.index_store import append_event, default_index_path, save_index from repo_manager.observe import observe_repository from repo_manager.parse.register import REGISTER_SCHEMA, SUPPORTED_REGISTER_KINDS, register_path from repo_manager.time import utc_now_text _ENTRY_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$") _PROTECTED_FIELDS = frozenset({"id", "title", "status", "notes", "created", "updated"}) _REQUIRED_DATA = { "sbom-inventory": frozenset({"package_name", "ecosystem"}), "repo-goals": frozenset({"description"}), "upstream-contributions": frozenset({"type"}), "technical-debt": frozenset(), "extension-points": frozenset(), "register-entries": frozenset({"register_kind"}), } def _now() -> str: return utc_now_text() def _reject(command: str, correlation_id: str, code: str, message: str, **evidence: Any) -> CommandResult: return CommandResult( command=command, status="rejected", correlation_id=correlation_id, evidence={"status": "rejected", **evidence}, error={"code": code, "message": message, "correlation_id": correlation_id}, ) def _load(path: Path, kind: str) -> dict[str, Any]: if not path.is_file(): return {"schema": REGISTER_SCHEMA, "kind": kind, "entries": []} try: data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} except yaml.YAMLError as exc: raise ValueError(f"invalid register YAML: {exc}") from exc if not isinstance(data, dict) or not isinstance(data.get("entries", []), list): raise TypeError("register must be a mapping with an entries list") if data.get("kind", kind) != kind: raise ValueError(f"register kind does not match filename {kind!r}") data.setdefault("schema", REGISTER_SCHEMA) data.setdefault("kind", kind) data.setdefault("entries", []) return data def _save(path: Path, document: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text( yaml.safe_dump(document, sort_keys=False, allow_unicode=True), encoding="utf-8", ) def mutate_register_entry( repo_root: Path, kind: str, entry_id: str, *, operation: str, title: str | None = None, status: str | None = None, data: dict[str, Any] | None = None, note: str | None = None, note_author: str | None = None, correlation_id: str | None = None, reason: str = "rmgr command", commit: bool = True, push: bool = False, expected_head_sha: str | None = None, idempotency_key: str | None = None, repo_slug: str | None = None, ) -> CommandResult: """Upsert, defer, or annotate an entry in one of the shared register kinds.""" command_map = { "upsert": "repo.register.upsert_entry", "defer": "repo.register.defer_entry", "note": "repo.register.add_note", } correlation_id = correlation_id or str(uuid.uuid4()) command = command_map.get(operation, "repo.register.upsert_entry") repo_root = repo_root.resolve() payload = { "repo_root": str(repo_root), "kind": kind, "entry_id": entry_id, "operation": operation, "title": title, "status": status, "data": data, "note": note, "note_author": note_author, "expected_head_sha": expected_head_sha, } digest = idempotency.payload_hash(payload) if idempotency_key: prior = idempotency.get(idempotency_key) if prior: if prior.get("payload_hash") != digest: return _reject(command, correlation_id, "conflict", "idempotency key reused") previous = prior.get("result") or {} return CommandResult( command=previous.get("command", command), status=previous.get("status", "applied"), correlation_id=previous.get("correlation_id", correlation_id), evidence=previous.get("evidence") or {"status": "applied", "replay": True}, error=previous.get("error"), ) if operation not in command_map: return _reject(command, correlation_id, "validation_error", f"invalid operation {operation!r}") if kind not in SUPPORTED_REGISTER_KINDS: return _reject(command, correlation_id, "validation_error", f"unsupported register {kind!r}") if not _ENTRY_ID_RE.fullmatch(entry_id): return _reject(command, correlation_id, "validation_error", f"invalid entry id {entry_id!r}") if status is not None and not status.strip(): return _reject(command, correlation_id, "validation_error", "status cannot be empty") if data is not None and not isinstance(data, dict): return _reject(command, correlation_id, "validation_error", "data must be an object") protected = _PROTECTED_FIELDS.intersection(data or {}) if protected: return _reject( command, correlation_id, "validation_error", f"data contains protected fields: {', '.join(sorted(protected))}", ) current_head = head_sha(repo_root) if expected_head_sha and current_head and expected_head_sha != current_head: result = _reject( command, correlation_id, "precondition_failed", f"expected_head_sha {expected_head_sha} != {current_head}", head_sha=current_head, ) if result.error: result.error["retryable"] = True return result snapshot, _index = observe_repository(repo_root, slug=repo_slug) slug = repo_slug or snapshot.get("slug") path = register_path(repo_root, kind) try: document = _load(path, kind) except (TypeError, ValueError) as exc: return _reject(command, correlation_id, "conflict", str(exc)) entries = document["entries"] matches = [item for item in entries if isinstance(item, dict) and str(item.get("id")) == entry_id] if len(matches) > 1: return _reject(command, correlation_id, "conflict", f"duplicate entry id {entry_id!r}") entry = matches[0] if matches else None before = yaml.safe_dump(entry, sort_keys=True) if entry is not None else None now = _now() if operation == "upsert": if entry is None: if not title or not title.strip(): return _reject(command, correlation_id, "validation_error", "new entry requires title") missing = sorted(key for key in _REQUIRED_DATA[kind] if key not in (data or {})) if missing: return _reject( command, correlation_id, "validation_error", f"{kind} entry missing data fields: {', '.join(missing)}", ) entry = { "id": entry_id, "title": title.strip(), "status": (status or "open").strip(), **(data or {}), "created": now, "updated": now, } entries.append(entry) else: if title is not None: if not title.strip(): return _reject(command, correlation_id, "validation_error", "title cannot be empty") entry["title"] = title.strip() if status is not None: entry["status"] = status.strip() entry.update(data or {}) entry["updated"] = now elif operation == "defer": if entry is None: return _reject(command, correlation_id, "not_found", f"entry {entry_id!r} not found") entry["status"] = (status or "deferred").strip() entry["updated"] = now else: if entry is None: return _reject(command, correlation_id, "not_found", f"entry {entry_id!r} not found") if not note or not note.strip(): return _reject(command, correlation_id, "validation_error", "note requires content") entry.setdefault("notes", []).append( {"content": note.strip(), "author": note_author or "codex", "created": now} ) entry["updated"] = now after = yaml.safe_dump(entry, sort_keys=True) if before == after: result = CommandResult( command=command, status="applied", correlation_id=correlation_id, evidence={"status": "applied", "git_sha": current_head, "files_touched": [], "noop": True}, ) if idempotency_key: idempotency.put(idempotency_key, digest, result.to_dict()) return result _save(path, document) relative_path = str(path.relative_to(repo_root)) git_sha: str | None = None push_ok: bool | None = None push_message: str | None = None if commit: try: git_sha = commit_paths( repo_root, [relative_path], message=( f"{command} {kind}/{entry_id}\n\n" f"correlation_id: {correlation_id}\nreason: {reason}\nsource: repo-manager\n" ), ) except GitError as exc: return CommandResult( command=command, status="failed", correlation_id=correlation_id, evidence={"status": "failed", "files_touched": [relative_path], "git_sha": None}, error={"code": "internal", "message": str(exc), "correlation_id": correlation_id}, ) if push: push_ok, push_message = push_ff(repo_root) _snapshot_after, index_after = observe_repository(repo_root, slug=slug) append_event( index_after, { "type": "repo.command.applied", "command": command, "operation": operation, "correlation_id": correlation_id, "register_kind": kind, "entry_id": entry_id, "git_sha": git_sha, "files_touched": [relative_path], "source": "repo-manager", }, ) append_event( index_after, { "type": "repo.work.indexed", "correlation_id": correlation_id, "kind": f"register:{kind}", "id": entry_id, "source_path": relative_path, }, ) save_index(index_after) dual_run.record_mutation( source="repo-manager", kind=f"register_{operation}", repo_slug=slug, detail={ "register_kind": kind, "entry_id": entry_id, "git_sha": git_sha, "correlation_id": correlation_id, }, ) evidence: dict[str, Any] = { "status": "applied", "git_sha": git_sha, "files_touched": [relative_path], "index_path": str(default_index_path(repo_root)), "source": "repo-manager", } if push: evidence["push_ok"] = push_ok evidence["push_message"] = push_message result = CommandResult( command=command, status="applied", correlation_id=correlation_id, evidence=evidence, ) if idempotency_key: idempotency.put(idempotency_key, digest, result.to_dict()) return result