"""§22.4a SLICE-4/5 — entry metadata edit endpoints. `POST .../rfcs//meta` writes schema-defined metadata to an entry's sidecar with a direct commit (D7: direct commit for authorized roles), validated against the collection's field schema at the write boundary (INV-4), lazy-migrating a legacy entry to a clean body-only `.md` on first edit. The Owner-gated `metadata.migrate_collection` operator endpoint also lives here (SLICE-4 carried work); SLICE-5's bulk endpoint will join it. """ from __future__ import annotations from typing import Any from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel from . import (auth, cache, collections as collections_mod, metadata as metadata_mod, metadata_schema, projects as projects_mod) from .bot import Bot from .config import Config from .gitea import Gitea, GiteaError class MetaEditBody(BaseModel): values: dict[str, Any] def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter: router = APIRouter() def _content_repo() -> tuple[str, str]: return config.gitea_org, (projects_mod.default_content_repo(config) or "") def _md_path(collection_id: str, slug: str) -> str: sub = collections_mod.subfolder_of(collection_id) or "" rfcs_dir = f"{sub}/rfcs" if sub else "rfcs" return f"{rfcs_dir}/{slug}.md" @router.post("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}/meta") async def edit_meta( project_id: str, collection_id: str, slug: str, body: MetaEditBody, request: Request, ) -> dict[str, Any]: viewer = auth.current_user(request) if collections_mod.project_of_collection(collection_id) != project_id: raise HTTPException(404, "Collection not in project") # INV-4: contributor+ on the collection (returns False for anonymous). if not auth.can_contribute_in_collection(viewer, collection_id): raise HTTPException(403, "Contributor access required to edit metadata") col = collections_mod.get_collection(collection_id) fields = (col or {}).get("fields") or {} if not fields: raise HTTPException(422, "Collection declares no editable fields") if not body.values: raise HTTPException(422, "Provide at least one field value") unknown = [k for k in body.values if k not in fields] if unknown: raise HTTPException(422, f"Unknown field(s): {', '.join(sorted(unknown))}") org, repo = _content_repo() md_path = _md_path(collection_id, slug) st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path) if st is None: raise HTTPException(404, f"{md_path} not found") new_entry = metadata_mod.apply_values(st.entry, body.values) problems = metadata_schema.validate( metadata_mod.metadata_dict(new_entry), fields) if problems: raise HTTPException(422, {"problems": [p.as_dict() for p in problems]}) files = metadata_mod.write_entry_files(md_path, new_entry, st) try: await bot.commit_entry_files( viewer.as_actor(), org=org, repo=repo, files=files, message=f"Edit metadata: {slug}", branch="main") except GiteaError as e: raise HTTPException(502, f"Gitea: {e.detail}") await cache.refresh_meta_repo(config, gitea) return { "ok": True, "slug": slug, "meta": metadata_mod.metadata_dict(new_entry), } @router.post("/api/projects/{project_id}/collections/{collection_id}/migrate") async def migrate( project_id: str, collection_id: str, request: Request ) -> dict[str, Any]: """§22.4a PUC-5: migrate a collection's legacy-frontmatter entries to clean body-only `.md` + sidecars, one commit per collection. Owner-gated operator action. Safe to ship now that every entry write path is sidecar-aware (SLICE-4 carried work). Idempotent.""" viewer = auth.current_user(request) if collections_mod.project_of_collection(collection_id) != project_id: raise HTTPException(404, "Collection not in project") if not auth.is_collection_superuser(viewer, collection_id): raise HTTPException(403, "Owner access required to migrate a collection") org, repo = _content_repo() subfolder = collections_mod.subfolder_of(collection_id) or "" try: result = await metadata_mod.migrate_collection( gitea, org=org, repo=repo, subfolder=subfolder, actor=viewer.as_actor()) except GiteaError as e: raise HTTPException(502, f"Gitea: {e.detail}") if result["committed"]: await cache.refresh_meta_repo(config, gitea) return result return router