599e7018f6
Plan for slice S2 of the three-tier (deployment→project→collection) refactor. Completes acceptance @S2 (C3.6). See docs/design/2026-06-05-three-tier-projects-collections.md Part E. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1211 lines
51 KiB
Markdown
1211 lines
51 KiB
Markdown
# §22 S2 — Create & navigate a second collection — Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to
|
|
> implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Let a deployment admin create a second RFC collection beside a project's
|
|
default one, navigate to it, and propose into it — completing acceptance scenario
|
|
C3.6 (`@S2`): an anonymous visitor landing on an empty public collection's catalog
|
|
sees an empty catalog with no propose action and a sign-in prompt.
|
|
|
|
**Architecture:** S1 shipped the collection grain (migration 029): a `collections`
|
|
table, entries keyed `(collection_id, slug)`, one seeded `default` collection per
|
|
project. S2 makes collections *plural and navigable*. Backend: the registry mirror
|
|
learns to read `.collection.yaml` manifests inside each project's content repo; the
|
|
corpus mirror becomes collection-grained (reads each collection's `<subfolder>/rfcs/`);
|
|
a bot-commit-wrapped create-collection endpoint commits a manifest; collection-scoped
|
|
list/get/propose endpoints land under `/api/projects/:id/collections/:cid/…`. Frontend:
|
|
`/p/<project>/` becomes a collection directory (1 visible → redirect, keeping S1; 2+ →
|
|
list); the Catalog rail + entry views read the active `:collectionId` from the route.
|
|
|
|
**Tech Stack:** FastAPI + sqlite (backend, `backend/app/`), pytest (`backend/tests/`),
|
|
React + react-router + vitest (`frontend/src/`), Gitea content repos via the Bot wrapper.
|
|
|
|
**Content-repo layout convention (decided this slice):** a collection with subfolder
|
|
`S` stores its manifest at `S/.collection.yaml` and its entries at `S/rfcs/`. The
|
|
default collection has subfolder `''` → manifest is the project's `projects.yaml`
|
|
entry (no `.collection.yaml`), entries at `rfcs/`. This keeps the shipped default
|
|
corpus path unchanged.
|
|
|
|
**Authorization (this slice):** create-collection is gated to a deployment
|
|
owner/admin (`auth.require_admin`). Scoped {owner, contributor} roles at the
|
|
collection axis land in S3 — out of scope here.
|
|
|
|
---
|
|
|
|
## File structure
|
|
|
|
**Backend (modify):**
|
|
- `backend/app/registry.py` — add `.collection.yaml` discovery + upsert to the mirror.
|
|
- `backend/app/collections.py` — add `list_collections`, `get_collection`, `subfolder_of`.
|
|
- `backend/app/cache.py` — make `refresh_meta_repo` iterate a project's collections.
|
|
- `backend/app/bot.py` — add `create_collection` (commit `<subfolder>/.collection.yaml`).
|
|
- `backend/app/api.py` — add collection-scoped list/get/propose; refactor propose to take a collection.
|
|
- `backend/app/api_collections.py` — **create**: GET list/one + POST create-collection endpoints.
|
|
- `backend/app/main.py` — mount the new router.
|
|
|
|
**Frontend (modify):**
|
|
- `frontend/src/lib/entryPaths.js` — add `useCollectionId()`.
|
|
- `frontend/src/api.js` — collection-scoped `listRFCs`/`getRFC`/`proposeRFC`; add `listCollections`, `createCollection`.
|
|
- `frontend/src/components/Catalog.jsx` — read active collection; scope fetches + links.
|
|
- `frontend/src/App.jsx` — `/p/<project>/` → `CollectionDirectory`; thread collection into propose.
|
|
- `frontend/src/components/CollectionDirectory.jsx` — **create**: the directory page.
|
|
|
|
**Tests (create):**
|
|
- `backend/tests/test_collection_registry.py` — `.collection.yaml` mirror.
|
|
- `backend/tests/test_collection_create_vertical.py` — create → mirror → list round-trip.
|
|
- `backend/tests/test_collection_scoped_serve.py` — list/get/propose under a named collection.
|
|
- `frontend/src/lib/entryPaths.test.js` (extend) + `frontend/src/components/CollectionDirectory.test.jsx`.
|
|
|
|
---
|
|
|
|
## Task 1: Registry mirror reads `.collection.yaml` manifests
|
|
|
|
**Files:**
|
|
- Modify: `backend/app/registry.py`
|
|
- Test: `backend/tests/test_collection_registry.py`
|
|
|
|
The mirror today upserts only the default collection from `projects.yaml`. Add a
|
|
`parse_collection_manifest(text)` (pure) and extend `refresh_registry` to walk each
|
|
project's content repo root, read every `<subdir>/.collection.yaml`, and upsert a
|
|
collection row keyed by the subdir name.
|
|
|
|
- [ ] **Step 1: Write the failing test** for the pure parser.
|
|
|
|
```python
|
|
# backend/tests/test_collection_registry.py
|
|
import pytest
|
|
from app import registry
|
|
|
|
|
|
def test_parse_collection_manifest_minimal():
|
|
doc = registry.parse_collection_manifest("type: bdd\n")
|
|
assert doc.type == "bdd"
|
|
# §22.4b: bdd defaults to 'active'; visibility inherits (None == inherit).
|
|
assert doc.initial_state == "active"
|
|
assert doc.visibility is None
|
|
assert doc.name is None
|
|
|
|
|
|
def test_parse_collection_manifest_full():
|
|
doc = registry.parse_collection_manifest(
|
|
"type: document\nvisibility: public\ninitial_state: active\nname: Model\n"
|
|
)
|
|
assert (doc.type, doc.visibility, doc.initial_state, doc.name) == (
|
|
"document", "public", "active", "Model",
|
|
)
|
|
|
|
|
|
def test_parse_collection_manifest_rejects_bad_type():
|
|
with pytest.raises(registry.RegistryError):
|
|
registry.parse_collection_manifest("type: nonsense\n")
|
|
```
|
|
|
|
- [ ] **Step 2: Run it, verify it fails.**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_collection_registry.py -q`
|
|
Expected: FAIL (`parse_collection_manifest` not defined).
|
|
|
|
- [ ] **Step 3: Implement `parse_collection_manifest` + `CollectionEntry`** in `registry.py`.
|
|
|
|
```python
|
|
# Add near ProjectEntry (after line 56).
|
|
@dataclass
|
|
class CollectionEntry:
|
|
type: str
|
|
visibility: str | None # None == inherit the project's visibility
|
|
initial_state: str
|
|
name: str | None
|
|
|
|
|
|
def parse_collection_manifest(text: str) -> CollectionEntry:
|
|
"""Parse + validate a `.collection.yaml`. Pure. Raises RegistryError."""
|
|
raw = yaml.safe_load(text) or {}
|
|
if not isinstance(raw, dict):
|
|
raise RegistryError("collection manifest must be a mapping")
|
|
ctype = str(raw.get("type") or "").strip()
|
|
if ctype not in VALID_TYPES:
|
|
raise RegistryError(f"collection has invalid type {ctype!r}")
|
|
vis = raw.get("visibility")
|
|
if vis is not None:
|
|
vis = str(vis).strip()
|
|
if vis not in VALID_VISIBILITY:
|
|
raise RegistryError(f"collection has invalid visibility {vis!r}")
|
|
initial_state = str(
|
|
raw.get("initial_state") or _TYPE_DEFAULT_INITIAL_STATE[ctype]
|
|
).strip()
|
|
if initial_state not in VALID_INITIAL_STATE:
|
|
raise RegistryError(f"collection has invalid initial_state {initial_state!r}")
|
|
name = raw.get("name")
|
|
name = str(name).strip() if name else None
|
|
return CollectionEntry(ctype, vis, initial_state, name)
|
|
```
|
|
|
|
- [ ] **Step 4: Run the parser tests, verify PASS.**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_collection_registry.py -q`
|
|
Expected: PASS (3 tests).
|
|
|
|
- [ ] **Step 5: Write the failing mirror test** (uses a fake Gitea exposing the content repo).
|
|
|
|
```python
|
|
# Append to backend/tests/test_collection_registry.py
|
|
import asyncio
|
|
|
|
|
|
class _FakeGitea:
|
|
"""Minimal Gitea stub: projects.yaml in the registry repo + a content repo
|
|
holding one `.collection.yaml` under `features/`."""
|
|
def __init__(self, projects_yaml, repo_tree):
|
|
self._projects_yaml = projects_yaml
|
|
self._repo_tree = repo_tree # {repo: {path: text}}
|
|
|
|
async def get_contents(self, org, repo, path, ref="main"):
|
|
import base64
|
|
if path == "projects.yaml":
|
|
enc = base64.b64encode(self._projects_yaml.encode()).decode()
|
|
return {"type": "file", "content": enc, "sha": "deadbeef"}
|
|
text = self._repo_tree.get(repo, {}).get(path)
|
|
if text is None:
|
|
return None
|
|
return {"type": "file",
|
|
"content": base64.b64encode(text.encode()).decode(), "sha": "c0ffee"}
|
|
|
|
async def list_dir(self, org, repo, path, ref="main"):
|
|
# Root listing: surface each top-level subdir as a 'dir' entry.
|
|
names = set()
|
|
for p in self._repo_tree.get(repo, {}):
|
|
head = p.split("/", 1)[0]
|
|
if "/" in p:
|
|
names.add(head)
|
|
return [{"type": "dir", "name": n, "path": n} for n in sorted(names)]
|
|
|
|
|
|
def test_refresh_registry_mirrors_named_collection(app_with_db, config):
|
|
gitea = _FakeGitea(
|
|
projects_yaml=(
|
|
"deployment:\n name: Ohm\n tagline: t\n"
|
|
"projects:\n - id: ohm\n name: Ohm\n type: document\n"
|
|
" content_repo: ohm-rfc\n visibility: public\n"
|
|
),
|
|
repo_tree={"ohm-rfc": {"features/.collection.yaml": "type: bdd\nname: Features\n"}},
|
|
)
|
|
asyncio.run(registry.refresh_registry(config, gitea))
|
|
from app import db
|
|
row = db.conn().execute(
|
|
"SELECT type, subfolder, name, project_id FROM collections WHERE id = 'features'"
|
|
).fetchone()
|
|
assert row is not None
|
|
assert (row["type"], row["subfolder"], row["project_id"]) == ("bdd", "features", "ohm")
|
|
```
|
|
|
|
> Use the existing test app/db fixture pattern — mirror `app_with_db` / `config`
|
|
> fixtures from `backend/tests/test_registry.py`. If that test uses different
|
|
> fixture names, copy its setup verbatim here.
|
|
|
|
- [ ] **Step 6: Run it, verify it fails** (`features` collection not mirrored).
|
|
|
|
Run: `cd backend && python -m pytest tests/test_collection_registry.py::test_refresh_registry_mirrors_named_collection -q`
|
|
Expected: FAIL (row is None).
|
|
|
|
- [ ] **Step 7: Extend `refresh_registry`** to discover + upsert named collections.
|
|
|
|
After `apply_registry(...)` in `refresh_registry` (registry.py line 215), add:
|
|
|
|
```python
|
|
# §22 S2: named collections are declared by `.collection.yaml` manifests
|
|
# inside each project's content repo (the default collection comes from
|
|
# projects.yaml above). Walk each content repo root; a subdir carrying a
|
|
# manifest becomes a collection keyed by the subdir name.
|
|
for proj in doc.projects:
|
|
try:
|
|
items = await gitea.list_dir(config.gitea_org, proj.content_repo, "", ref="main")
|
|
except Exception as e: # GiteaError or transport — tolerate, keep last-good
|
|
log.warning("registry: cannot list %s root: %s", proj.content_repo, e)
|
|
continue
|
|
for it in items:
|
|
if it.get("type") != "dir":
|
|
continue
|
|
subdir = it["name"]
|
|
manifest = await gitea.get_contents(
|
|
config.gitea_org, proj.content_repo, f"{subdir}/.collection.yaml", ref="main"
|
|
)
|
|
if not manifest or manifest.get("type") != "file":
|
|
continue
|
|
mtext = base64.b64decode(manifest["content"]).decode("utf-8")
|
|
try:
|
|
ce = parse_collection_manifest(mtext)
|
|
except RegistryError as e:
|
|
log.error("registry: bad manifest %s/%s: %s", proj.content_repo, subdir, e)
|
|
continue
|
|
_upsert_named_collection(proj, subdir, ce, sha)
|
|
```
|
|
|
|
And add the upsert helper (after `apply_registry`):
|
|
|
|
```python
|
|
def _upsert_named_collection(proj: ProjectEntry, subdir: str, ce: CollectionEntry, sha: str) -> None:
|
|
"""Upsert one named collection. Type is immutable (§22.4a): a type change
|
|
against an existing row is refused (logged, not applied). Visibility None
|
|
inherits the project's visibility."""
|
|
visibility = ce.visibility or proj.visibility
|
|
with db.tx() as conn:
|
|
existing = conn.execute(
|
|
"SELECT type FROM collections WHERE id = ?", (subdir,)
|
|
).fetchone()
|
|
if existing is not None and existing["type"] != ce.type:
|
|
log.error("registry: refusing immutable type change on collection %s (%s -> %s)",
|
|
subdir, existing["type"], ce.type)
|
|
return
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO collections
|
|
(id, project_id, type, subfolder, initial_state, visibility, name, registry_sha, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
project_id = excluded.project_id,
|
|
initial_state = excluded.initial_state,
|
|
visibility = excluded.visibility,
|
|
name = excluded.name,
|
|
registry_sha = excluded.registry_sha,
|
|
updated_at = datetime('now')
|
|
""",
|
|
(subdir, proj.id, ce.type, subdir, ce.initial_state, visibility, ce.name, sha),
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 8: Run all of Task 1's tests, verify PASS.**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_collection_registry.py -q`
|
|
Expected: PASS (4 tests).
|
|
|
|
- [ ] **Step 9: Commit.**
|
|
|
|
```bash
|
|
git add backend/app/registry.py backend/tests/test_collection_registry.py
|
|
git commit -m "§22 S2: registry mirror reads .collection.yaml manifests"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Collection read helpers — `list_collections`, `get_collection`, `subfolder_of`
|
|
|
|
**Files:**
|
|
- Modify: `backend/app/collections.py`
|
|
- Test: `backend/tests/test_collection_create_vertical.py` (first assertions)
|
|
|
|
- [ ] **Step 1: Write the failing test.**
|
|
|
|
```python
|
|
# backend/tests/test_collection_create_vertical.py
|
|
from app import db, collections as collections_mod
|
|
|
|
|
|
def _seed(project_id="ohm"):
|
|
db.conn().execute(
|
|
"INSERT INTO projects (id, name, content_repo, visibility, updated_at) "
|
|
"VALUES (?, 'Ohm', 'ohm-rfc', 'public', datetime('now'))", (project_id,))
|
|
for cid, sub, vis, name in [
|
|
("default", "", "public", "Model"),
|
|
("features", "features", "public", "Features"),
|
|
("secret", "secret", "unlisted", "Secret"),
|
|
]:
|
|
db.conn().execute(
|
|
"INSERT INTO collections (id, project_id, type, subfolder, initial_state, "
|
|
"visibility, name, created_at, updated_at) VALUES (?,?, 'document', ?, "
|
|
"'super-draft', ?, ?, datetime('now'), datetime('now'))",
|
|
(cid, project_id, sub, vis, name))
|
|
|
|
|
|
def test_list_collections_excludes_unlisted(app_with_db):
|
|
_seed()
|
|
ids = [c["id"] for c in collections_mod.list_collections("ohm", include_unlisted=False)]
|
|
assert ids == ["default", "features"] # 'secret' (unlisted) omitted
|
|
|
|
|
|
def test_get_collection_and_subfolder(app_with_db):
|
|
_seed()
|
|
assert collections_mod.get_collection("features")["name"] == "Features"
|
|
assert collections_mod.subfolder_of("features") == "features"
|
|
assert collections_mod.subfolder_of("default") == ""
|
|
assert collections_mod.get_collection("nope") is None
|
|
```
|
|
|
|
- [ ] **Step 2: Run it, verify it fails.**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_collection_create_vertical.py -q`
|
|
Expected: FAIL (`list_collections` not defined).
|
|
|
|
- [ ] **Step 3: Implement the helpers** in `collections.py`.
|
|
|
|
```python
|
|
def get_collection(collection_id: str) -> dict | None:
|
|
row = db.conn().execute(
|
|
"SELECT id, project_id, type, subfolder, initial_state, visibility, name "
|
|
"FROM collections WHERE id = ?", (collection_id,)
|
|
).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def subfolder_of(collection_id: str) -> str:
|
|
row = db.conn().execute(
|
|
"SELECT subfolder FROM collections WHERE id = ?", (collection_id,)
|
|
).fetchone()
|
|
return (row["subfolder"] if row else "") or ""
|
|
|
|
|
|
def list_collections(project_id: str, include_unlisted: bool = False) -> list[dict]:
|
|
"""Collections in a project, default first then by name. `unlisted` is
|
|
omitted from enumeration unless include_unlisted (a direct-id read)."""
|
|
rows = db.conn().execute(
|
|
"SELECT id, project_id, type, subfolder, initial_state, visibility, name "
|
|
"FROM collections WHERE project_id = ? ORDER BY (id != 'default'), name, id",
|
|
(project_id,),
|
|
).fetchall()
|
|
out = []
|
|
for r in rows:
|
|
if not include_unlisted and r["visibility"] == "unlisted":
|
|
continue
|
|
out.append(dict(r))
|
|
return out
|
|
```
|
|
|
|
- [ ] **Step 4: Run it, verify PASS.**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_collection_create_vertical.py -q`
|
|
Expected: PASS (2 tests).
|
|
|
|
- [ ] **Step 5: Commit.**
|
|
|
|
```bash
|
|
git add backend/app/collections.py backend/tests/test_collection_create_vertical.py
|
|
git commit -m "§22 S2: collection read helpers (list/get/subfolder)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Corpus mirror is collection-grained
|
|
|
|
**Files:**
|
|
- Modify: `backend/app/cache.py:37-97`
|
|
- Test: `backend/tests/test_collection_scoped_serve.py` (first assertion)
|
|
|
|
`refresh_meta_repo` reads only `rfcs/` (the default collection). Make it iterate a
|
|
project's collections and read each collection's `<subfolder>/rfcs/`, keying
|
|
`cached_rfcs` by `collection_id`.
|
|
|
|
- [ ] **Step 1: Write the failing test.**
|
|
|
|
```python
|
|
# backend/tests/test_collection_scoped_serve.py
|
|
import asyncio
|
|
from app import db, cache
|
|
|
|
|
|
class _CorpusGitea:
|
|
"""A content repo with entries under both the default `rfcs/` and a named
|
|
collection's `features/rfcs/`."""
|
|
def __init__(self, tree):
|
|
self._tree = tree # {path: text}
|
|
|
|
async def list_dir(self, org, repo, path, ref="main"):
|
|
out = []
|
|
prefix = (path.rstrip("/") + "/") if path else ""
|
|
for p in self._tree:
|
|
if p.startswith(prefix) and "/" not in p[len(prefix):]:
|
|
out.append({"type": "file", "name": p.split("/")[-1], "path": p})
|
|
return out
|
|
|
|
async def read_file(self, org, repo, path, ref="main"):
|
|
t = self._tree.get(path)
|
|
return (t, "sha-" + path) if t is not None else None
|
|
|
|
|
|
def _entry_md(slug, title):
|
|
return f"---\nslug: {slug}\ntitle: {title}\nstate: active\n---\nbody\n"
|
|
|
|
|
|
def test_mirror_keys_entries_by_collection(app_with_db, config):
|
|
db.conn().execute(
|
|
"INSERT INTO projects (id, name, content_repo, visibility, updated_at) "
|
|
"VALUES ('ohm','Ohm','ohm-rfc','public', datetime('now'))")
|
|
for cid, sub in [("default", ""), ("features", "features")]:
|
|
db.conn().execute(
|
|
"INSERT INTO collections (id, project_id, type, subfolder, initial_state, "
|
|
"visibility, created_at, updated_at) VALUES (?, 'ohm','document',?, "
|
|
"'super-draft','public', datetime('now'), datetime('now'))", (cid, sub))
|
|
gitea = _CorpusGitea({
|
|
"rfcs/a.md": _entry_md("a", "Default A"),
|
|
"features/rfcs/b.md": _entry_md("b", "Feature B"),
|
|
})
|
|
asyncio.run(cache.refresh_meta_repo(config, gitea))
|
|
got = {(r["collection_id"], r["slug"]) for r in
|
|
db.conn().execute("SELECT collection_id, slug FROM cached_rfcs")}
|
|
assert got == {("default", "a"), ("features", "b")}
|
|
```
|
|
|
|
- [ ] **Step 2: Run it, verify it fails** (entry `b` mirrored to wrong/absent collection).
|
|
|
|
Run: `cd backend && python -m pytest tests/test_collection_scoped_serve.py::test_mirror_keys_entries_by_collection -q`
|
|
Expected: FAIL.
|
|
|
|
- [ ] **Step 3: Rework `refresh_meta_repo` + `_refresh_project_corpus`** in `cache.py`.
|
|
|
|
Replace the body of `_refresh_project_corpus` so it loops the project's collections:
|
|
|
|
```python
|
|
async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: Gitea) -> None:
|
|
# §22 S2: the corpus grain is the collection. Mirror each collection of the
|
|
# project from its `<subfolder>/rfcs/` directory, keying cached_rfcs by the
|
|
# collection id. The default collection (subfolder '') reads `rfcs/`.
|
|
from . import collections as collections_mod
|
|
for col in collections_mod.list_collections(project_id, include_unlisted=True):
|
|
collection_id = col["id"]
|
|
sub = col["subfolder"] or ""
|
|
rfcs_dir = f"{sub}/rfcs" if sub else "rfcs"
|
|
try:
|
|
files = await gitea.list_dir(org, repo, rfcs_dir, ref="main")
|
|
except GiteaError as e:
|
|
log.warning("refresh_meta_repo: %s/%s: cannot list %s: %s",
|
|
project_id, collection_id, rfcs_dir, e)
|
|
continue
|
|
seen_slugs: set[str] = set()
|
|
for f in files:
|
|
if f.get("type") != "file" or not f.get("name", "").endswith(".md"):
|
|
continue
|
|
result = await gitea.read_file(org, repo, f["path"], ref="main")
|
|
if not result:
|
|
continue
|
|
text, sha = result
|
|
try:
|
|
entry = entry_mod.parse(text)
|
|
except Exception as parse_err:
|
|
log.warning("refresh_meta_repo: %s/%s: skipping %s: %s",
|
|
project_id, collection_id, f["path"], parse_err)
|
|
continue
|
|
if not entry.slug:
|
|
continue
|
|
seen_slugs.add(entry.slug)
|
|
_upsert_cached_rfc(entry, body_sha=sha, collection_id=collection_id)
|
|
existing = {row["slug"] for row in db.conn().execute(
|
|
"SELECT slug FROM cached_rfcs WHERE collection_id = ?", (collection_id,))}
|
|
for missing in existing - seen_slugs:
|
|
log.info("refresh_meta_repo: %s/%s/%s no longer present — leaving cache row",
|
|
project_id, collection_id, missing)
|
|
```
|
|
|
|
> `refresh_meta_repo` itself (the project loop) is unchanged — it still calls
|
|
> `_refresh_project_corpus(org, id, content_repo, gitea)` per project.
|
|
|
|
- [ ] **Step 4: Run it, verify PASS.**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_collection_scoped_serve.py::test_mirror_keys_entries_by_collection -q`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Run the full backend suite** — the default-collection path must be unchanged.
|
|
|
|
Run: `cd backend && python -m pytest -q`
|
|
Expected: PASS (all green; N=1 default path intact).
|
|
|
|
- [ ] **Step 6: Commit.**
|
|
|
|
```bash
|
|
git add backend/app/cache.py backend/tests/test_collection_scoped_serve.py
|
|
git commit -m "§22 S2: corpus mirror reads each collection's <subfolder>/rfcs/"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Collection-scoped serve + propose endpoints (backend)
|
|
|
|
**Files:**
|
|
- Modify: `backend/app/api.py` — refactor propose to accept a collection; add scoped list/get/propose.
|
|
- Test: `backend/tests/test_collection_scoped_serve.py` (append)
|
|
|
|
The shipped `_propose_into_project` hardcodes `collection_id = default_collection_id(project_id)`
|
|
(api.py:973). Generalize it to accept an explicit collection, and write the entry into
|
|
that collection's `<subfolder>/rfcs/`.
|
|
|
|
- [ ] **Step 1: Write the failing test** (scoped list + propose targets the named collection).
|
|
|
|
```python
|
|
# Append to backend/tests/test_collection_scoped_serve.py
|
|
def test_scoped_list_returns_only_that_collection(client_with_corpus):
|
|
client, _ = client_with_corpus # fixture seeds 'default'+'features' w/ entries a,b
|
|
r = client.get("/api/projects/ohm/collections/features/rfcs")
|
|
assert r.status_code == 200
|
|
assert [i["slug"] for i in r.json()["items"]] == ["b"]
|
|
|
|
|
|
def test_scoped_propose_writes_into_collection_subfolder(client_with_corpus):
|
|
client, fake_bot = client_with_corpus
|
|
r = client.post("/api/projects/ohm/collections/features/rfcs/propose",
|
|
json={"title": "New", "slug": "newb", "pitch": "x", "tags": []})
|
|
assert r.status_code == 200
|
|
# The bot was asked to write under features/rfcs/, not rfcs/.
|
|
assert fake_bot.last_meta_path_prefix == "features/rfcs"
|
|
```
|
|
|
|
> Build `client_with_corpus` from the existing `app_with_fake_gitea` /
|
|
> propose-vertical fixture in `backend/tests/test_propose_vertical.py`; extend its
|
|
> fake bot to record the subfolder it was handed (see Step 3). If the propose-vertical
|
|
> fixture is structured differently, follow its shape and adapt these two asserts.
|
|
|
|
- [ ] **Step 2: Run it, verify it fails.**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_collection_scoped_serve.py -q`
|
|
Expected: FAIL (route 404 / propose ignores collection).
|
|
|
|
- [ ] **Step 3: Generalize `_propose_into_project` → `_propose_into_collection`** in api.py.
|
|
|
|
Change the signature and the two collection-dependent lines. The current helper
|
|
(api.py:959) resolves `collection_id` from the project default; instead take it as a
|
|
parameter and prefix the bot write path with the collection's subfolder:
|
|
|
|
```python
|
|
async def _propose_into_collection(
|
|
project_id: str, collection_id: str, payload: ProposeBody, user
|
|
) -> dict[str, Any]:
|
|
if not auth.can_contribute_in_project(user, project_id):
|
|
raise HTTPException(403, "You do not have contribute access to this project")
|
|
slug = payload.slug.strip().lower()
|
|
if not entry_mod.is_valid_slug(slug):
|
|
raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes")
|
|
clash = db.conn().execute(
|
|
"SELECT 1 FROM cached_rfcs WHERE slug = ? AND collection_id = ?", (slug, collection_id)
|
|
).fetchone()
|
|
if clash:
|
|
raise HTTPException(409, f"Slug `{slug}` is already taken")
|
|
# ...unchanged idea_clash / entry build...
|
|
landing_state = (
|
|
"active" if collections_mod.collection_initial_state(collection_id) == "active"
|
|
else "super-draft"
|
|
)
|
|
# ...build `entry`, `contents`, `pr_title`, `pr_description` unchanged...
|
|
subfolder = collections_mod.subfolder_of(collection_id)
|
|
rfcs_prefix = f"{subfolder}/rfcs" if subfolder else "rfcs"
|
|
pr = await bot.open_idea_pr(
|
|
user.as_actor(),
|
|
org=config.gitea_org,
|
|
meta_repo=(projects_mod.content_repo(project_id) or ""),
|
|
slug=slug,
|
|
file_contents=contents,
|
|
pr_title=pr_title,
|
|
pr_description=pr_description,
|
|
rfcs_dir=rfcs_prefix,
|
|
)
|
|
# ...refresh + use_case persistence: pass collection_id (it already does)...
|
|
```
|
|
|
|
> Keep `_propose_into_project(project_id, payload, user)` as a thin wrapper that
|
|
> resolves the default collection and calls `_propose_into_collection`, so the
|
|
> existing `/api/rfcs/propose` and `/api/projects/:id/rfcs/propose` routes are
|
|
> unchanged. `landing_state` now derives from the collection (replacing the
|
|
> `projects_mod.project_initial_state` line at api.py:991).
|
|
|
|
- [ ] **Step 4: Add `rfcs_dir` param to `bot.open_idea_pr`** (default `"rfcs"`), threading it
|
|
into the file path it writes. In `backend/app/bot.py`, `open_idea_pr` (≈line 168) builds the
|
|
entry file path as `f"rfcs/{slug}.md"`; change to accept `rfcs_dir: str = "rfcs"` and build
|
|
`f"{rfcs_dir}/{slug}.md"`. Leave every existing caller (which omits the arg) on `"rfcs"`.
|
|
|
|
- [ ] **Step 5: Add the scoped read + propose routes** in api.py (beside the existing project routes).
|
|
|
|
```python
|
|
@router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs")
|
|
async def list_collection_rfcs(project_id: str, collection_id: str, request: Request,
|
|
unreviewed: str | None = None) -> dict[str, Any]:
|
|
viewer = auth.current_user(request)
|
|
auth.require_project_readable(viewer, project_id)
|
|
_require_collection_in_project(collection_id, project_id)
|
|
return _list_rfcs_for_collection(collection_id, viewer, unreviewed)
|
|
|
|
@router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}")
|
|
async def get_collection_rfc(project_id: str, collection_id: str, slug: str,
|
|
request: Request) -> dict[str, Any]:
|
|
viewer = auth.current_user(request)
|
|
auth.require_project_readable(viewer, project_id)
|
|
_require_collection_in_project(collection_id, project_id)
|
|
return _get_rfc_for_collection(collection_id, slug, viewer)
|
|
|
|
@router.post("/api/projects/{project_id}/collections/{collection_id}/rfcs/propose")
|
|
async def propose_collection_rfc(project_id: str, collection_id: str,
|
|
payload: ProposeBody, request: Request) -> dict[str, Any]:
|
|
user = auth.require_contributor(request)
|
|
auth.require_project_readable(user, project_id)
|
|
_require_collection_in_project(collection_id, project_id)
|
|
return await _propose_into_collection(project_id, collection_id, payload, user)
|
|
```
|
|
|
|
Add the small helpers (refactor the bodies of the existing `list_project_rfcs` /
|
|
`get_project_rfc` at api.py:720-795 into `_list_rfcs_for_collection(collection_id, viewer, unreviewed)`
|
|
and `_get_rfc_for_collection(collection_id, slug, viewer)`, then have the project-scoped
|
|
routes call them with the default collection). Add:
|
|
|
|
```python
|
|
def _require_collection_in_project(collection_id: str, project_id: str) -> None:
|
|
if collections_mod.project_of_collection(collection_id) != project_id:
|
|
raise HTTPException(404, "Not found")
|
|
```
|
|
|
|
- [ ] **Step 6: Run the scoped-serve tests, verify PASS.**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_collection_scoped_serve.py -q`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 7: Run the full backend suite, verify PASS** (default-collection routes unchanged).
|
|
|
|
Run: `cd backend && python -m pytest -q`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 8: Commit.**
|
|
|
|
```bash
|
|
git add backend/app/api.py backend/app/bot.py backend/tests/test_collection_scoped_serve.py
|
|
git commit -m "§22 S2: collection-scoped list/get/propose endpoints"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: create-collection endpoint (bot commit + registry refresh)
|
|
|
|
**Files:**
|
|
- Create: `backend/app/api_collections.py`
|
|
- Modify: `backend/app/bot.py` (add `create_collection`), `backend/app/main.py` (mount router).
|
|
- Test: `backend/tests/test_collection_create_vertical.py` (append)
|
|
|
|
Flow: deployment admin POSTs `{collection_id, type, name?, visibility?, initial_state?}`;
|
|
the bot commits `<collection_id>/.collection.yaml` to the project's content-repo `main`;
|
|
then `refresh_registry` re-reads and upserts the collection row. The registry stays the
|
|
source of truth (§22.2) — the endpoint never writes the `collections` row directly.
|
|
|
|
- [ ] **Step 1: Write the failing vertical test.**
|
|
|
|
```python
|
|
# Append to backend/tests/test_collection_create_vertical.py
|
|
def test_create_collection_commits_manifest_and_mirrors(admin_client_with_fake_gitea):
|
|
client, fake = admin_client_with_fake_gitea # seeds project 'ohm' + default collection
|
|
r = client.post("/api/projects/ohm/collections",
|
|
json={"collection_id": "features", "type": "bdd", "name": "Features"})
|
|
assert r.status_code == 200, r.text
|
|
assert fake.committed_path == "features/.collection.yaml"
|
|
assert "type: bdd" in fake.committed_text
|
|
# registry refresh ran → row exists.
|
|
row = db.conn().execute("SELECT type, project_id FROM collections WHERE id='features'").fetchone()
|
|
assert (row["type"], row["project_id"]) == ("bdd", "ohm")
|
|
|
|
|
|
def test_create_collection_requires_admin(member_client):
|
|
r = member_client.post("/api/projects/ohm/collections",
|
|
json={"collection_id": "x", "type": "bdd"})
|
|
assert r.status_code in (401, 403)
|
|
|
|
|
|
def test_create_collection_rejects_duplicate_id(admin_client_with_fake_gitea):
|
|
client, _ = admin_client_with_fake_gitea
|
|
client.post("/api/projects/ohm/collections", json={"collection_id": "features", "type": "bdd"})
|
|
r = client.post("/api/projects/ohm/collections", json={"collection_id": "features", "type": "bdd"})
|
|
assert r.status_code == 409
|
|
```
|
|
|
|
> Reuse the `app_with_fake_gitea` admin fixture from `test_propose_vertical.py`;
|
|
> extend its fake bot/Gitea to record `committed_path` / `committed_text` for a
|
|
> root-level file create, and to make `refresh_registry`'s `list_dir` + `get_contents`
|
|
> see the just-committed manifest (an in-memory tree the fake mutates on commit).
|
|
|
|
- [ ] **Step 2: Run it, verify it fails.**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_collection_create_vertical.py -k create_collection -q`
|
|
Expected: FAIL (route 404).
|
|
|
|
- [ ] **Step 3: Add `Bot.create_collection`** in `bot.py` (commit the manifest to main).
|
|
|
|
```python
|
|
async def create_collection(self, actor, *, org: str, content_repo: str,
|
|
collection_id: str, manifest_yaml: str) -> None:
|
|
"""Commit `<collection_id>/.collection.yaml` to the content repo's main.
|
|
A structural admin action — commits straight to main (no PR), like
|
|
registry config. Logs an action row for the audit trail."""
|
|
path = f"{collection_id}/.collection.yaml"
|
|
await self._gitea.create_file(
|
|
org, content_repo, path, manifest_yaml,
|
|
message=self._stamp(actor, f"chore: create collection {collection_id}"),
|
|
branch="main",
|
|
)
|
|
self._log(actor, action="create_collection",
|
|
details={"collection_id": collection_id, "repo": content_repo})
|
|
```
|
|
|
|
> Match the exact `create_file` signature + `_stamp`/`_log` call shapes used by the
|
|
> neighbouring bot methods (e.g. `commit_accepted_change`); names above are
|
|
> illustrative of the pattern, not necessarily the literal argument names.
|
|
|
|
- [ ] **Step 4: Create `backend/app/api_collections.py`** with the read + create routes.
|
|
|
|
```python
|
|
"""§22 S2 — collection directory + create-collection.
|
|
|
|
GET /api/projects/:id/collections — list visible collections.
|
|
GET /api/projects/:id/collections/:cid — one collection's settings.
|
|
POST /api/projects/:id/collections — create a collection (admin; bot commits
|
|
a `.collection.yaml`, then the registry
|
|
mirror upserts the row — §22.2).
|
|
"""
|
|
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, bot, cache, collections as collections_mod, db, gitea as gitea_mod
|
|
from . import projects as projects_mod, registry as registry_mod
|
|
from .config import Config
|
|
from .gitea import 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_mod.Gitea) -> 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)
|
|
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]:
|
|
user = auth.require_admin(request) # deployment owner/admin (S2; scoped roles in S3)
|
|
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 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: the registry mirror is the source of truth — re-read so the new
|
|
# manifest becomes a collections 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
|
|
```
|
|
|
|
> `bot.create_collection` is module-level if the codebase exposes `bot.<verb>`
|
|
> module functions (as the propose path uses `bot.open_idea_pr`); if `Bot` is a
|
|
> class instance, call it the same way the propose route does. Match the existing
|
|
> convention exactly.
|
|
|
|
- [ ] **Step 5: Mount the router** in `backend/app/main.py` next to the other `make_router` mounts.
|
|
|
|
```python
|
|
from . import api_collections
|
|
app.include_router(api_collections.make_router(config, gitea))
|
|
```
|
|
|
|
- [ ] **Step 6: Run the create-collection tests, verify PASS.**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_collection_create_vertical.py -q`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 7: Run the full backend suite, verify PASS.**
|
|
|
|
Run: `cd backend && python -m pytest -q`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 8: Commit.**
|
|
|
|
```bash
|
|
git add backend/app/api_collections.py backend/app/bot.py backend/app/main.py backend/tests/test_collection_create_vertical.py
|
|
git commit -m "§22 S2: create-collection endpoint (bot commit + registry refresh)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Frontend — collection-scoped path + API helpers
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/lib/entryPaths.js`, `frontend/src/api.js`
|
|
- Test: `frontend/src/lib/entryPaths.test.js` (extend)
|
|
|
|
- [ ] **Step 1: Write the failing test** for `useCollectionId` fallback + scoped API URLs.
|
|
|
|
```js
|
|
// Append to frontend/src/lib/entryPaths.test.js
|
|
import { describe, it, expect } from 'vitest'
|
|
import { DEFAULT_COLLECTION } from './entryPaths'
|
|
|
|
describe('collection paths', () => {
|
|
it('entryPath honors an explicit collection', () => {
|
|
// entryPath(pid, slug, cid)
|
|
const { entryPath } = require('./entryPaths')
|
|
expect(entryPath('ohm', 'a', 'features')).toBe('/p/ohm/c/features/e/a')
|
|
expect(entryPath('ohm', 'a')).toBe(`/p/ohm/c/${DEFAULT_COLLECTION}/e/a`)
|
|
})
|
|
})
|
|
```
|
|
|
|
> Match the file's existing import/runner style (it may use ESM `import` rather
|
|
> than `require`); follow whatever `entryPaths.test.js` already does.
|
|
|
|
- [ ] **Step 2: Run it, verify PASS or FAIL** (entryPath already supports cid → this asserts the
|
|
contract; the new piece is `useCollectionId`). If green, proceed; the behavioral gap is the hook.
|
|
|
|
Run: `cd frontend && npx vitest run src/lib/entryPaths.test.js`
|
|
|
|
- [ ] **Step 3: Add `useCollectionId`** to `entryPaths.js`.
|
|
|
|
```js
|
|
import { useParams } from 'react-router-dom'
|
|
|
|
// The collection id a component should scope to: the `/c/:collectionId/` segment
|
|
// when present, else the project's default collection.
|
|
export function useCollectionId() {
|
|
const { collectionId } = useParams()
|
|
return collectionId || DEFAULT_COLLECTION
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Make `api.js` collection-aware.** Add optional `collectionId` to the corpus reads
|
|
and propose; add `listCollections` + `createCollection`.
|
|
|
|
```js
|
|
export async function listRFCs(projectId, collectionId) {
|
|
if (projectId && collectionId) {
|
|
return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}/rfcs`))
|
|
}
|
|
const url = projectId ? `/api/projects/${projectId}/rfcs` : '/api/rfcs'
|
|
return jsonOrThrow(await fetch(url))
|
|
}
|
|
|
|
export async function getRFC(projectId, slug, collectionId) {
|
|
if (collectionId) {
|
|
return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}/rfcs/${slug}`))
|
|
}
|
|
if (slug === undefined) return jsonOrThrow(await fetch(`/api/rfcs/${projectId}`))
|
|
return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`))
|
|
}
|
|
|
|
export async function listCollections(projectId) {
|
|
return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections`))
|
|
}
|
|
|
|
export async function createCollection(projectId, { collectionId, type, name, visibility, initialState }) {
|
|
const res = await fetch(`/api/projects/${projectId}/collections`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ collection_id: collectionId, type, name: name || null,
|
|
visibility: visibility || null, initial_state: initialState || null }),
|
|
})
|
|
return jsonOrThrow(res)
|
|
}
|
|
```
|
|
|
|
And add an optional `collectionId` to `proposeRFC` so it targets the scoped route:
|
|
|
|
```js
|
|
export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase, collectionId }) {
|
|
const url = (projectId && collectionId)
|
|
? `/api/projects/${projectId}/collections/${collectionId}/rfcs/propose`
|
|
: (projectId ? `/api/projects/${projectId}/rfcs/propose` : '/api/rfcs/propose')
|
|
// ...unchanged body/post...
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run the frontend unit tests, verify PASS.**
|
|
|
|
Run: `cd frontend && npx vitest run src/lib/entryPaths.test.js`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit.**
|
|
|
|
```bash
|
|
git add frontend/src/lib/entryPaths.js frontend/src/api.js frontend/src/lib/entryPaths.test.js
|
|
git commit -m "§22 S2: collection-scoped frontend path + API helpers"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Frontend — Catalog reads the active collection
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/components/Catalog.jsx`, `frontend/src/App.jsx` (propose wiring)
|
|
|
|
- [ ] **Step 1: Scope the Catalog to the active collection.** In `Catalog.jsx`:
|
|
- import `useCollectionId` from `../lib/entryPaths`;
|
|
- `const cid = useCollectionId()`;
|
|
- fetch `listRFCs(pid, cid)` and add `cid` to the effect deps;
|
|
- build entry links with `entryPath(pid, r.slug, cid)` and `proposalPath(pid, p.pr_number, cid)`.
|
|
|
|
The anonymous empty-state (C3.6) is already correct — the footer renders the
|
|
"Sign in to propose" link for `!viewer`, and the empty list shows "No RFCs in the
|
|
catalog yet." Confirm both render for an anonymous viewer on an empty collection.
|
|
|
|
- [ ] **Step 2: Thread the active collection into the propose modal** in `App.jsx`. The
|
|
`ProposeModal` is mounted with `projectId={currentProjectId}`; also pass the active
|
|
collection (derive from the route — read `useParams().collectionId` in the `AppShell`
|
|
scope, default `DEFAULT_COLLECTION`) and pass it to `proposeRFC` so a propose from a
|
|
named collection targets that collection. On submit, navigate with `proposalPath(currentProjectId, pr_number, currentCollectionId)`.
|
|
|
|
- [ ] **Step 3: Build + run the frontend test suite, verify PASS.**
|
|
|
|
Run: `cd frontend && npx vitest run`
|
|
Expected: PASS (existing tests green; default-collection behavior unchanged).
|
|
|
|
- [ ] **Step 4: Commit.**
|
|
|
|
```bash
|
|
git add frontend/src/components/Catalog.jsx frontend/src/App.jsx
|
|
git commit -m "§22 S2: Catalog + propose scoped to the active collection"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Frontend — the collection directory at `/p/<project>/`
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/components/CollectionDirectory.jsx`
|
|
- Modify: `frontend/src/App.jsx` (swap `DefaultCollectionRedirect` → `CollectionDirectory`)
|
|
- Test: `frontend/src/components/CollectionDirectory.test.jsx`
|
|
|
|
- [ ] **Step 1: Write the failing test.**
|
|
|
|
```jsx
|
|
// frontend/src/components/CollectionDirectory.test.jsx
|
|
import { render, screen, waitFor } from '@testing-library/react'
|
|
import { MemoryRouter } from 'react-router-dom'
|
|
import { describe, it, expect, vi } from 'vitest'
|
|
import CollectionDirectory from './CollectionDirectory'
|
|
|
|
vi.mock('../api', () => ({
|
|
listCollections: vi.fn(async () => ({ items: [
|
|
{ id: 'default', name: 'Model', type: 'document' },
|
|
{ id: 'features', name: 'Features', type: 'bdd' },
|
|
] })),
|
|
}))
|
|
|
|
describe('CollectionDirectory', () => {
|
|
it('lists collections when there are 2+', async () => {
|
|
render(<MemoryRouter initialEntries={["/p/ohm/"]}><CollectionDirectory projectId="ohm" /></MemoryRouter>)
|
|
await waitFor(() => expect(screen.getByText('Features')).toBeInTheDocument())
|
|
expect(screen.getByText('Model')).toBeInTheDocument()
|
|
})
|
|
})
|
|
```
|
|
|
|
> Match the project's component-test conventions (see `Directory.test.jsx` /
|
|
> `ProjectLayout.test.jsx` for the render + mock pattern; adapt the mock + queries
|
|
> to whatever they use).
|
|
|
|
- [ ] **Step 2: Run it, verify it fails** (component missing).
|
|
|
|
Run: `cd frontend && npx vitest run src/components/CollectionDirectory.test.jsx`
|
|
Expected: FAIL.
|
|
|
|
- [ ] **Step 3: Implement `CollectionDirectory.jsx`.** Fetch `listCollections(projectId)`; when
|
|
exactly one visible collection, `<Navigate>` to its `collectionHome` (preserves the S1
|
|
C3.7/C3.8 single-collection redirect); when 2+, render a list of links to each
|
|
`collectionHome(projectId, c.id)` with its name + type. (The "Create your first
|
|
collection" empty-state is S4 — for S2 a 0-collection project simply shows a minimal
|
|
"No collections yet." line; the create affordance UI lands in S4.)
|
|
|
|
```jsx
|
|
import { useEffect, useState } from 'react'
|
|
import { Link, Navigate } from 'react-router-dom'
|
|
import { listCollections } from '../api'
|
|
import { collectionHome } from '../lib/entryPaths'
|
|
|
|
export default function CollectionDirectory({ projectId }) {
|
|
const [cols, setCols] = useState(null)
|
|
useEffect(() => {
|
|
let live = true
|
|
listCollections(projectId).then(d => { if (live) setCols(d.items) }).catch(() => live && setCols([]))
|
|
return () => { live = false }
|
|
}, [projectId])
|
|
if (cols === null) return <main className="chrome-pane"><div className="boot">Loading…</div></main>
|
|
if (cols.length === 1) return <Navigate to={collectionHome(projectId, cols[0].id)} replace />
|
|
return (
|
|
<main className="chrome-pane">
|
|
<div className="collection-directory">
|
|
<h1>Collections</h1>
|
|
{cols.length === 0 ? (
|
|
<p>No collections yet.</p>
|
|
) : (
|
|
<ul>
|
|
{cols.map(c => (
|
|
<li key={c.id}>
|
|
<Link to={collectionHome(projectId, c.id)}>{c.name || c.id}</Link>
|
|
<span className="collection-type"> · {c.type}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Wire it into `App.jsx`.** Replace the `DefaultCollectionRedirect` route element
|
|
at the project landing (App.jsx:365) with `<CollectionDirectory projectId={<the route projectId>} />`.
|
|
Read the project id from `useParams()` inside a small wrapper (mirroring how
|
|
`DefaultCollectionRedirect` reads it), and keep `DefaultCollectionRedirect` only if still
|
|
referenced elsewhere (otherwise delete it). The S1 single-collection redirect now lives
|
|
inside `CollectionDirectory`, so the C3.7/C3.8 behavior is preserved.
|
|
|
|
- [ ] **Step 5: Run the directory test + full frontend suite, verify PASS.**
|
|
|
|
Run: `cd frontend && npx vitest run`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit.**
|
|
|
|
```bash
|
|
git add frontend/src/components/CollectionDirectory.jsx frontend/src/components/CollectionDirectory.test.jsx frontend/src/App.jsx
|
|
git commit -m "§22 S2: collection directory at /p/<project>/ (1 → redirect, 2+ → list)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Acceptance — C3.6 anonymous empty-collection catalog
|
|
|
|
**Files:**
|
|
- Test: `backend/tests/test_collection_scoped_serve.py` (append the `@S2` acceptance assertion)
|
|
|
|
The `@S2` gate is C3.6: *a public collection with no entries; an anonymous visitor
|
|
lands on `/p/ohm/c/model/`; they see an empty catalog with no propose action and a
|
|
sign-in prompt.* Backend half: the scoped list returns `{items: []}` for an anonymous
|
|
viewer on a public empty collection (no 404, no propose surfaced). Frontend half: the
|
|
Catalog footer renders "Sign in to propose" for `!viewer` (already covered by Task 7).
|
|
|
|
- [ ] **Step 1: Write the acceptance test** (backend contract for the empty public collection).
|
|
|
|
```python
|
|
def test_s2_anonymous_empty_public_collection(client_with_corpus):
|
|
"""C3.6 (@S2): anonymous viewer, public empty collection → empty catalog, 200."""
|
|
client, _ = client_with_corpus # ensure an empty public collection 'model'
|
|
r = client.get("/api/projects/ohm/collections/model/rfcs") # no auth header
|
|
assert r.status_code == 200
|
|
assert r.json()["items"] == []
|
|
```
|
|
|
|
> If `client_with_corpus` doesn't already seed an empty public `model` collection,
|
|
> add one (mirror Task 3's seed). The propose-absence is enforced by `require_contributor`
|
|
> on the propose route (anonymous → 401/403) and the Catalog UI footer.
|
|
|
|
- [ ] **Step 2: Run it, verify PASS.**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_collection_scoped_serve.py::test_s2_anonymous_empty_public_collection -q`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Manually verify the propose route rejects anonymous** (sanity — no new code):
|
|
|
|
Run: `cd backend && python -m pytest tests/test_collection_scoped_serve.py -q`
|
|
Expected: PASS (all).
|
|
|
|
- [ ] **Step 4: Commit.**
|
|
|
|
```bash
|
|
git add backend/tests/test_collection_scoped_serve.py
|
|
git commit -m "§22 S2: @S2 acceptance — anonymous empty public collection catalog"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Release — version bump, changelog, docs
|
|
|
|
**Files:**
|
|
- Modify: `VERSION`, `frontend/package.json`, `CHANGELOG.md`
|
|
- Modify: `docs/design/2026-06-05-three-tier-projects-collections.md` (mark S2 shipped)
|
|
|
|
S2 adds functionality and is non-breaking (new optional path segment + new endpoints;
|
|
the default-collection paths are unchanged). Per SPEC §20, that's a **minor** bump:
|
|
`0.40.0` → `0.41.0`.
|
|
|
|
- [ ] **Step 1: Bump `VERSION`** to `0.41.0`.
|
|
|
|
- [ ] **Step 2: Bump `frontend/package.json#version`** to `0.41.0` (must mirror VERSION — §20).
|
|
|
|
- [ ] **Step 3: Add the `CHANGELOG.md` entry** under a new `## 0.41.0` heading: a minor
|
|
release shipping S2 (create + navigate + propose a second collection): registry mirror
|
|
reads `.collection.yaml`; collection-grained corpus mirror; create-collection endpoint;
|
|
collection-scoped list/get/propose; `/p/<project>/` collection directory. Note it
|
|
completes `@S2` (C3.6). No upgrade steps required (additive; existing default-collection
|
|
deployments keep working unchanged) — state that explicitly.
|
|
|
|
- [ ] **Step 4: Mark S2 shipped** in the design doc's Part E slice list (a short
|
|
"Landed vX" note on the S2 bullet, mirroring how S1 was annotated).
|
|
|
|
- [ ] **Step 5: Run both suites once more, verify green.**
|
|
|
|
Run: `cd backend && python -m pytest -q && cd ../frontend && npx vitest run`
|
|
Expected: PASS (both).
|
|
|
|
- [ ] **Step 6: Commit.**
|
|
|
|
```bash
|
|
git add VERSION frontend/package.json CHANGELOG.md docs/design/2026-06-05-three-tier-projects-collections.md
|
|
git commit -m "§22 S2: release v0.41.0 — create & navigate a second collection (@S2)"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-review notes
|
|
|
|
- **Spec coverage (Part E "S2" bullet):** registry reads `.collection.yaml` (Task 1) ·
|
|
create-collection endpoint, admin-gated (Task 5) · project collection-directory at
|
|
`/p/<project>/` (Task 8) · collection-scoped propose/serve (Tasks 3,4,7) · `@S2` / C3.6
|
|
acceptance (Task 9). ✓
|
|
- **N=1 unchanged invariant:** every backend task ends by running the full suite; the
|
|
default-collection routes are preserved as thin wrappers over the new collection-scoped
|
|
internals. ✓
|
|
- **Type consistency:** `collection_id`/`subfolder` naming is uniform; the entries path is
|
|
`<subfolder>/rfcs/` everywhere (cache mirror Task 3, propose Task 4, create manifest Task 5).
|
|
- **Deferred / lower-confidence calls (log to transcript):** (a) create-collection commits
|
|
straight to `main` rather than via a PR — chosen because a collection is structural config
|
|
and the registry mirror is the source of truth; (b) S2 collection visibility filtering is
|
|
coarse (project read-gate + drop `unlisted`); full scope-role enforcement is S3; (c) the
|
|
per-collection "create"/"propose-first" *empty-state affordances* are S4 — S2 ships only the
|
|
anonymous empty catalog (C3.6) and a minimal directory.
|