From 0c654b173d7ebd927155eabf3a6a1cde08cc52c0 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 5 Jun 2026 13:10:09 -0700 Subject: [PATCH] =?UTF-8?q?=C2=A722=20S2:=20create-collection=20endpoint?= =?UTF-8?q?=20(bot=20commit=20+=20registry=20refresh)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/projects//collections, owner/admin-gated, commits a .collection.yaml to the content repo main via bot.create_collection, then re-mirrors the registry so the collections row appears (§22.2). Adds GET list/one collection routes. Extends FakeGitea to model directory listings so the mirror's content-repo walk is exercised end to end. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/api.py | 2 + backend/app/api_collections.py | 112 ++++++++++++++++++ backend/app/bot.py | 35 ++++++ .../tests/test_collection_create_vertical.py | 83 +++++++++++++ backend/tests/test_propose_vertical.py | 45 +++++-- 5 files changed, 266 insertions(+), 11 deletions(-) create mode 100644 backend/app/api_collections.py create mode 100644 backend/tests/test_collection_create_vertical.py diff --git a/backend/app/api.py b/backend/app/api.py index b81a5a5..55eb9fc 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -21,6 +21,7 @@ from pydantic import BaseModel, Field from . import ( api_admin, api_branches, + api_collections, api_contributions, api_deployment, api_discussion, @@ -152,6 +153,7 @@ def make_router( # §22.9/§22.10 (M3): runtime deployment + per-project config (replaces # VITE_APP_NAME) + the old-URL 308 redirects. router.include_router(api_deployment.make_router(config)) + router.include_router(api_collections.make_router(config, gitea, bot)) # --------------------------------------------------------------- # §17: /api/health — unauthenticated post-flight probe. diff --git a/backend/app/api_collections.py b/backend/app/api_collections.py new file mode 100644 index 0000000..c80866a --- /dev/null +++ b/backend/app/api_collections.py @@ -0,0 +1,112 @@ +"""§22 S2 — collection directory + create-collection. + +GET /api/projects/:id/collections — list the project's visible collections. +GET /api/projects/:id/collections/:cid — one collection's settings. +POST /api/projects/:id/collections — create a collection. Authorized by a + deployment owner/admin (S2; scoped + {owner, contributor} roles at the + collection axis land in S3). The bot + commits a `.collection.yaml` to the + content repo, then the registry mirror + upserts the collections row — §22.2 + keeps the registry the source of truth. +""" +from __future__ import annotations + +import re +from typing import Any + +import yaml +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from . import ( + auth, + collections as collections_mod, + projects as projects_mod, + registry as registry_mod, +) +from .bot import Bot +from .config import Config +from .gitea import Gitea, GiteaError + +_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") + + +class CreateCollectionBody(BaseModel): + collection_id: str + type: str + name: str | None = None + visibility: str | None = None + initial_state: str | None = None + + +def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter: + router = APIRouter() + + @router.get("/api/projects/{project_id}/collections") + async def list_cols(project_id: str, request: Request) -> dict[str, Any]: + viewer = auth.current_user(request) + # §22.5 read gate: a gated project 404s a non-member. + auth.require_project_readable(viewer, project_id) + return {"items": collections_mod.list_collections(project_id)} + + @router.get("/api/projects/{project_id}/collections/{collection_id}") + async def get_col(project_id: str, collection_id: str, request: Request) -> dict[str, Any]: + viewer = auth.current_user(request) + auth.require_project_readable(viewer, project_id) + col = collections_mod.get_collection(collection_id) + if col is None or col["project_id"] != project_id: + raise HTTPException(404, "Not found") + return col + + @router.post("/api/projects/{project_id}/collections") + async def create_col( + project_id: str, body: CreateCollectionBody, request: Request + ) -> dict[str, Any]: + # S2 authority: deployment owner/admin (the scoped-role surface is S3). + user = auth.require_admin(request) + auth.require_project_readable(user, project_id) + cid = body.collection_id.strip().lower() + if not _SLUG_RE.match(cid) or cid == "default": + raise HTTPException(422, "collection id must be a slug and not 'default'") + if body.type not in registry_mod.VALID_TYPES: + raise HTTPException(422, f"invalid type {body.type!r}") + if body.visibility is not None and body.visibility not in registry_mod.VALID_VISIBILITY: + raise HTTPException(422, f"invalid visibility {body.visibility!r}") + if body.initial_state is not None and body.initial_state not in registry_mod.VALID_INITIAL_STATE: + raise HTTPException(422, f"invalid initial_state {body.initial_state!r}") + if collections_mod.get_collection(cid) is not None: + raise HTTPException(409, f"collection `{cid}` already exists") + content_repo = projects_mod.content_repo(project_id) + if not content_repo: + raise HTTPException(409, "project has no content repo") + + manifest: dict[str, Any] = {"type": body.type} + if body.name: + manifest["name"] = body.name + if body.visibility: + manifest["visibility"] = body.visibility + if body.initial_state: + manifest["initial_state"] = body.initial_state + manifest_yaml = yaml.safe_dump(manifest, sort_keys=False) + + try: + await bot.create_collection( + user.as_actor(), + org=config.gitea_org, + content_repo=content_repo, + collection_id=cid, + manifest_yaml=manifest_yaml, + ) + except GiteaError as e: + raise HTTPException(502, f"Gitea: {e.detail}") + + # §22.2: re-read the registry so the new manifest becomes a row. + await registry_mod.refresh_registry(config, gitea) + col = collections_mod.get_collection(cid) + if col is None: + raise HTTPException(500, "collection committed but not mirrored") + return col + + return router diff --git a/backend/app/bot.py b/backend/app/bot.py index ce8fbd8..f985f23 100644 --- a/backend/app/bot.py +++ b/backend/app/bot.py @@ -163,6 +163,41 @@ class Bot: def __init__(self, gitea: Gitea): self._gitea = gitea + # ----- Content repo: collection structure (§22 S2) ----- + + async def create_collection( + self, + actor: Actor, + *, + org: str, + content_repo: str, + collection_id: str, + manifest_yaml: str, + ) -> dict: + """§22 S2: commit `/.collection.yaml` to the content + repo's main. A structural admin action — committed straight to main (no + PR), like the registry config it feeds; the registry mirror then upserts + the collections row (§22.2 keeps the registry the source of truth). Logs + an audit row for the §6.5 trail.""" + path = f"{collection_id}/.collection.yaml" + created = await self._gitea.create_file( + org, + content_repo, + path, + content=manifest_yaml, + message=_stamp_single(f"chore: create collection {collection_id}", actor), + branch="main", + author_name=actor.display_name, + author_email=actor.email or f"{actor.gitea_login}@users.noreply", + ) + _log( + actor, + "create_collection", + bot_commit_sha=created.get("commit", {}).get("sha"), + details={"collection_id": collection_id, "repo": content_repo}, + ) + return created + # ----- Meta repo: idea PRs (§9.1 / §9.2) ----- async def open_idea_pr( diff --git a/backend/tests/test_collection_create_vertical.py b/backend/tests/test_collection_create_vertical.py new file mode 100644 index 0000000..c19a978 --- /dev/null +++ b/backend/tests/test_collection_create_vertical.py @@ -0,0 +1,83 @@ +"""§22 S2 — create-collection vertical: a deployment owner/admin POSTs, the bot +commits a `.collection.yaml`, and the registry mirror upserts the collections +row (registry stays the source of truth).""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app import db +from test_propose_vertical import ( # noqa: F401 + app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as, +) + + +def test_create_collection_commits_manifest_and_mirrors(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=1, login="ben", role="owner") + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", + role="owner", email="ben@test") + r = client.post("/api/projects/default/collections", + json={"collection_id": "features", "type": "bdd", "name": "Features"}) + assert r.status_code == 200, r.text + assert r.json()["type"] == "bdd" + + # The bot committed the manifest to the content repo's main. + f = fake.files.get(("wiggleverse", "meta", "main", "features/.collection.yaml")) + assert f is not None + assert "type: bdd" in f["content"] + + # The registry refresh mirrored it into a collections row. + row = db.conn().execute( + "SELECT type, project_id, subfolder FROM collections WHERE id='features'" + ).fetchone() + assert (row["type"], row["project_id"], row["subfolder"]) == ("bdd", "default", "features") + + # It is now navigable via the directory + scoped serve. + items = client.get("/api/projects/default/collections").json()["items"] + assert any(c["id"] == "features" for c in items) + assert client.get("/api/projects/default/collections/features/rfcs").status_code == 200 + + +def test_create_collection_requires_admin(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", + role="contributor", email="alice@test") + r = client.post("/api/projects/default/collections", + json={"collection_id": "x", "type": "bdd"}) + assert r.status_code in (401, 403) + + +def test_create_collection_anonymous_rejected(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + r = client.post("/api/projects/default/collections", + json={"collection_id": "x", "type": "bdd"}) + assert r.status_code in (401, 403) + + +def test_create_collection_rejects_duplicate(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=1, login="ben", role="owner") + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", + role="owner", email="ben@test") + ok = client.post("/api/projects/default/collections", + json={"collection_id": "features", "type": "bdd"}) + assert ok.status_code == 200, ok.text + dup = client.post("/api/projects/default/collections", + json={"collection_id": "features", "type": "bdd"}) + assert dup.status_code == 409 + + +def test_create_collection_rejects_reserved_default_id(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=1, login="ben", role="owner") + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", + role="owner", email="ben@test") + r = client.post("/api/projects/default/collections", + json={"collection_id": "default", "type": "bdd"}) + assert r.status_code == 422 diff --git a/backend/tests/test_propose_vertical.py b/backend/tests/test_propose_vertical.py index 6045619..f4bc22c 100644 --- a/backend/tests/test_propose_vertical.py +++ b/backend/tests/test_propose_vertical.py @@ -90,6 +90,28 @@ class FakeGitea: self._commit_counter += 1 return f"sha{self._commit_counter:04d}" + def _dir_listing(self, owner, repo, ref, dirpath): + """Children directly under `dirpath` on (owner, repo, ref): files as + `type: file` and immediate subdirectories as `type: dir` (the shape real + Gitea returns for a contents listing).""" + prefix = (dirpath.rstrip("/") + "/") if dirpath else "" + files: dict[str, dict] = {} + dirs: set[str] = set() + for (o, r, br, p), data in self.files.items(): + if (o, r, br) != (owner, repo, ref) or not p.startswith(prefix): + continue + rest = p[len(prefix):] + if "/" in rest: + dirs.add(rest.split("/", 1)[0]) + elif rest: + files[p] = data + children = [{"name": n, "path": prefix + n, "type": "dir"} for n in sorted(dirs)] + children += [ + {"name": p.rsplit("/", 1)[-1], "path": p, "type": "file", "sha": d["sha"]} + for p, d in sorted(files.items()) + ] + return children + def _enrich_pr(self, owner: str, repo: str, pr: dict) -> dict: """Return the PR with mergeability fields filled in. @@ -227,6 +249,16 @@ class FakeGitea: } return httpx.Response(201, json={"name": new}) + # GET /repos/{owner}/{repo}/contents (root listing, empty path). §22 S2: + # the registry mirror walks the content-repo root for collection + # subfolders, so the simulator models a root directory listing that + # surfaces both file and `dir` children. + m_root = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/?", path) + if method == "GET" and m_root: + owner, repo = m_root.groups() + ref = request.url.params.get("ref", "main") + return httpx.Response(200, json=self._dir_listing(owner, repo, ref, "")) + # GET /repos/{owner}/{repo}/contents/{path}?ref=... m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path) if method == "GET" and m: @@ -242,17 +274,8 @@ class FakeGitea: "sha": f["sha"], "content": base64.b64encode(f["content"].encode()).decode(), }) - # Directory listing - prefix = fpath.rstrip("/") + "/" - children = [] - for (o, r, br, p), data in self.files.items(): - if (o, r, br) == (owner, repo, ref) and p.startswith(prefix) and "/" not in p[len(prefix):]: - children.append({ - "name": p.rsplit("/", 1)[-1], - "path": p, - "type": "file", - "sha": data["sha"], - }) + # Directory listing — both file and subdir children. + children = self._dir_listing(owner, repo, ref, fpath) if children: return httpx.Response(200, json=children) return httpx.Response(404, json={"message": "not found"})