feat(RMGR-WP-0008): add workplan and register receiving surfaces
This commit is contained in:
parent
5502afc1fd
commit
859df9aae7
15 changed files with 1501 additions and 11 deletions
311
src/repo_manager/commands/register.py
Normal file
311
src/repo_manager/commands/register.py
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
"""Governed mutation spine for repository-owned registers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
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
|
||||
|
||||
_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 datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue