§22 S2: collection-scoped list/get/propose endpoints
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/<id>/collections/<cid>/rfcs[/<slug>|/propose]. Propose writes the entry under the target collection's <subfolder>/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) <noreply@anthropic.com>
This commit is contained in:
+78
-19
@@ -717,15 +717,15 @@ def make_router(
|
|||||||
# second project's corpus renders under /p/<id>/.
|
# second project's corpus renders under /p/<id>/.
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/api/projects/{project_id}/rfcs")
|
def _require_collection_in_project(collection_id: str, project_id: str) -> None:
|
||||||
async def list_project_rfcs(
|
# §22 S2: a collection-scoped route 404s when the collection does not
|
||||||
project_id: str, request: Request, unreviewed: str | None = None
|
# 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]:
|
) -> 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
|
viewer_id = viewer.user_id if viewer else None
|
||||||
unreviewed_clause = ""
|
unreviewed_clause = ""
|
||||||
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
|
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
|
||||||
@@ -769,11 +769,7 @@ def make_router(
|
|||||||
]
|
]
|
||||||
return {"items": items}
|
return {"items": items}
|
||||||
|
|
||||||
@router.get("/api/projects/{project_id}/rfcs/{slug}")
|
def _get_rfc_for_collection(collection_id: str, slug: str, viewer) -> dict[str, Any]:
|
||||||
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)
|
|
||||||
row = db.conn().execute(
|
row = db.conn().execute(
|
||||||
"SELECT * FROM cached_rfcs WHERE collection_id = ? AND slug = ?",
|
"SELECT * FROM cached_rfcs WHERE collection_id = ? AND slug = ?",
|
||||||
(collection_id, slug),
|
(collection_id, slug),
|
||||||
@@ -794,6 +790,46 @@ def make_router(
|
|||||||
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
||||||
return payload
|
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/<project>/c/<collection>/; 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
|
# §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]:
|
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
|
# §22.6/§22.7: proposing a new entry requires project-level contribute
|
||||||
# standing in the *target* project. On the public default project the
|
# standing in the *target* project. On the public default project the
|
||||||
# implicit-public baseline preserves the pre-multi-project flow.
|
# 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
|
# We re-check atomically here even though the client also checks
|
||||||
# on every keystroke, since a concurrent submission could land
|
# on every keystroke, since a concurrent submission could land
|
||||||
# between dialog-open and submit.
|
# between dialog-open and submit.
|
||||||
collection_id = collections_mod.default_collection_id(project_id)
|
|
||||||
clash = db.conn().execute(
|
clash = db.conn().execute(
|
||||||
"SELECT 1 FROM cached_rfcs WHERE slug = ? AND collection_id = ?", (slug, collection_id)
|
"SELECT 1 FROM cached_rfcs WHERE slug = ? AND collection_id = ?", (slug, collection_id)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
@@ -984,11 +1028,12 @@ def make_router(
|
|||||||
if idea_clash:
|
if idea_clash:
|
||||||
raise HTTPException(409, f"Slug `{slug}` is already reserved by an open proposal")
|
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
|
# §22.4b: the target collection's landing state (the per-corpus field
|
||||||
# entry lands in the default project; M3-frontend routing carries a
|
# moved down to the collection in migration 029).
|
||||||
# non-default target later.
|
landing_state = (
|
||||||
target_project = project_id
|
"active" if collections_mod.collection_initial_state(collection_id) == "active"
|
||||||
landing_state = "active" if projects_mod.project_initial_state(target_project) == "active" else "super-draft"
|
else "super-draft"
|
||||||
|
)
|
||||||
|
|
||||||
entry = entry_mod.Entry(
|
entry = entry_mod.Entry(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
@@ -1019,6 +1064,9 @@ def make_router(
|
|||||||
f"**Topic:** {entry.title}\n\n"
|
f"**Topic:** {entry.title}\n\n"
|
||||||
f"{payload.pitch.strip()}"
|
f"{payload.pitch.strip()}"
|
||||||
)
|
)
|
||||||
|
# §22 S2: write the entry under the target collection's <subfolder>/rfcs.
|
||||||
|
subfolder = collections_mod.subfolder_of(collection_id)
|
||||||
|
rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs"
|
||||||
try:
|
try:
|
||||||
pr = await bot.open_idea_pr(
|
pr = await bot.open_idea_pr(
|
||||||
user.as_actor(),
|
user.as_actor(),
|
||||||
@@ -1028,6 +1076,7 @@ def make_router(
|
|||||||
file_contents=contents,
|
file_contents=contents,
|
||||||
pr_title=pr_title,
|
pr_title=pr_title,
|
||||||
pr_description=pr_description,
|
pr_description=pr_description,
|
||||||
|
rfcs_dir=rfcs_dir,
|
||||||
)
|
)
|
||||||
except GiteaError as e:
|
except GiteaError as e:
|
||||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||||
@@ -1076,6 +1125,16 @@ def make_router(
|
|||||||
auth.require_project_readable(user, project_id)
|
auth.require_project_readable(user, project_id)
|
||||||
return await _propose_into_project(project_id, payload, user)
|
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
|
# §9.1 Slice 2 (roadmap #27): Claude Haiku tag suggestions as the
|
||||||
# propose-RFC fields fill in. The modal debounce-posts the partial
|
# propose-RFC fields fill in. The modal debounce-posts the partial
|
||||||
|
|||||||
+7
-3
@@ -175,12 +175,16 @@ class Bot:
|
|||||||
file_contents: str,
|
file_contents: str,
|
||||||
pr_title: str,
|
pr_title: str,
|
||||||
pr_description: str,
|
pr_description: str,
|
||||||
|
rfcs_dir: str = "rfcs",
|
||||||
) -> dict:
|
) -> 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 `<rfcs_dir>/`.
|
||||||
|
|
||||||
One file per PR keeps idea submissions atomic and conflict-free.
|
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
|
The PR title and the file-add commit subject share §9.2's fixed
|
||||||
pattern; callers compose `pr_title` as `Propose: <Title>`.
|
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}"
|
branch = f"propose/{slug}"
|
||||||
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
||||||
@@ -189,7 +193,7 @@ class Bot:
|
|||||||
created = await self._gitea.create_file(
|
created = await self._gitea.create_file(
|
||||||
org,
|
org,
|
||||||
meta_repo,
|
meta_repo,
|
||||||
f"rfcs/{slug}.md",
|
f"{rfcs_dir}/{slug}.md",
|
||||||
content=file_contents,
|
content=file_contents,
|
||||||
message=commit_message,
|
message=commit_message,
|
||||||
branch=branch,
|
branch=branch,
|
||||||
|
|||||||
@@ -75,3 +75,67 @@ def test_mirror_keys_entries_by_collection():
|
|||||||
got = {(r["collection_id"], r["slug"]) for r in
|
got = {(r["collection_id"], r["slug"]) for r in
|
||||||
db.conn().execute("SELECT collection_id, slug FROM cached_rfcs")}
|
db.conn().execute("SELECT collection_id, slug FROM cached_rfcs")}
|
||||||
assert got == {("default", "a"), ("features", "b")}
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user