§22 S3: scope-role enforcement + collection-grain visibility — v0.42.0 (@S3) #1
@@ -23,6 +23,63 @@ skip versions are the composition of each intervening adjacent
|
|||||||
release's steps in order — no A-to-B path is pre-computed beyond
|
release's steps in order — no A-to-B path is pre-computed beyond
|
||||||
that.
|
that.
|
||||||
|
|
||||||
|
## 0.41.0 — 2026-06-05
|
||||||
|
|
||||||
|
**Minor (non-breaking) — §22 three-tier refactor, slice S2: *create & navigate a
|
||||||
|
second collection.* A deployment can now host more than one RFC collection per
|
||||||
|
project: a second collection is created, navigated, and proposed into beside the
|
||||||
|
default one. Purely additive — a single-collection deployment is unchanged (its
|
||||||
|
`/p/<project>/` still redirects into the sole collection, C3.7/C3.8). Completes
|
||||||
|
acceptance scenario `@S2` (C3.6: an anonymous reader of an empty public
|
||||||
|
collection sees an empty catalog with no propose action and a sign-in prompt).**
|
||||||
|
|
||||||
|
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||||||
|
(Part E slice S2) and the slice plan
|
||||||
|
[`docs/design/plans/2026-06-05-s2-second-collection.md`](./docs/design/plans/2026-06-05-s2-second-collection.md).
|
||||||
|
Scoped {owner, contributor} roles at the collection axis remain S3; the
|
||||||
|
role-keyed create/propose-first empty states remain S4.
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- **Named collections via `.collection.yaml`** — the registry mirror walks each
|
||||||
|
project's content repo and upserts a collection per
|
||||||
|
`<subfolder>/.collection.yaml` manifest (`type`, optional `visibility` /
|
||||||
|
`initial_state` / `name`; `type` immutable per §22.4a, visibility inherits the
|
||||||
|
project's when omitted). The default collection still flows from
|
||||||
|
`projects.yaml`.
|
||||||
|
- **create-collection** — `POST /api/projects/<id>/collections` (deployment
|
||||||
|
owner/admin). The bot commits a `.collection.yaml` to the content repo's
|
||||||
|
`main`, then the registry re-mirrors so the `collections` row appears (the
|
||||||
|
registry stays the source of truth, §22.2). New reads
|
||||||
|
`GET /api/projects/<id>/collections` and `…/collections/<cid>`.
|
||||||
|
- **Collection-scoped serve + propose** —
|
||||||
|
`GET /api/projects/<id>/collections/<cid>/rfcs[/<slug>]` and
|
||||||
|
`POST …/collections/<cid>/rfcs/propose`. A propose writes the entry under the
|
||||||
|
target collection's `<subfolder>/rfcs/`.
|
||||||
|
- **Collection directory at `/p/<project>/`** — lists the project's visible
|
||||||
|
collections, or redirects into the sole one when there is exactly one
|
||||||
|
(preserving the S1 single-collection UX). The catalog rail + entry views read
|
||||||
|
the active `/c/<collection>/` segment and scope to it.
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
|
||||||
|
- **The corpus mirror is collection-grained** — `cache.refresh_meta_repo`
|
||||||
|
iterates each project's collections and reads each collection's
|
||||||
|
`<subfolder>/rfcs/`, keying `cached_rfcs` by `collection_id`. The default
|
||||||
|
collection keeps the shipped repo-root `rfcs/` path; N=1 serving is unchanged.
|
||||||
|
|
||||||
|
> ### Upgrade steps (0.40.0 → 0.41.0)
|
||||||
|
>
|
||||||
|
> - No required operator action — the slice is additive and the default-collection
|
||||||
|
> paths are unchanged. A deployment **MAY** deploy this version with no config
|
||||||
|
> change and keep running exactly as on 0.40.0.
|
||||||
|
> - To add a second collection, a deployment owner/admin **MAY** call
|
||||||
|
> `POST /api/projects/<id>/collections` (or commit a `<subfolder>/.collection.yaml`
|
||||||
|
> to the content repo directly); the registry mirror picks it up on the next
|
||||||
|
> refresh.
|
||||||
|
> - Deployments that pin the framework version **MUST** bump their version pin to
|
||||||
|
> `0.41.0`.
|
||||||
|
|
||||||
## 0.40.0 — 2026-06-05
|
## 0.40.0 — 2026-06-05
|
||||||
|
|
||||||
**Minor (breaking URL) — §22 three-tier refactor, slice S1: the *collection*
|
**Minor (breaking URL) — §22 three-tier refactor, slice S1: the *collection*
|
||||||
|
|||||||
+80
-19
@@ -21,6 +21,7 @@ from pydantic import BaseModel, Field
|
|||||||
from . import (
|
from . import (
|
||||||
api_admin,
|
api_admin,
|
||||||
api_branches,
|
api_branches,
|
||||||
|
api_collections,
|
||||||
api_contributions,
|
api_contributions,
|
||||||
api_deployment,
|
api_deployment,
|
||||||
api_discussion,
|
api_discussion,
|
||||||
@@ -152,6 +153,7 @@ def make_router(
|
|||||||
# §22.9/§22.10 (M3): runtime deployment + per-project config (replaces
|
# §22.9/§22.10 (M3): runtime deployment + per-project config (replaces
|
||||||
# VITE_APP_NAME) + the old-URL 308 redirects.
|
# VITE_APP_NAME) + the old-URL 308 redirects.
|
||||||
router.include_router(api_deployment.make_router(config))
|
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.
|
# §17: /api/health — unauthenticated post-flight probe.
|
||||||
@@ -717,15 +719,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 +771,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 +792,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 +995,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 +1017,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 +1030,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 +1066,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 +1078,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 +1127,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
|
||||||
|
|||||||
@@ -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
|
||||||
+42
-3
@@ -163,6 +163,41 @@ class Bot:
|
|||||||
def __init__(self, gitea: Gitea):
|
def __init__(self, gitea: Gitea):
|
||||||
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) -----
|
# ----- Meta repo: idea PRs (§9.1 / §9.2) -----
|
||||||
|
|
||||||
async def open_idea_pr(
|
async def open_idea_pr(
|
||||||
@@ -175,12 +210,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 +228,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,
|
||||||
|
|||||||
+26
-11
@@ -54,15 +54,27 @@ async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: Gitea) -> None:
|
async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: Gitea) -> None:
|
||||||
# §22 S1: the corpus grain is the collection. The mirror is project-grained
|
# §22 S2: the corpus grain is the collection. Mirror every collection of the
|
||||||
# (reads rfcs/ at the repo root = the project's default collection); resolve
|
# project from its `<subfolder>/rfcs/` directory, keying cached_rfcs by the
|
||||||
# that collection once and key cached_rfcs by it.
|
# collection id. The default collection (subfolder '') reads `rfcs/` — the
|
||||||
|
# shipped path, unchanged. include_unlisted: the mirror serves every
|
||||||
|
# collection's content regardless of enumeration visibility.
|
||||||
from . import collections as collections_mod
|
from . import collections as collections_mod
|
||||||
collection_id = collections_mod.default_collection_id(project_id)
|
for col in collections_mod.list_collections(project_id, include_unlisted=True):
|
||||||
|
await _refresh_collection_corpus(
|
||||||
|
org, project_id, repo, col["id"], col["subfolder"] or "", gitea
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_collection_corpus(
|
||||||
|
org: str, project_id: str, repo: str, collection_id: str, subfolder: str, gitea: Gitea
|
||||||
|
) -> None:
|
||||||
|
rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs"
|
||||||
try:
|
try:
|
||||||
files = await gitea.list_dir(org, repo, "rfcs", ref="main")
|
files = await gitea.list_dir(org, repo, rfcs_dir, ref="main")
|
||||||
except GiteaError as e:
|
except GiteaError as e:
|
||||||
log.warning("refresh_meta_repo: project %s: cannot list rfcs/: %s", project_id, e)
|
log.warning("refresh_meta_repo: %s/%s: cannot list %s: %s",
|
||||||
|
project_id, collection_id, rfcs_dir, e)
|
||||||
return
|
return
|
||||||
|
|
||||||
seen_slugs: set[str] = set()
|
seen_slugs: set[str] = set()
|
||||||
@@ -76,17 +88,19 @@ async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: G
|
|||||||
try:
|
try:
|
||||||
entry = entry_mod.parse(text)
|
entry = entry_mod.parse(text)
|
||||||
except Exception as parse_err:
|
except Exception as parse_err:
|
||||||
log.warning("refresh_meta_repo: %s: skipping %s: %s", project_id, f["path"], parse_err)
|
log.warning("refresh_meta_repo: %s/%s: skipping %s: %s",
|
||||||
|
project_id, collection_id, f["path"], parse_err)
|
||||||
continue
|
continue
|
||||||
if not entry.slug:
|
if not entry.slug:
|
||||||
log.warning("refresh_meta_repo: %s: skipping %s: missing slug", project_id, f["path"])
|
log.warning("refresh_meta_repo: %s/%s: skipping %s: missing slug",
|
||||||
|
project_id, collection_id, f["path"])
|
||||||
continue
|
continue
|
||||||
seen_slugs.add(entry.slug)
|
seen_slugs.add(entry.slug)
|
||||||
_upsert_cached_rfc(entry, body_sha=sha, collection_id=collection_id)
|
_upsert_cached_rfc(entry, body_sha=sha, collection_id=collection_id)
|
||||||
|
|
||||||
# Entries removed from a project's rfcs/ — the spec keeps withdrawn entries
|
# Entries removed from a collection's rfcs/ — the spec keeps withdrawn entries
|
||||||
# as historical record (§3), so this fires only for out-of-band deletes;
|
# as historical record (§3), so this fires only for out-of-band deletes;
|
||||||
# leave the row, scoped to this project, for reconciler attention.
|
# leave the row, scoped to this collection, for reconciler attention.
|
||||||
existing = {
|
existing = {
|
||||||
row["slug"]
|
row["slug"]
|
||||||
for row in db.conn().execute(
|
for row in db.conn().execute(
|
||||||
@@ -94,7 +108,8 @@ async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: G
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
for missing in existing - seen_slugs:
|
for missing in existing - seen_slugs:
|
||||||
log.info("refresh_meta_repo: %s/%s no longer in rfcs/ — leaving cache row", project_id, missing)
|
log.info("refresh_meta_repo: %s/%s/%s no longer present — leaving cache row",
|
||||||
|
project_id, collection_id, missing)
|
||||||
|
|
||||||
|
|
||||||
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, collection_id: str = "default") -> None:
|
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, collection_id: str = "default") -> None:
|
||||||
|
|||||||
@@ -48,3 +48,39 @@ def collection_type(collection_id: str) -> str:
|
|||||||
"SELECT type FROM collections WHERE id = ?", (collection_id,)
|
"SELECT type FROM collections WHERE id = ?", (collection_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return row["type"] if row and row["type"] else "document"
|
return row["type"] if row and row["type"] else "document"
|
||||||
|
|
||||||
|
|
||||||
|
def subfolder_of(collection_id: str) -> str:
|
||||||
|
"""The content-repo subfolder a collection lives under (§22.3). Empty string
|
||||||
|
for the default collection (entries at the repo root `rfcs/`)."""
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT subfolder FROM collections WHERE id = ?", (collection_id,)
|
||||||
|
).fetchone()
|
||||||
|
return (row["subfolder"] if row else "") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_collection(collection_id: str) -> dict | None:
|
||||||
|
"""The full collection row as a dict, or None if unknown."""
|
||||||
|
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 list_collections(project_id: str, include_unlisted: bool = False) -> list[dict]:
|
||||||
|
"""Collections in a project, the default first then by name (§22.5). `unlisted`
|
||||||
|
is omitted from enumeration unless include_unlisted (a direct-id read or the
|
||||||
|
corpus mirror, which serves every collection)."""
|
||||||
|
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: list[dict] = []
|
||||||
|
for r in rows:
|
||||||
|
if not include_unlisted and r["visibility"] == "unlisted":
|
||||||
|
continue
|
||||||
|
out.append(dict(r))
|
||||||
|
return out
|
||||||
|
|||||||
@@ -55,6 +55,17 @@ class ProjectEntry:
|
|||||||
config: dict = field(default_factory=dict) # theme, enabled_models
|
config: dict = field(default_factory=dict) # theme, enabled_models
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CollectionEntry:
|
||||||
|
"""A named collection declared by a `.collection.yaml` manifest inside a
|
||||||
|
project's content repo (S2). `visibility=None` means "inherit the project's
|
||||||
|
visibility"."""
|
||||||
|
type: str
|
||||||
|
visibility: str | None
|
||||||
|
initial_state: str
|
||||||
|
name: str | None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RegistryDoc:
|
class RegistryDoc:
|
||||||
deployment_name: str
|
deployment_name: str
|
||||||
@@ -114,6 +125,33 @@ def parse_registry(text: str) -> RegistryDoc:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_collection_manifest(text: str) -> CollectionEntry:
|
||||||
|
"""Parse + validate a `.collection.yaml`. Pure (no I/O). Raises RegistryError.
|
||||||
|
|
||||||
|
`type` is required and immutable (§22.4a, enforced at upsert). `visibility`
|
||||||
|
is optional — omitted means inherit the project's. `initial_state` defaults
|
||||||
|
per type (§22.4b)."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
def _default_collection_id(project_id: str, default_id: str) -> str:
|
def _default_collection_id(project_id: str, default_id: str) -> str:
|
||||||
"""The id of a project's default collection. The deployment's primary
|
"""The id of a project's default collection. The deployment's primary
|
||||||
project (== `default_id`, the §22.13 resolved default) gets the stable
|
project (== `default_id`, the §22.13 resolved default) gets the stable
|
||||||
@@ -193,6 +231,71 @@ def apply_registry(doc: RegistryDoc, registry_sha: str, default_id: str) -> None
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_named_collection(
|
||||||
|
proj: ProjectEntry, subdir: str, ce: CollectionEntry, sha: str
|
||||||
|
) -> None:
|
||||||
|
"""Upsert one named collection (S2). Type is immutable (§22.4a): a type
|
||||||
|
change against an existing row is refused (logged, not applied). A None
|
||||||
|
manifest visibility 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),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _mirror_named_collections(config: Config, gitea: Gitea, doc: RegistryDoc, sha: str) -> None:
|
||||||
|
"""§22 S2: named collections are declared by `.collection.yaml` manifests
|
||||||
|
inside each project's content repo (the default collection comes from
|
||||||
|
projects.yaml). Walk each content repo root; a subdir carrying a manifest
|
||||||
|
becomes a collection keyed by the subdir name. Tolerant: a transport or
|
||||||
|
parse failure on one project/collection logs and is skipped, never aborts
|
||||||
|
the wider mirror (keep last-good)."""
|
||||||
|
for proj in doc.projects:
|
||||||
|
try:
|
||||||
|
items = await gitea.list_dir(config.gitea_org, proj.content_repo, "", ref="main")
|
||||||
|
except Exception as e: # noqa: BLE001 — GiteaError/transport: tolerate
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
async def refresh_registry(config: Config, gitea: Gitea) -> None:
|
async def refresh_registry(config: Config, gitea: Gitea) -> None:
|
||||||
"""Mirror REGISTRY_REPO/projects.yaml into projects + deployment.
|
"""Mirror REGISTRY_REPO/projects.yaml into projects + deployment.
|
||||||
|
|
||||||
@@ -213,4 +316,6 @@ async def refresh_registry(config: Config, gitea: Gitea) -> None:
|
|||||||
doc = parse_registry(text)
|
doc = parse_registry(text)
|
||||||
from . import projects as projects_mod
|
from . import projects as projects_mod
|
||||||
apply_registry(doc, sha, projects_mod.resolved_default_id(config))
|
apply_registry(doc, sha, projects_mod.resolved_default_id(config))
|
||||||
|
# §22 S2: discover + upsert named collections from each content repo.
|
||||||
|
await _mirror_named_collections(config, gitea, doc, sha)
|
||||||
log.info("registry: mirrored %d project(s) at %s", len(doc.projects), sha)
|
log.info("registry: mirrored %d project(s) at %s", len(doc.projects), sha)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""§22 S2 — collection read helpers: list_collections / get_collection /
|
||||||
|
subfolder_of."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app import collections as collections_mod, db
|
||||||
|
from app.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def _db() -> Config:
|
||||||
|
cfg = Config(
|
||||||
|
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="x",
|
||||||
|
registry_repo="registry", oauth_client_id="x",
|
||||||
|
oauth_client_secret="x", app_url="x", secret_key="x",
|
||||||
|
database_path=Path(tempfile.mkdtemp(prefix="colhelp-")) / "t.db",
|
||||||
|
owner_gitea_login="x", webhook_secret="x",
|
||||||
|
)
|
||||||
|
db.run_migrations(cfg)
|
||||||
|
if db._CONN is not None:
|
||||||
|
db._CONN.close()
|
||||||
|
db._CONN = None
|
||||||
|
db.init(cfg)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(project_id="ohm"):
|
||||||
|
db.conn().execute(
|
||||||
|
"INSERT OR REPLACE 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 OR REPLACE 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():
|
||||||
|
_db()
|
||||||
|
_seed()
|
||||||
|
ids = [c["id"] for c in collections_mod.list_collections("ohm", include_unlisted=False)]
|
||||||
|
assert ids == ["default", "features"] # default first, then by name; 'secret' omitted
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_collections_include_unlisted():
|
||||||
|
_db()
|
||||||
|
_seed()
|
||||||
|
ids = {c["id"] for c in collections_mod.list_collections("ohm", include_unlisted=True)}
|
||||||
|
assert ids == {"default", "features", "secret"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_collection_and_subfolder():
|
||||||
|
_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
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""§22 S2 — the registry mirror reads `.collection.yaml` manifests inside each
|
||||||
|
project's content repo and upserts a named collection per manifest. The default
|
||||||
|
collection still flows from projects.yaml (test_registry.py)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import db, registry
|
||||||
|
from app.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def _db() -> Config:
|
||||||
|
cfg = Config(
|
||||||
|
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="wiggleverse",
|
||||||
|
registry_repo="registry", oauth_client_id="x",
|
||||||
|
oauth_client_secret="x", app_url="x", secret_key="x",
|
||||||
|
database_path=Path(tempfile.mkdtemp(prefix="colreg-")) / "t.db",
|
||||||
|
owner_gitea_login="x", webhook_secret="x",
|
||||||
|
)
|
||||||
|
db.run_migrations(cfg)
|
||||||
|
if db._CONN is not None:
|
||||||
|
db._CONN.close()
|
||||||
|
db._CONN = None
|
||||||
|
db.init(cfg)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
# --- pure parser --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_collection_manifest_rejects_bad_visibility():
|
||||||
|
with pytest.raises(registry.RegistryError):
|
||||||
|
registry.parse_collection_manifest("type: bdd\nvisibility: nope\n")
|
||||||
|
|
||||||
|
|
||||||
|
# --- mirror discovery ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeGitea:
|
||||||
|
"""Minimal Gitea stub: projects.yaml in the registry repo + a content repo
|
||||||
|
whose root holds a `features/` subdir carrying a `.collection.yaml`."""
|
||||||
|
|
||||||
|
def __init__(self, projects_yaml: str, repo_tree: dict[str, dict[str, str]]):
|
||||||
|
self._projects_yaml = projects_yaml
|
||||||
|
self._repo_tree = repo_tree # {repo: {path: text}}
|
||||||
|
|
||||||
|
async def get_contents(self, org, repo, path, ref="main"):
|
||||||
|
if path == "projects.yaml":
|
||||||
|
return {"type": "file",
|
||||||
|
"content": base64.b64encode(self._projects_yaml.encode()).decode(),
|
||||||
|
"sha": "regsha-test"}
|
||||||
|
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 segment as a 'dir' entry.
|
||||||
|
prefix = (path.rstrip("/") + "/") if path else ""
|
||||||
|
dirs = set()
|
||||||
|
for p in self._repo_tree.get(repo, {}):
|
||||||
|
if not p.startswith(prefix):
|
||||||
|
continue
|
||||||
|
rest = p[len(prefix):]
|
||||||
|
if "/" in rest:
|
||||||
|
dirs.add(rest.split("/", 1)[0])
|
||||||
|
return [{"type": "dir", "name": n, "path": prefix + n} for n in sorted(dirs)]
|
||||||
|
|
||||||
|
|
||||||
|
_PROJECTS = (
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_registry_mirrors_named_collection():
|
||||||
|
cfg = _db()
|
||||||
|
gitea = _FakeGitea(
|
||||||
|
projects_yaml=_PROJECTS,
|
||||||
|
repo_tree={"ohm-rfc": {"features/.collection.yaml": "type: bdd\nname: Features\n"}},
|
||||||
|
)
|
||||||
|
asyncio.run(registry.refresh_registry(cfg, gitea))
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT type, subfolder, name, project_id, visibility FROM collections WHERE id='features'"
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert (row["type"], row["subfolder"], row["project_id"]) == ("bdd", "features", "ohm")
|
||||||
|
assert row["name"] == "Features"
|
||||||
|
# visibility inherits the project's (public) when the manifest omits it.
|
||||||
|
assert row["visibility"] == "public"
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_registry_leaves_default_collection_intact():
|
||||||
|
cfg = _db()
|
||||||
|
gitea = _FakeGitea(
|
||||||
|
projects_yaml=_PROJECTS,
|
||||||
|
repo_tree={"ohm-rfc": {"features/.collection.yaml": "type: bdd\n"}},
|
||||||
|
)
|
||||||
|
asyncio.run(registry.refresh_registry(cfg, gitea))
|
||||||
|
# The default collection (from projects.yaml) and the named one coexist.
|
||||||
|
ids = {r["id"] for r in db.conn().execute("SELECT id FROM collections")}
|
||||||
|
assert {"default", "features"} <= ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_registry_immutable_type_on_named_collection():
|
||||||
|
cfg = _db()
|
||||||
|
gitea = _FakeGitea(
|
||||||
|
projects_yaml=_PROJECTS,
|
||||||
|
repo_tree={"ohm-rfc": {"features/.collection.yaml": "type: bdd\n"}},
|
||||||
|
)
|
||||||
|
asyncio.run(registry.refresh_registry(cfg, gitea))
|
||||||
|
# A later manifest that flips the type is refused (§22.4a immutable type).
|
||||||
|
gitea._repo_tree["ohm-rfc"]["features/.collection.yaml"] = "type: document\n"
|
||||||
|
asyncio.run(registry.refresh_registry(cfg, gitea))
|
||||||
|
t = db.conn().execute("SELECT type FROM collections WHERE id='features'").fetchone()["type"]
|
||||||
|
assert t == "bdd"
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""§22 S2 — collection-grained corpus mirror + collection-scoped serve/propose.
|
||||||
|
|
||||||
|
The mirror test drives cache.refresh_meta_repo against an in-memory content repo
|
||||||
|
holding entries under both the default `rfcs/` and a named collection's
|
||||||
|
`features/rfcs/`, and asserts cached_rfcs is keyed by the right collection_id."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app import cache, db
|
||||||
|
from app.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def _db() -> Config:
|
||||||
|
cfg = Config(
|
||||||
|
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="wiggleverse",
|
||||||
|
registry_repo="registry", oauth_client_id="x",
|
||||||
|
oauth_client_secret="x", app_url="x", secret_key="x",
|
||||||
|
database_path=Path(tempfile.mkdtemp(prefix="colserve-")) / "t.db",
|
||||||
|
owner_gitea_login="x", webhook_secret="x",
|
||||||
|
)
|
||||||
|
db.run_migrations(cfg)
|
||||||
|
if db._CONN is not None:
|
||||||
|
db._CONN.close()
|
||||||
|
db._CONN = None
|
||||||
|
db.init(cfg)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
class _CorpusGitea:
|
||||||
|
"""A content repo modelled as a flat {path: text} map, listing files under a
|
||||||
|
directory prefix and reading them back."""
|
||||||
|
|
||||||
|
def __init__(self, tree: dict[str, str]):
|
||||||
|
self._tree = tree
|
||||||
|
|
||||||
|
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 _seed_project_with_two_collections():
|
||||||
|
db.conn().execute(
|
||||||
|
"INSERT OR REPLACE 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 OR REPLACE 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))
|
||||||
|
|
||||||
|
|
||||||
|
def test_mirror_keys_entries_by_collection():
|
||||||
|
cfg = _db()
|
||||||
|
_seed_project_with_two_collections()
|
||||||
|
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(cfg, 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")}
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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
|
||||||
|
|
||||||
|
|
||||||
|
def test_s2_anonymous_empty_public_collection(app_with_fake_gitea):
|
||||||
|
"""C3.6 (@S2): a public collection with no entries; an anonymous visitor
|
||||||
|
lands on its catalog → an empty catalog (200, no items), and the propose
|
||||||
|
action is not available to them (the propose route rejects anonymous)."""
|
||||||
|
app, _ = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_add_features_collection() # public, no entries
|
||||||
|
# Anonymous (no session cookie) reads the empty catalog — 200, [].
|
||||||
|
r = client.get("/api/projects/default/collections/features/rfcs")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["items"] == []
|
||||||
|
# No propose action for an anonymous visitor.
|
||||||
|
r2 = client.post(
|
||||||
|
"/api/projects/default/collections/features/rfcs/propose",
|
||||||
|
json={"title": "X", "slug": "x", "pitch": "p", "tags": []})
|
||||||
|
assert r2.status_code == 401
|
||||||
@@ -90,6 +90,28 @@ class FakeGitea:
|
|||||||
self._commit_counter += 1
|
self._commit_counter += 1
|
||||||
return f"sha{self._commit_counter:04d}"
|
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:
|
def _enrich_pr(self, owner: str, repo: str, pr: dict) -> dict:
|
||||||
"""Return the PR with mergeability fields filled in.
|
"""Return the PR with mergeability fields filled in.
|
||||||
|
|
||||||
@@ -227,6 +249,16 @@ class FakeGitea:
|
|||||||
}
|
}
|
||||||
return httpx.Response(201, json={"name": new})
|
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=...
|
# GET /repos/{owner}/{repo}/contents/{path}?ref=...
|
||||||
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path)
|
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path)
|
||||||
if method == "GET" and m:
|
if method == "GET" and m:
|
||||||
@@ -242,17 +274,8 @@ class FakeGitea:
|
|||||||
"sha": f["sha"],
|
"sha": f["sha"],
|
||||||
"content": base64.b64encode(f["content"].encode()).decode(),
|
"content": base64.b64encode(f["content"].encode()).decode(),
|
||||||
})
|
})
|
||||||
# Directory listing
|
# Directory listing — both file and subdir children.
|
||||||
prefix = fpath.rstrip("/") + "/"
|
children = self._dir_listing(owner, repo, ref, fpath)
|
||||||
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"],
|
|
||||||
})
|
|
||||||
if children:
|
if children:
|
||||||
return httpx.Response(200, json=children)
|
return httpx.Response(200, json=children)
|
||||||
return httpx.Response(404, json={"message": "not found"})
|
return httpx.Response(404, json={"message": "not found"})
|
||||||
|
|||||||
@@ -549,7 +549,8 @@ means the deployment runs and either gains a capability or provably loses none
|
|||||||
exactly as before, now with a real collection layer and one extra path segment.
|
exactly as before, now with a real collection layer and one extra path segment.
|
||||||
**Completes:** `@S1` (the single-collection / single-project redirect skips).
|
**Completes:** `@S1` (the single-collection / single-project redirect skips).
|
||||||
|
|
||||||
- **S2 — Create & navigate a second collection.** Teach the registry mirror to
|
- **S2 — Create & navigate a second collection.** *(Shipped v0.41.0.)* Teach the
|
||||||
|
registry mirror to
|
||||||
read `.collection.yaml`; add the bot-commit-wrapped **create-collection**
|
read `.collection.yaml`; add the bot-commit-wrapped **create-collection**
|
||||||
endpoint (authorized by existing deployment owner/admin for now — the scoped
|
endpoint (authorized by existing deployment owner/admin for now — the scoped
|
||||||
role surface lands in S3); the project collection-directory at `/p/<project>/`;
|
role surface lands in S3); the project collection-directory at `/p/<project>/`;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.40.0",
|
"version": "0.41.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+16
-9
@@ -15,6 +15,7 @@ import RFCView from './components/RFCView.jsx'
|
|||||||
import PRView from './components/PRView.jsx'
|
import PRView from './components/PRView.jsx'
|
||||||
import ProposalView from './components/ProposalView.jsx'
|
import ProposalView from './components/ProposalView.jsx'
|
||||||
import ProposeModal from './components/ProposeModal.jsx'
|
import ProposeModal from './components/ProposeModal.jsx'
|
||||||
|
import CollectionDirectory from './components/CollectionDirectory.jsx'
|
||||||
import ContributeRequestForm from './components/ContributeRequestForm.jsx'
|
import ContributeRequestForm from './components/ContributeRequestForm.jsx'
|
||||||
import Landing from './components/Landing.jsx'
|
import Landing from './components/Landing.jsx'
|
||||||
import Login from './components/Login.jsx'
|
import Login from './components/Login.jsx'
|
||||||
@@ -66,6 +67,10 @@ export default function App() {
|
|||||||
// right project. Falls back to the deployment default off a project route.
|
// right project. Falls back to the deployment default off a project route.
|
||||||
const _projMatch = location.pathname.match(/^\/p\/([^/]+)/)
|
const _projMatch = location.pathname.match(/^\/p\/([^/]+)/)
|
||||||
const currentProjectId = (_projMatch && _projMatch[1]) || deployment.defaultProjectId
|
const currentProjectId = (_projMatch && _projMatch[1]) || deployment.defaultProjectId
|
||||||
|
// §22 S2 — the collection the viewer is currently in (from the /c/<cid>/ URL
|
||||||
|
// segment), so a propose targets that collection. Falls back to the default.
|
||||||
|
const _colMatch = location.pathname.match(/^\/p\/[^/]+\/c\/([^/]+)/)
|
||||||
|
const currentCollectionId = (_colMatch && _colMatch[1]) || DEFAULT_COLLECTION
|
||||||
// #28 Parts 2–3: the LinkedText create/contribute affordances route via
|
// #28 Parts 2–3: the LinkedText create/contribute affordances route via
|
||||||
// query params so they need no prop-threading from deep in a comment
|
// query params so they need no prop-threading from deep in a comment
|
||||||
// list. `?propose=<term>` opens the propose modal pre-filled;
|
// list. `?propose=<term>` opens the propose modal pre-filled;
|
||||||
@@ -360,9 +365,10 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<main className="main-pane">
|
<main className="main-pane">
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* §22 three-tier (C3.7): the project landing redirects into
|
{/* §22 S2: the project landing is the collection directory —
|
||||||
its sole/default collection (S1: the `default` one). */}
|
it lists collections, or (C3.7/C3.8) redirects into the
|
||||||
<Route path="" element={<DefaultCollectionRedirect />} />
|
sole visible collection when there is exactly one. */}
|
||||||
|
<Route path="" element={<CollectionDirectoryRoute />} />
|
||||||
{/* Backcompat: the shipped v0.35.0 corpus URLs without a
|
{/* Backcompat: the shipped v0.35.0 corpus URLs without a
|
||||||
/c/<collection>/ segment redirect into the default
|
/c/<collection>/ segment redirect into the default
|
||||||
collection, so old bookmarks keep working. */}
|
collection, so old bookmarks keep working. */}
|
||||||
@@ -389,12 +395,13 @@ export default function App() {
|
|||||||
viewer={viewer}
|
viewer={viewer}
|
||||||
initialTitle={proposeParam || ''}
|
initialTitle={proposeParam || ''}
|
||||||
projectId={currentProjectId}
|
projectId={currentProjectId}
|
||||||
|
collectionId={currentCollectionId}
|
||||||
onClose={() => { setProposeOpen(false); clearParams('propose') }}
|
onClose={() => { setProposeOpen(false); clearParams('propose') }}
|
||||||
onSubmitted={({ pr_number }) => {
|
onSubmitted={({ pr_number }) => {
|
||||||
setProposeOpen(false)
|
setProposeOpen(false)
|
||||||
clearParams('propose')
|
clearParams('propose')
|
||||||
setCatalogVersion(v => v + 1)
|
setCatalogVersion(v => v + 1)
|
||||||
navigate(proposalPath(currentProjectId, pr_number))
|
navigate(proposalPath(currentProjectId, pr_number, currentCollectionId))
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -414,12 +421,12 @@ export default function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// §22 three-tier — corpus redirects mounted under /p/:projectId/*.
|
// §22 S2 — the project landing at /p/:projectId/ is the collection directory.
|
||||||
// C3.7: the project landing skips the (single-collection) directory and lands in
|
// A tiny wrapper reads the route's projectId and hands it to CollectionDirectory
|
||||||
// the project's default collection.
|
// (which lists collections, or redirects into the sole one — C3.7/C3.8).
|
||||||
function DefaultCollectionRedirect() {
|
function CollectionDirectoryRoute() {
|
||||||
const { projectId } = useParams()
|
const { projectId } = useParams()
|
||||||
return <Navigate to={`/p/${projectId}/c/${DEFAULT_COLLECTION}/`} replace />
|
return <CollectionDirectory projectId={projectId} />
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backcompat for the shipped v0.35.0 corpus URLs that lacked the
|
// Backcompat for the shipped v0.35.0 corpus URLs that lacked the
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// §22 S2 — the API client builds collection-scoped URLs when a collection id is
|
||||||
|
// supplied, and falls back to the project/default-collection paths otherwise.
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
import { listRFCs, getRFC, proposeRFC, listCollections } from './api.js'
|
||||||
|
|
||||||
|
function mockFetch() {
|
||||||
|
const fn = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ items: [] }),
|
||||||
|
}))
|
||||||
|
global.fetch = fn
|
||||||
|
return fn
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => { vi.restoreAllMocks() })
|
||||||
|
|
||||||
|
describe('collection-scoped api URLs', () => {
|
||||||
|
it('listRFCs scopes to a collection when given one', async () => {
|
||||||
|
const f = mockFetch()
|
||||||
|
await listRFCs('ohm', 'features')
|
||||||
|
expect(f).toHaveBeenCalledWith('/api/projects/ohm/collections/features/rfcs')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('listRFCs falls back to the project default path without a collection', async () => {
|
||||||
|
const f = mockFetch()
|
||||||
|
await listRFCs('ohm')
|
||||||
|
expect(f).toHaveBeenCalledWith('/api/projects/ohm/rfcs')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getRFC scopes to a collection when given one', async () => {
|
||||||
|
const f = mockFetch()
|
||||||
|
await getRFC('ohm', 'login', 'features')
|
||||||
|
expect(f).toHaveBeenCalledWith('/api/projects/ohm/collections/features/rfcs/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('proposeRFC targets the collection-scoped propose route', async () => {
|
||||||
|
const f = mockFetch()
|
||||||
|
await proposeRFC('ohm', { title: 'T', slug: 's', pitch: 'p', tags: [], collectionId: 'features' })
|
||||||
|
expect(f.mock.calls[0][0]).toBe('/api/projects/ohm/collections/features/rfcs/propose')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('listCollections hits the project collections route', async () => {
|
||||||
|
const f = mockFetch()
|
||||||
|
await listCollections('ohm')
|
||||||
|
expect(f).toHaveBeenCalledWith('/api/projects/ohm/collections')
|
||||||
|
})
|
||||||
|
})
|
||||||
+37
-7
@@ -182,22 +182,50 @@ export async function getProject(projectId) {
|
|||||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}`))
|
return jsonOrThrow(await fetch(`/api/projects/${projectId}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
// §22.4 (Plan B): per-project serving. Given a projectId, read the
|
// §22.4 (Plan B) / §22 S2: per-collection serving. With a projectId + a
|
||||||
// project-scoped routes so a non-default project's corpus renders; without
|
// collectionId, read the collection-scoped routes; with only a projectId, the
|
||||||
// one, fall back to the default-project compat path.
|
// project default-collection compat path; with neither, the unscoped path.
|
||||||
export async function listRFCs(projectId) {
|
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'
|
const url = projectId ? `/api/projects/${projectId}/rfcs` : '/api/rfcs'
|
||||||
return jsonOrThrow(await fetch(url))
|
return jsonOrThrow(await fetch(url))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRFC(projectId, slug) {
|
export async function getRFC(projectId, slug, collectionId) {
|
||||||
// Back-compat: getRFC(slug) (one arg) still hits the unscoped default path.
|
// Back-compat: getRFC(slug) (one arg) still hits the unscoped default path.
|
||||||
if (slug === undefined) {
|
if (slug === undefined) {
|
||||||
return jsonOrThrow(await fetch(`/api/rfcs/${projectId}`))
|
return jsonOrThrow(await fetch(`/api/rfcs/${projectId}`))
|
||||||
}
|
}
|
||||||
|
if (collectionId) {
|
||||||
|
return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}/rfcs/${slug}`))
|
||||||
|
}
|
||||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`))
|
return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §22 S2: the collections of a project (for the /p/<project>/ directory).
|
||||||
|
export async function listCollections(projectId) {
|
||||||
|
return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// §22 S2: create-collection (deployment owner/admin). The backend commits a
|
||||||
|
// .collection.yaml and re-mirrors the registry, returning the new collection.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
export async function listProposals(projectId) {
|
export async function listProposals(projectId) {
|
||||||
const url = projectId ? `/api/projects/${projectId}/proposals` : '/api/proposals'
|
const url = projectId ? `/api/projects/${projectId}/proposals` : '/api/proposals'
|
||||||
return jsonOrThrow(await fetch(url))
|
return jsonOrThrow(await fetch(url))
|
||||||
@@ -209,8 +237,10 @@ export async function getProposal(prNumber) {
|
|||||||
|
|
||||||
// §22.4 (Plan B write): propose into a specific project when projectId is
|
// §22.4 (Plan B write): propose into a specific project when projectId is
|
||||||
// given; else the default-project compat path.
|
// given; else the default-project compat path.
|
||||||
export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase }) {
|
export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase, collectionId }) {
|
||||||
const url = projectId ? `/api/projects/${projectId}/rfcs/propose` : '/api/rfcs/propose'
|
const url = (projectId && collectionId)
|
||||||
|
? `/api/projects/${projectId}/collections/${collectionId}/rfcs/propose`
|
||||||
|
: (projectId ? `/api/projects/${projectId}/rfcs/propose` : '/api/rfcs/propose')
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { listRFCs, listProposals } from '../api'
|
import { listRFCs, listProposals } from '../api'
|
||||||
import { entryPath, proposalPath, useProjectId } from '../lib/entryPaths'
|
import { entryPath, proposalPath, useProjectId, useCollectionId } from '../lib/entryPaths'
|
||||||
|
|
||||||
const STATE_CHIPS = [
|
const STATE_CHIPS = [
|
||||||
{ id: 'super-draft', label: 'Super-draft' },
|
{ id: 'super-draft', label: 'Super-draft' },
|
||||||
@@ -32,11 +32,14 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
const [pendingOpen, setPendingOpen] = useState(true)
|
const [pendingOpen, setPendingOpen] = useState(true)
|
||||||
const { slug, prNumber } = useParams()
|
const { slug, prNumber } = useParams()
|
||||||
const pid = useProjectId()
|
const pid = useProjectId()
|
||||||
|
// §22 S2: the catalog is scoped to the active collection (the `/c/:cid/`
|
||||||
|
// route segment, else the project's default collection).
|
||||||
|
const cid = useCollectionId()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listRFCs(pid).then(d => setRfcs(d.items)).catch(() => setRfcs([]))
|
listRFCs(pid, cid).then(d => setRfcs(d.items)).catch(() => setRfcs([]))
|
||||||
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
|
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
|
||||||
}, [version, pid])
|
}, [version, pid, cid])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const needle = search.trim().toLowerCase()
|
const needle = search.trim().toLowerCase()
|
||||||
@@ -105,7 +108,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={r.slug}
|
key={r.slug}
|
||||||
to={entryPath(pid, r.slug)}
|
to={entryPath(pid, r.slug, cid)}
|
||||||
className={`catalog-row ${isActive ? 'active' : ''} ${isSuper ? 'is-super' : ''}`}
|
className={`catalog-row ${isActive ? 'active' : ''} ${isSuper ? 'is-super' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="row-top">
|
<div className="row-top">
|
||||||
@@ -133,7 +136,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
{proposals.map(p => (
|
{proposals.map(p => (
|
||||||
<Link
|
<Link
|
||||||
key={p.pr_number}
|
key={p.pr_number}
|
||||||
to={proposalPath(pid, p.pr_number)}
|
to={proposalPath(pid, p.pr_number, cid)}
|
||||||
className={`pending-row ${String(prNumber) === String(p.pr_number) ? 'active' : ''}`}
|
className={`pending-row ${String(prNumber) === String(p.pr_number) ? 'active' : ''}`}
|
||||||
>
|
>
|
||||||
<div>{p.title.replace(/^Propose:\s*/, '')}</div>
|
<div>{p.title.replace(/^Propose:\s*/, '')}</div>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// §22 S2 — the project collection directory at `/p/<project>/`. Lists the
|
||||||
|
// project's caller-visible collections as cards linking into each collection's
|
||||||
|
// `/p/<project>/c/<collection>/` home. When exactly one collection is visible
|
||||||
|
// the directory is skipped and we redirect straight into it (the S1 C3.7/C3.8
|
||||||
|
// single-collection UX, preserved). The role-keyed "Create your first
|
||||||
|
// collection" empty state is S4; S2 shows a minimal note when there are none.
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, Navigate } from 'react-router-dom'
|
||||||
|
import { listCollections } from '../api'
|
||||||
|
import { collectionHome } from '../lib/entryPaths'
|
||||||
|
import { entryNoun } from './ProjectLayout.jsx'
|
||||||
|
|
||||||
|
export default function CollectionDirectory({ projectId }) {
|
||||||
|
const [cols, setCols] = useState(null)
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true
|
||||||
|
listCollections(projectId)
|
||||||
|
.then(d => { if (live) setCols(d.items) })
|
||||||
|
.catch(() => { if (live) setCols([]) })
|
||||||
|
return () => { live = false }
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
if (cols === null) {
|
||||||
|
return <main className="chrome-pane"><div className="boot">Loading…</div></main>
|
||||||
|
}
|
||||||
|
// C3.7/C3.8: a single visible collection skips the directory.
|
||||||
|
if (cols.length === 1) {
|
||||||
|
return <Navigate to={collectionHome(projectId, cols[0].id)} replace />
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<main className="chrome-pane">
|
||||||
|
<div className="directory">
|
||||||
|
<h1>Collections</h1>
|
||||||
|
{cols.length === 0 ? (
|
||||||
|
<p className="directory-tagline">No collections yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="directory-list">
|
||||||
|
{cols.map(c => (
|
||||||
|
<li key={c.id} className="directory-card">
|
||||||
|
<Link to={collectionHome(projectId, c.id)}>
|
||||||
|
<span className="directory-card-name">{c.name || c.id}</span>
|
||||||
|
<span className="directory-card-type">{entryNoun(c.type)}s</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
|
||||||
|
let mockItems = []
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
listCollections: vi.fn(async () => ({ items: mockItems })),
|
||||||
|
}))
|
||||||
|
import CollectionDirectory from './CollectionDirectory.jsx'
|
||||||
|
|
||||||
|
beforeEach(() => { mockItems = [] })
|
||||||
|
|
||||||
|
function renderDir(items) {
|
||||||
|
mockItems = items
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={["/p/ohm/"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/p/:projectId/*" element={<CollectionDirectory projectId="ohm" />} />
|
||||||
|
<Route path="/p/:projectId/c/:collectionId/*" element={<div>collection home</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CollectionDirectory', () => {
|
||||||
|
it('lists a card per collection with the type-driven noun + link when 2+', async () => {
|
||||||
|
renderDir([
|
||||||
|
{ id: 'default', name: 'Model', type: 'document' },
|
||||||
|
{ id: 'features', name: 'Scenarios', type: 'bdd' },
|
||||||
|
])
|
||||||
|
await waitFor(() => expect(screen.getByText('Scenarios')).toBeInTheDocument())
|
||||||
|
expect(screen.getByText('Model').closest('a')).toHaveAttribute('href', '/p/ohm/c/default/')
|
||||||
|
expect(screen.getByText('Scenarios').closest('a')).toHaveAttribute('href', '/p/ohm/c/features/')
|
||||||
|
expect(screen.getByText('RFCs')).toBeInTheDocument() // document → RFCs
|
||||||
|
expect(screen.getByText('Features')).toBeInTheDocument() // bdd → Features (type noun)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects into the sole collection when exactly one is visible', async () => {
|
||||||
|
renderDir([{ id: 'default', name: 'Model', type: 'document' }])
|
||||||
|
await waitFor(() => expect(screen.getByText('collection home')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a minimal empty note when there are no collections', async () => {
|
||||||
|
renderDir([])
|
||||||
|
await waitFor(() => expect(screen.getByText('No collections yet.')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -28,7 +28,7 @@ function slugify(title) {
|
|||||||
.replace(/^-+|-+$/g, '')
|
.replace(/^-+|-+$/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '', projectId }) {
|
export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '', projectId, collectionId }) {
|
||||||
// #28 Part 2: a "create RFC for '<term>'" affordance pre-fills the title
|
// #28 Part 2: a "create RFC for '<term>'" affordance pre-fills the title
|
||||||
// (App passes the `?propose=<term>` value here); the slug derives from it
|
// (App passes the `?propose=<term>` value here); the slug derives from it
|
||||||
// via the same effect that drives manual typing.
|
// via the same effect that drives manual typing.
|
||||||
@@ -98,6 +98,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
|
|||||||
pitch: pitch.trim(),
|
pitch: pitch.trim(),
|
||||||
tags,
|
tags,
|
||||||
proposedUseCase: useCase.trim() || null,
|
proposedUseCase: useCase.trim() || null,
|
||||||
|
collectionId,
|
||||||
})
|
})
|
||||||
// v0.15.0 — analytics: fire on the §9.1 propose-RFC submit.
|
// v0.15.0 — analytics: fire on the §9.1 propose-RFC submit.
|
||||||
// Slug is a stable, low-cardinality identifier (kebab-case
|
// Slug is a stable, low-cardinality identifier (kebab-case
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// project-scoped, so the builders emit the default collection segment; the
|
// project-scoped, so the builders emit the default collection segment; the
|
||||||
// collection-aware link layer (named collections) lands in S2. Components build
|
// collection-aware link layer (named collections) lands in S2. Components build
|
||||||
// links via these helpers so that flip happens in one place.
|
// links via these helpers so that flip happens in one place.
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
import { useProject } from '../components/ProjectLayout.jsx'
|
import { useProject } from '../components/ProjectLayout.jsx'
|
||||||
import { useDeployment } from '../context/DeploymentProvider'
|
import { useDeployment } from '../context/DeploymentProvider'
|
||||||
|
|
||||||
@@ -37,3 +38,10 @@ export function useProjectId() {
|
|||||||
const { defaultProjectId } = useDeployment()
|
const { defaultProjectId } = useDeployment()
|
||||||
return (ctx && ctx.projectId) || defaultProjectId
|
return (ctx && ctx.projectId) || defaultProjectId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §22 S2 — the collection id a component should scope to: the `/c/:collectionId/`
|
||||||
|
// route segment when present, else the project's default collection.
|
||||||
|
export function useCollectionId() {
|
||||||
|
const { collectionId } = useParams()
|
||||||
|
return collectionId || DEFAULT_COLLECTION
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user