From f57d4080dcaf192b37d064b0cbde696d313f6748 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 5 Jun 2026 13:05:58 -0700 Subject: [PATCH] =?UTF-8?q?=C2=A722=20S2:=20collection-scoped=20list/get/p?= =?UTF-8?q?ropose=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor the project-scoped serve/propose internals into collection-grained helpers (_list_rfcs_for_collection, _get_rfc_for_collection, _propose_into_collection); add routes under /api/projects//collections//rfcs[/|/propose]. Propose writes the entry under the target collection's /rfcs via a new rfcs_dir param on bot.open_idea_pr. Default-collection routes preserved as wrappers. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/api.py | 97 +++++++++++++++---- backend/app/bot.py | 10 +- backend/tests/test_collection_scoped_serve.py | 64 ++++++++++++ 3 files changed, 149 insertions(+), 22 deletions(-) diff --git a/backend/app/api.py b/backend/app/api.py index 0d5cd2c..b81a5a5 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -717,15 +717,15 @@ def make_router( # second project's corpus renders under /p//. # --------------------------------------------------------------- - @router.get("/api/projects/{project_id}/rfcs") - async def list_project_rfcs( - project_id: str, request: Request, unreviewed: str | None = None + def _require_collection_in_project(collection_id: str, project_id: str) -> None: + # §22 S2: a collection-scoped route 404s when the collection does not + # belong to the project in the path (shape matches an unknown id). + if collections_mod.project_of_collection(collection_id) != project_id: + raise HTTPException(404, "Not found") + + def _list_rfcs_for_collection( + collection_id: str, viewer, unreviewed: str | None ) -> dict[str, Any]: - viewer = auth.current_user(request) - # §22.5 read gate: a gated project's catalog 404s to a non-member. - auth.require_project_readable(viewer, project_id) - # §22 S1: serve the project's default collection (the corpus grain). - collection_id = collections_mod.default_collection_id(project_id) viewer_id = viewer.user_id if viewer else None unreviewed_clause = "" if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"): @@ -769,11 +769,7 @@ def make_router( ] return {"items": items} - @router.get("/api/projects/{project_id}/rfcs/{slug}") - async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]: - viewer = auth.current_user(request) - auth.require_project_readable(viewer, project_id) - collection_id = collections_mod.default_collection_id(project_id) + def _get_rfc_for_collection(collection_id: str, slug: str, viewer) -> dict[str, Any]: row = db.conn().execute( "SELECT * FROM cached_rfcs WHERE collection_id = ? AND slug = ?", (collection_id, slug), @@ -794,6 +790,46 @@ def make_router( payload["proposed_use_case"] = uc["use_case"] if uc else None return payload + @router.get("/api/projects/{project_id}/rfcs") + async def list_project_rfcs( + project_id: str, request: Request, unreviewed: str | None = None + ) -> dict[str, Any]: + viewer = auth.current_user(request) + # §22.5 read gate: a gated project's catalog 404s to a non-member. + auth.require_project_readable(viewer, project_id) + # §22 S1: the project-scoped route serves the default collection. + collection_id = collections_mod.default_collection_id(project_id) + return _list_rfcs_for_collection(collection_id, viewer, unreviewed) + + @router.get("/api/projects/{project_id}/rfcs/{slug}") + async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]: + viewer = auth.current_user(request) + auth.require_project_readable(viewer, project_id) + collection_id = collections_mod.default_collection_id(project_id) + return _get_rfc_for_collection(collection_id, slug, viewer) + + # §22 S2: collection-scoped serve + propose. The catalog/entry views read + # these under /p//c//; the project-scoped routes above + # stay as the default-collection compat surface. + @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) + # --------------------------------------------------------------- # §22.4c: mark-reviewed — clear an active entry's `unreviewed` flag # --------------------------------------------------------------- @@ -957,6 +993,15 @@ def make_router( # --------------------------------------------------------------- async def _propose_into_project(project_id: str, payload: ProposeBody, user) -> dict[str, Any]: + # Default-collection wrapper (§22 S1/S2): resolve the project's default + # collection and delegate. Keeps the project-scoped propose routes intact. + return await _propose_into_collection( + project_id, collections_mod.default_collection_id(project_id), payload, user + ) + + async def _propose_into_collection( + project_id: str, collection_id: str, payload: ProposeBody, user + ) -> dict[str, Any]: # §22.6/§22.7: proposing a new entry requires project-level contribute # standing in the *target* project. On the public default project the # implicit-public baseline preserves the pre-multi-project flow. @@ -970,7 +1015,6 @@ def make_router( # We re-check atomically here even though the client also checks # on every keystroke, since a concurrent submission could land # between dialog-open and submit. - collection_id = collections_mod.default_collection_id(project_id) clash = db.conn().execute( "SELECT 1 FROM cached_rfcs WHERE slug = ? AND collection_id = ?", (slug, collection_id) ).fetchone() @@ -984,11 +1028,12 @@ def make_router( if idea_clash: raise HTTPException(409, f"Slug `{slug}` is already reserved by an open proposal") - # §22.4b: the target project's landing state. Through Plan A every - # entry lands in the default project; M3-frontend routing carries a - # non-default target later. - target_project = project_id - landing_state = "active" if projects_mod.project_initial_state(target_project) == "active" else "super-draft" + # §22.4b: the target collection's landing state (the per-corpus field + # moved down to the collection in migration 029). + landing_state = ( + "active" if collections_mod.collection_initial_state(collection_id) == "active" + else "super-draft" + ) entry = entry_mod.Entry( slug=slug, @@ -1019,6 +1064,9 @@ def make_router( f"**Topic:** {entry.title}\n\n" f"{payload.pitch.strip()}" ) + # §22 S2: write the entry under the target collection's /rfcs. + subfolder = collections_mod.subfolder_of(collection_id) + rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs" try: pr = await bot.open_idea_pr( user.as_actor(), @@ -1028,6 +1076,7 @@ def make_router( file_contents=contents, pr_title=pr_title, pr_description=pr_description, + rfcs_dir=rfcs_dir, ) except GiteaError as e: raise HTTPException(502, f"Gitea: {e.detail}") @@ -1076,6 +1125,16 @@ def make_router( auth.require_project_readable(user, project_id) return await _propose_into_project(project_id, payload, user) + @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]: + # §22 S2: propose a new entry into a specific collection of a project. + 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) + # --------------------------------------------------------------- # §9.1 Slice 2 (roadmap #27): Claude Haiku tag suggestions as the # propose-RFC fields fill in. The modal debounce-posts the partial diff --git a/backend/app/bot.py b/backend/app/bot.py index f6ea9c3..ce8fbd8 100644 --- a/backend/app/bot.py +++ b/backend/app/bot.py @@ -175,12 +175,16 @@ class Bot: file_contents: str, pr_title: str, pr_description: str, + rfcs_dir: str = "rfcs", ) -> dict: - """Per §9.1: open a meta-repo PR adding one file under rfcs/. + """Per §9.1: open a meta-repo PR adding one file under `/`. One file per PR keeps idea submissions atomic and conflict-free. The PR title and the file-add commit subject share §9.2's fixed - pattern; callers compose `pr_title` as `Propose: `. + pattern; callers compose `pr_title` as `Propose: <Title>`. §22 S2: + `rfcs_dir` carries the target collection's `<subfolder>/rfcs` so a + propose into a named collection writes under its subfolder; it + defaults to `rfcs` (the default collection / shipped behaviour). """ branch = f"propose/{slug}" await self._gitea.create_branch(org, meta_repo, branch, from_branch="main") @@ -189,7 +193,7 @@ class Bot: created = await self._gitea.create_file( org, meta_repo, - f"rfcs/{slug}.md", + f"{rfcs_dir}/{slug}.md", content=file_contents, message=commit_message, branch=branch, diff --git a/backend/tests/test_collection_scoped_serve.py b/backend/tests/test_collection_scoped_serve.py index f3d1c27..cb1ab43 100644 --- a/backend/tests/test_collection_scoped_serve.py +++ b/backend/tests/test_collection_scoped_serve.py @@ -75,3 +75,67 @@ def test_mirror_keys_entries_by_collection(): 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")} + + +# --- collection-scoped serve + propose (full app) ----------------------------- + +from fastapi.testclient import TestClient # noqa: E402 +from test_propose_vertical import ( # noqa: E402,F401 + app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as, +) + + +def _add_features_collection(content_repo="meta"): + """Add a named 'features' collection (subfolder 'features') under the seeded + default project, plus a single entry under features/rfcs/ in the db cache.""" + db.conn().execute( + "INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, " + "initial_state, visibility, name, created_at, updated_at) VALUES " + "('features','default','document','features','super-draft','public','Features', " + "datetime('now'), datetime('now'))") + + +def test_scoped_list_returns_only_that_collection(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _add_features_collection() + # Seed one entry under each collection's rfcs dir + mirror them in. + fake.files[("wiggleverse", "meta", "main", "rfcs/a.md")] = { + "content": _entry_md("a", "Default A"), "sha": "sa"} + fake.files[("wiggleverse", "meta", "main", "features/rfcs/b.md")] = { + "content": _entry_md("b", "Feature B"), "sha": "sb"} + from app import cache as cache_mod, gitea as gitea_mod + from app.config import load_config + cfg = load_config() + asyncio.run(cache_mod.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg))) + + r = client.get("/api/projects/default/collections/features/rfcs") + assert r.status_code == 200, r.text + assert [i["slug"] for i in r.json()["items"]] == ["b"] + # The default collection still serves only its own entry. + r2 = client.get("/api/projects/default/collections/default/rfcs") + assert [i["slug"] for i in r2.json()["items"]] == ["a"] + + +def test_scoped_propose_writes_into_collection_subfolder(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _add_features_collection() + provision_user_row(user_id=3, login="alice", role="contributor") + sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice", + role="contributor", email="alice@test") + r = client.post( + "/api/projects/default/collections/features/rfcs/propose", + json={"title": "New B", "slug": "newb", "pitch": "x", "tags": []}) + assert r.status_code == 200, r.text + # The bot wrote the entry under features/rfcs/, not rfcs/. + keys = {(k[1], k[3]) for k in fake.files + if k[1] == "meta" and k[3].endswith("newb.md")} + assert ("meta", "features/rfcs/newb.md") in keys + + +def test_scoped_routes_404_for_collection_outside_project(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + r = client.get("/api/projects/default/collections/nope/rfcs") + assert r.status_code == 404