feat(slice4): Owner-gated collection migrate endpoint (§22.4a PUC-5)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user