From fec51bdbb6200c568632a57bffba015f4c922129 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 4 Jun 2026 07:02:19 -0700 Subject: [PATCH] =?UTF-8?q?=C2=A722=20M3-backend=20Plan=20B=20(write=20pat?= =?UTF-8?q?h,=20propose):=20project-scoped=20propose=20(v0.38.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new entry can be proposed into a specific project; it lands in that project's content repo and shows under that project's proposals. A non-default project is no longer read-only. - api.py: POST /api/projects/{pid}/rfcs/propose (propose body extracted into a project-parameterized helper; unscoped /api/rfcs/propose kept as default compat). Slug uniqueness, idea-PR reservation, landing state, and the proposed_use_cases row scoped to the target project. GET /api/projects/{pid}/proposals. - cache.py: refresh_meta_pulls loops every project's content_repo, stamping cached_prs.project_id; projects.content_repo(pid) helper. - frontend: proposeRFC(projectId,…)/listProposals(projectId); ProposeModal takes projectId; App resolves current project from the /p// URL; Catalog lists that project's proposals. - tests: test_project_scoped_propose.py (lands scoped + gated 404). 447 backend + 11 Vitest green; clean build. Known limitation: branch/PR/graduation edit flows + default-id re-stamp not yet scoped (next slice). Per docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 36 ++++++++++ VERSION | 2 +- backend/app/api.py | 75 +++++++++++++++----- backend/app/cache.py | 25 ++++--- backend/app/projects.py | 9 +++ backend/tests/test_project_scoped_propose.py | 64 +++++++++++++++++ frontend/package.json | 2 +- frontend/src/App.jsx | 8 ++- frontend/src/api.js | 12 ++-- frontend/src/components/Catalog.jsx | 2 +- frontend/src/components/ProposeModal.jsx | 4 +- 11 files changed, 205 insertions(+), 34 deletions(-) create mode 100644 backend/tests/test_project_scoped_propose.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 75ffc05..3f1a862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,42 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.38.0 — 2026-06-04 + +**Minor — §22 M3-backend Plan B (write path, propose): a new entry can be +proposed *into a specific project*, landing in that project's content repo and +surfacing under that project's proposals. A non-default project is no longer +read-only. No upgrade steps; single-project deployments are unaffected.** + +Added: + +- **`POST /api/projects/{pid}/rfcs/propose`** — propose into a chosen project + (read-gated, then project-level contribute-gated, §22.6/§22.7). The propose + body is now a project-parameterized helper; the unscoped `/api/rfcs/propose` + stays as the default-project compat path. Slug uniqueness, the idea-PR + reservation, the landing state (§22.4b), and the `proposed_use_cases` row are + all scoped to the target project. +- **`GET /api/projects/{pid}/proposals`** — pending idea-PRs scoped to one + project. +- **Per-project PR mirror** (`app/cache.py`): `refresh_meta_pulls` iterates + every project's `content_repo`, stamping `cached_prs.project_id` (was: the + default project only). `projects.content_repo(pid)` helper added. +- **Tests:** `test_project_scoped_propose.py` — propose into a second project + lands in its content repo + shows only under its proposals (not the + default's); gated-project propose 404s a non-member. 447 backend green. + +Changed: + +- **Frontend:** `api.proposeRFC(projectId, …)` / `listProposals(projectId)`; + `ProposeModal` takes a `projectId`; `App` resolves the current project from + the `/p//` URL so the propose modal targets it; `Catalog` lists that + project's proposals. + +Known limitation (next slice): the **edit** write flows — branch / PR / +graduation — and the **default-project-id re-stamp** (§22.13 step 1) are not +yet project-scoped (they still target the default project's content repo). Per +`docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md` §1 + §3. + ## 0.37.0 — 2026-06-04 **Minor — §22 M3-backend Plan B (2/2, read path): per-project RFC serving. A diff --git a/VERSION b/VERSION index 0f1a7df..ca75280 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.37.0 +0.38.0 diff --git a/backend/app/api.py b/backend/app/api.py index 06966dc..0318b48 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -870,6 +870,35 @@ def make_router( ] } + @router.get("/api/projects/{project_id}/proposals") + async def list_project_proposals(project_id: str, request: Request) -> dict[str, Any]: + # §22.4/§22.5: the pending idea-PRs scoped to one project. + viewer = auth.current_user(request) + auth.require_project_readable(viewer, project_id) + rows = db.conn().execute( + """ + SELECT rfc_slug, pr_number, title, description, opened_by, opened_at, state + FROM cached_prs + WHERE pr_kind = 'idea' AND state = 'open' AND project_id = ? + ORDER BY opened_at DESC + """, + (project_id,), + ).fetchall() + return { + "items": [ + { + "slug": r["rfc_slug"], + "pr_number": r["pr_number"], + "title": r["title"], + "description": r["description"], + "opened_by": r["opened_by"], + "opened_at": r["opened_at"], + "proposed_use_case": _proposal_use_case(r["pr_number"]), + } + for r in rows + ] + } + @router.get("/api/proposals/{pr_number}") async def get_proposal(pr_number: int, request: Request) -> dict[str, Any]: """§9.3 pending-idea view data. @@ -922,14 +951,11 @@ def make_router( # §9.1: propose a new RFC # --------------------------------------------------------------- - @router.post("/api/rfcs/propose") - async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]: - user = auth.require_contributor(request) + async def _propose_into_project(project_id: str, payload: ProposeBody, user) -> dict[str, Any]: # §22.6/§22.7: proposing a new entry requires project-level contribute - # standing. Through M2 every entry lands in the default project; M3's - # routing carries 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. - if not auth.can_contribute_in_project(user, auth.DEFAULT_PROJECT_ID): + if not auth.can_contribute_in_project(user, project_id): raise HTTPException(403, "You do not have contribute access to this project") slug = payload.slug.strip().lower() if not entry_mod.is_valid_slug(slug): @@ -940,13 +966,14 @@ def make_router( # on every keystroke, since a concurrent submission could land # between dialog-open and submit. clash = db.conn().execute( - "SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,) + "SELECT 1 FROM cached_rfcs WHERE slug = ? AND project_id = ?", (slug, project_id) ).fetchone() if clash: raise HTTPException(409, f"Slug `{slug}` is already taken") idea_clash = db.conn().execute( - "SELECT 1 FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' AND rfc_slug = ?", - (slug,), + "SELECT 1 FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' " + "AND rfc_slug = ? AND project_id = ?", + (slug, project_id), ).fetchone() if idea_clash: raise HTTPException(409, f"Slug `{slug}` is already reserved by an open proposal") @@ -954,7 +981,7 @@ def make_router( # §22.4b: the target project's landing state. Through Plan A every # entry lands in the default project; M3-frontend routing carries a # non-default target later. - target_project = projects_mod.resolved_default_id(config) + target_project = project_id landing_state = "active" if projects_mod.project_initial_state(target_project) == "active" else "super-draft" entry = entry_mod.Entry( @@ -990,7 +1017,7 @@ def make_router( pr = await bot.open_idea_pr( user.as_actor(), org=config.gitea_org, - meta_repo=(projects_mod.default_content_repo(config) or ""), + meta_repo=(projects_mod.content_repo(project_id) or ""), slug=slug, file_contents=contents, pr_title=pr_title, @@ -1014,19 +1041,35 @@ def make_router( if use_case: db.conn().execute( """ - INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case) - VALUES ('rfc', ?, ?, ?) + INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case, project_id) + VALUES ('rfc', ?, ?, ?, ?) ON CONFLICT(project_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case """, - (slug, pr["number"], use_case), + (slug, pr["number"], use_case, project_id), ) db.conn().execute( - "UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ?", - (use_case, pr["number"]), + "UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ? AND project_id = ?", + (use_case, pr["number"], project_id), ) return {"pr_number": pr["number"], "slug": slug} + @router.post("/api/rfcs/propose") + async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]: + # Default-project compat path (pre-multi-project clients). + user = auth.require_contributor(request) + return await _propose_into_project(projects_mod.resolved_default_id(config), payload, user) + + @router.post("/api/projects/{project_id}/rfcs/propose") + async def propose_project_rfc( + project_id: str, payload: ProposeBody, request: Request + ) -> dict[str, Any]: + # §22.4: propose a new entry into a specific project (read-gated first + # so a gated project 404s a non-member before the contribute check). + user = auth.require_contributor(request) + auth.require_project_readable(user, project_id) + return await _propose_into_project(project_id, payload, user) + # --------------------------------------------------------------- # §9.1 Slice 2 (roadmap #27): Claude Haiku tag suggestions as the # propose-RFC fields fill in. The modal debounce-posts the partial diff --git a/backend/app/cache.py b/backend/app/cache.py index 57b1683..4af2ab2 100644 --- a/backend/app/cache.py +++ b/backend/app/cache.py @@ -468,20 +468,28 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None: login as last resort. """ org = config.gitea_org - repo = projects_mod.default_content_repo(config) - if not repo: - log.warning("refresh_meta_pulls: default project has no content_repo yet; skipping") + bot_login = config.gitea_bot_user + rows = db.conn().execute( + "SELECT id, content_repo FROM projects WHERE content_repo IS NOT NULL AND content_repo != ''" + ).fetchall() + if not rows: + log.warning("refresh_meta_pulls: no projects with a content_repo yet; skipping") return + for prow in rows: + await _refresh_project_pulls(org, prow["id"], prow["content_repo"], gitea, bot_login) + + +async def _refresh_project_pulls( + org: str, project_id: str, repo: str, gitea: Gitea, bot_login: str +) -> None: repo_full = f"{org}/{repo}" try: open_pulls = await gitea.list_pulls(org, repo, state="open") closed_pulls = await gitea.list_pulls(org, repo, state="closed") except GiteaError as e: - log.warning("refresh_meta_pulls: %s", e) + log.warning("refresh_meta_pulls: project %s: %s", project_id, e) return - bot_login = config.gitea_bot_user - for pull in open_pulls + closed_pulls: head_branch = pull.get("head", {}).get("ref", "") # A merged-and-deleted PR's branch is no longer reported by Gitea @@ -536,8 +544,8 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None: INSERT INTO cached_prs (rfc_slug, pr_kind, repo, pr_number, title, description, state, opened_by, opened_at, merged_at, closed_at, - head_branch, base_branch, head_sha, merge_commit_sha) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + head_branch, base_branch, head_sha, merge_commit_sha, project_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(repo, pr_number) DO UPDATE SET title = excluded.title, description = excluded.description, @@ -564,6 +572,7 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None: (pull.get("base") or {}).get("ref") or "main", (pull.get("head") or {}).get("sha"), merge_commit_sha, + project_id, ), ) diff --git a/backend/app/projects.py b/backend/app/projects.py index 4245939..4b99fc6 100644 --- a/backend/app/projects.py +++ b/backend/app/projects.py @@ -31,6 +31,15 @@ def default_content_repo(config: Config) -> str | None: return row["content_repo"] if row and row["content_repo"] else None +def content_repo(project_id: str) -> str | None: + """The content repo for a specific project (§22.3). None if unknown/unset. + The per-project successor to `default_content_repo` for the write path.""" + row = db.conn().execute( + "SELECT content_repo FROM projects WHERE id = ?", (project_id,) + ).fetchone() + return row["content_repo"] if row and row["content_repo"] else None + + def project_initial_state(project_id: str) -> str: """§22.4b landing state for new entries in a project. Defaults to 'super-draft' for an unknown/unset row (the safe, today's-flow default).""" diff --git a/backend/tests/test_project_scoped_propose.py b/backend/tests/test_project_scoped_propose.py new file mode 100644 index 0000000..251984c --- /dev/null +++ b/backend/tests/test_project_scoped_propose.py @@ -0,0 +1,64 @@ +"""§22.4 (Plan B write): proposing a new entry into a *specific* project lands +it in that project's content repo and surfaces under that project's proposals, +isolated from the default project.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from test_propose_vertical import ( # noqa: F401 + app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as, +) + + +def _register_ecomm(fake): + from app import db + db.conn().execute( + "INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) " + "VALUES ('ecomm', 'Ecomm', 'document', 'ecomm-content', 'public', 'super-draft')" + ) + fake._seed_repo("wiggleverse", "ecomm-content") + + +def test_propose_into_second_project_lands_scoped(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _register_ecomm(fake) + 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/ecomm/rfcs/propose", json={ + "title": "Cart", "slug": "cart", "pitch": "why a cart", "tags": [], + }) + assert r.status_code == 200, r.text + + # The idea PR shows under ecomm's proposals, not the default's. + e = {i["slug"] for i in client.get("/api/projects/ecomm/proposals").json()["items"]} + d = {i["slug"] for i in client.get("/api/projects/default/proposals").json()["items"]} + assert "cart" in e + assert "cart" not in d + + # It landed in ecomm's content repo, not the default 'meta' repo. + assert ("wiggleverse", "ecomm-content") in { + (o, rp) for (o, rp) in fake.branches if rp == "ecomm-content" + } + assert any( + br.startswith("propose/cart") + for br in fake.branches.get(("wiggleverse", "ecomm-content"), {}) + ) + + +def test_propose_into_gated_project_404s_for_non_member(app_with_fake_gitea): + app, _ = app_with_fake_gitea + from app import db + with TestClient(app) as client: + db.conn().execute( + "INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) " + "VALUES ('secret', 'Secret', 'document', 'secret-content', 'gated', 'super-draft')" + ) + provision_user_row(user_id=4, login="bob", role="contributor") + sign_in_as(client, user_id=4, gitea_login="bob", display_name="Bob", + role="contributor", email="bob@test") + r = client.post("/api/projects/secret/rfcs/propose", json={ + "title": "X", "slug": "x", "pitch": "p", "tags": [], + }) + assert r.status_code == 404 diff --git a/frontend/package.json b/frontend/package.json index 4312740..363dd52 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.37.0", + "version": "0.38.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b39c2f7..b9a0aed 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -61,6 +61,11 @@ export default function App() { // §22.9 — runtime deployment config (name for the brand, default project id // for building corpus links this slice; see DeploymentProvider). const deployment = useDeployment() + // §22.4 — the project the viewer is currently in (from the /p// URL), + // so the propose modal (App-level chrome, above the route tree) targets the + // right project. Falls back to the deployment default off a project route. + const _projMatch = location.pathname.match(/^\/p\/([^/]+)/) + const currentProjectId = (_projMatch && _projMatch[1]) || deployment.defaultProjectId // #28 Parts 2–3: the LinkedText create/contribute affordances route via // query params so they need no prop-threading from deep in a comment // list. `?propose=` opens the propose modal pre-filled; @@ -372,12 +377,13 @@ export default function App() { { setProposeOpen(false); clearParams('propose') }} onSubmitted={({ pr_number }) => { setProposeOpen(false) clearParams('propose') setCatalogVersion(v => v + 1) - navigate(proposalPath(deployment.defaultProjectId, pr_number)) + navigate(proposalPath(currentProjectId, pr_number)) }} /> )} diff --git a/frontend/src/api.js b/frontend/src/api.js index 19cc2cc..35978ca 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -198,16 +198,20 @@ export async function getRFC(projectId, slug) { return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`)) } -export async function listProposals() { - return jsonOrThrow(await fetch('/api/proposals')) +export async function listProposals(projectId) { + const url = projectId ? `/api/projects/${projectId}/proposals` : '/api/proposals' + return jsonOrThrow(await fetch(url)) } export async function getProposal(prNumber) { return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`)) } -export async function proposeRFC({ title, slug, pitch, tags, proposedUseCase }) { - const res = await fetch('/api/rfcs/propose', { +// §22.4 (Plan B write): propose into a specific project when projectId is +// given; else the default-project compat path. +export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase }) { + const url = projectId ? `/api/projects/${projectId}/rfcs/propose` : '/api/rfcs/propose' + const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, // #26: proposed_use_case is optional; send null when blank so the diff --git a/frontend/src/components/Catalog.jsx b/frontend/src/components/Catalog.jsx index daad784..24bc470 100644 --- a/frontend/src/components/Catalog.jsx +++ b/frontend/src/components/Catalog.jsx @@ -35,7 +35,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) { useEffect(() => { listRFCs(pid).then(d => setRfcs(d.items)).catch(() => setRfcs([])) - listProposals().then(d => setProposals(d.items)).catch(() => setProposals([])) + listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([])) }, [version, pid]) const filtered = useMemo(() => { diff --git a/frontend/src/components/ProposeModal.jsx b/frontend/src/components/ProposeModal.jsx index 24fa6ef..0989a89 100644 --- a/frontend/src/components/ProposeModal.jsx +++ b/frontend/src/components/ProposeModal.jsx @@ -28,7 +28,7 @@ function slugify(title) { .replace(/^-+|-+$/g, '') } -export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '' }) { +export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '', projectId }) { // #28 Part 2: a "create RFC for ''" affordance pre-fills the title // (App passes the `?propose=` value here); the slug derives from it // via the same effect that drives manual typing. @@ -92,7 +92,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl setSubmitting(true) setError(null) try { - const result = await proposeRFC({ + const result = await proposeRFC(projectId, { title: title.trim(), slug, pitch: pitch.trim(),