From 46c957cff5ac73890d28c66918129a5a161d5abf Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 7 Jun 2026 19:01:18 -0700 Subject: [PATCH] =?UTF-8?q?feat(slice4):=20Owner-gated=20collection=20migr?= =?UTF-8?q?ate=20endpoint=20(=C2=A722.4a=20PUC-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST .../collections/{cid}/migrate — now safe to ship since all write paths are sidecar-aware. Idempotent, one commit per collection. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/api_metadata.py | 25 +++++++++++ backend/tests/test_metadata_edit_endpoint.py | 44 ++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/backend/app/api_metadata.py b/backend/app/api_metadata.py index f8ea126..77ed8bb 100644 --- a/backend/app/api_metadata.py +++ b/backend/app/api_metadata.py @@ -84,4 +84,29 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter: "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 diff --git a/backend/tests/test_metadata_edit_endpoint.py b/backend/tests/test_metadata_edit_endpoint.py index 9014c63..6b010d8 100644 --- a/backend/tests/test_metadata_edit_endpoint.py +++ b/backend/tests/test_metadata_edit_endpoint.py @@ -144,3 +144,47 @@ def test_get_rfc_exposes_meta_and_can_edit(app_with_fake_gitea): _login_owner(client) r = client.get(f"{META}/rfcs/a") assert r.json()["can_edit_meta"] is True + + +# ---- Owner-gated collection migrate endpoint (PUC-5) ---- + +def test_migrate_collection_endpoint_owner(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _seed_legacy(fake, "a", priority="P1", tags=["x"]) + _seed_legacy(fake, "b", priority="P0") + _refresh() + _login_owner(client) + r = client.post(f"{META}/migrate") + assert r.status_code == 200, r.text + assert r.json()["committed"] is True + assert set(r.json()["migrated"]) == {"a", "b"} + # both entries now body-only + sidecar + for slug in ("a", "b"): + assert "---" not in fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")]["content"] + assert ("wiggleverse", "meta", "main", f"rfcs/{slug}.meta.yaml") in fake.files + + +def test_migrate_collection_forbidden_for_contributor(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _seed_legacy(fake, "a", priority="P1") + _refresh() + provision_user_row(user_id=2, login="carol", role="contributor") + sign_in_as(client, user_id=2, gitea_login="carol", + display_name="Carol", role="contributor") + r = client.post(f"{META}/migrate") + assert r.status_code == 403, r.text + + +def test_migrate_collection_idempotent(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _seed_legacy(fake, "a", priority="P1") + _refresh() + _login_owner(client) + assert client.post(f"{META}/migrate").json()["committed"] is True + # second run: nothing left to migrate + r2 = client.post(f"{META}/migrate") + assert r2.status_code == 200, r2.text + assert r2.json()["committed"] is False