§22 S2: create-collection endpoint (bot commit + registry refresh)
POST /api/projects/<id>/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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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_id>/.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(
|
||||
|
||||
@@ -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
|
||||
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user