Merge pull request '§22 Plan B write path (propose): project-scoped propose (v0.38.0)' (#16) from feat/m3-planb-write into main

This commit is contained in:
Ben Stull
2026-06-04 14:02:29 +00:00
11 changed files with 205 additions and 34 deletions
+36
View File
@@ -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 release's steps in order — no A-to-B path is pre-computed beyond
that. 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/<id>/` 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 ## 0.37.0 — 2026-06-04
**Minor — §22 M3-backend Plan B (2/2, read path): per-project RFC serving. A **Minor — §22 M3-backend Plan B (2/2, read path): per-project RFC serving. A
+1 -1
View File
@@ -1 +1 @@
0.37.0 0.38.0
+59 -16
View File
@@ -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}") @router.get("/api/proposals/{pr_number}")
async def get_proposal(pr_number: int, request: Request) -> dict[str, Any]: async def get_proposal(pr_number: int, request: Request) -> dict[str, Any]:
"""§9.3 pending-idea view data. """§9.3 pending-idea view data.
@@ -922,14 +951,11 @@ def make_router(
# §9.1: propose a new RFC # §9.1: propose a new RFC
# --------------------------------------------------------------- # ---------------------------------------------------------------
@router.post("/api/rfcs/propose") async def _propose_into_project(project_id: str, payload: ProposeBody, user) -> dict[str, Any]:
async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
# §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. Through M2 every entry lands in the default project; M3's # standing in the *target* project. On the public default project the
# routing carries 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.
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") raise HTTPException(403, "You do not have contribute access to this project")
slug = payload.slug.strip().lower() slug = payload.slug.strip().lower()
if not entry_mod.is_valid_slug(slug): if not entry_mod.is_valid_slug(slug):
@@ -940,13 +966,14 @@ def make_router(
# 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.
clash = db.conn().execute( 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() ).fetchone()
if clash: if clash:
raise HTTPException(409, f"Slug `{slug}` is already taken") raise HTTPException(409, f"Slug `{slug}` is already taken")
idea_clash = db.conn().execute( idea_clash = db.conn().execute(
"SELECT 1 FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' AND rfc_slug = ?", "SELECT 1 FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' "
(slug,), "AND rfc_slug = ? AND project_id = ?",
(slug, project_id),
).fetchone() ).fetchone()
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")
@@ -954,7 +981,7 @@ def make_router(
# §22.4b: the target project's landing state. Through Plan A every # §22.4b: the target project's landing state. Through Plan A every
# entry lands in the default project; M3-frontend routing carries a # entry lands in the default project; M3-frontend routing carries a
# non-default target later. # 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" landing_state = "active" if projects_mod.project_initial_state(target_project) == "active" else "super-draft"
entry = entry_mod.Entry( entry = entry_mod.Entry(
@@ -990,7 +1017,7 @@ def make_router(
pr = await bot.open_idea_pr( pr = await bot.open_idea_pr(
user.as_actor(), user.as_actor(),
org=config.gitea_org, org=config.gitea_org,
meta_repo=(projects_mod.default_content_repo(config) or ""), meta_repo=(projects_mod.content_repo(project_id) or ""),
slug=slug, slug=slug,
file_contents=contents, file_contents=contents,
pr_title=pr_title, pr_title=pr_title,
@@ -1014,19 +1041,35 @@ def make_router(
if use_case: if use_case:
db.conn().execute( db.conn().execute(
""" """
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case) INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case, project_id)
VALUES ('rfc', ?, ?, ?) VALUES ('rfc', ?, ?, ?, ?)
ON CONFLICT(project_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case 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( db.conn().execute(
"UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ?", "UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ? AND project_id = ?",
(use_case, pr["number"]), (use_case, pr["number"], project_id),
) )
return {"pr_number": pr["number"], "slug": slug} 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 # §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
+17 -8
View File
@@ -468,20 +468,28 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
login as last resort. login as last resort.
""" """
org = config.gitea_org org = config.gitea_org
repo = projects_mod.default_content_repo(config) bot_login = config.gitea_bot_user
if not repo: rows = db.conn().execute(
log.warning("refresh_meta_pulls: default project has no content_repo yet; skipping") "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 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}" repo_full = f"{org}/{repo}"
try: try:
open_pulls = await gitea.list_pulls(org, repo, state="open") open_pulls = await gitea.list_pulls(org, repo, state="open")
closed_pulls = await gitea.list_pulls(org, repo, state="closed") closed_pulls = await gitea.list_pulls(org, repo, state="closed")
except GiteaError as e: except GiteaError as e:
log.warning("refresh_meta_pulls: %s", e) log.warning("refresh_meta_pulls: project %s: %s", project_id, e)
return return
bot_login = config.gitea_bot_user
for pull in open_pulls + closed_pulls: for pull in open_pulls + closed_pulls:
head_branch = pull.get("head", {}).get("ref", "") head_branch = pull.get("head", {}).get("ref", "")
# A merged-and-deleted PR's branch is no longer reported by Gitea # 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 INSERT INTO cached_prs
(rfc_slug, pr_kind, repo, pr_number, title, description, state, (rfc_slug, pr_kind, repo, pr_number, title, description, state,
opened_by, opened_at, merged_at, closed_at, opened_by, opened_at, merged_at, closed_at,
head_branch, base_branch, head_sha, merge_commit_sha) head_branch, base_branch, head_sha, merge_commit_sha, project_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(repo, pr_number) DO UPDATE SET ON CONFLICT(repo, pr_number) DO UPDATE SET
title = excluded.title, title = excluded.title,
description = excluded.description, 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("base") or {}).get("ref") or "main",
(pull.get("head") or {}).get("sha"), (pull.get("head") or {}).get("sha"),
merge_commit_sha, merge_commit_sha,
project_id,
), ),
) )
+9
View File
@@ -31,6 +31,15 @@ def default_content_repo(config: Config) -> str | None:
return row["content_repo"] if row and row["content_repo"] else 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: def project_initial_state(project_id: str) -> str:
"""§22.4b landing state for new entries in a project. Defaults to """§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).""" 'super-draft' for an unknown/unset row (the safe, today's-flow default)."""
@@ -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
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "rfc-app-frontend", "name": "rfc-app-frontend",
"private": true, "private": true,
"version": "0.37.0", "version": "0.38.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+7 -1
View File
@@ -61,6 +61,11 @@ export default function App() {
// §22.9 runtime deployment config (name for the brand, default project id // §22.9 runtime deployment config (name for the brand, default project id
// for building corpus links this slice; see DeploymentProvider). // for building corpus links this slice; see DeploymentProvider).
const deployment = useDeployment() const deployment = useDeployment()
// §22.4 the project the viewer is currently in (from the /p/<id>/ 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 23: the LinkedText create/contribute affordances route via // #28 Parts 23: 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;
@@ -372,12 +377,13 @@ export default function App() {
<ProposeModal <ProposeModal
viewer={viewer} viewer={viewer}
initialTitle={proposeParam || ''} initialTitle={proposeParam || ''}
projectId={currentProjectId}
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(deployment.defaultProjectId, pr_number)) navigate(proposalPath(currentProjectId, pr_number))
}} }}
/> />
)} )}
+8 -4
View File
@@ -198,16 +198,20 @@ export async function getRFC(projectId, slug) {
return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`)) return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`))
} }
export async function listProposals() { export async function listProposals(projectId) {
return jsonOrThrow(await fetch('/api/proposals')) const url = projectId ? `/api/projects/${projectId}/proposals` : '/api/proposals'
return jsonOrThrow(await fetch(url))
} }
export async function getProposal(prNumber) { export async function getProposal(prNumber) {
return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`)) return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`))
} }
export async function proposeRFC({ title, slug, pitch, tags, proposedUseCase }) { // §22.4 (Plan B write): propose into a specific project when projectId is
const res = await fetch('/api/rfcs/propose', { // 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
// #26: proposed_use_case is optional; send null when blank so the // #26: proposed_use_case is optional; send null when blank so the
+1 -1
View File
@@ -35,7 +35,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
useEffect(() => { useEffect(() => {
listRFCs(pid).then(d => setRfcs(d.items)).catch(() => setRfcs([])) 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]) }, [version, pid])
const filtered = useMemo(() => { const filtered = useMemo(() => {
+2 -2
View File
@@ -28,7 +28,7 @@ function slugify(title) {
.replace(/^-+|-+$/g, '') .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 '<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.
@@ -92,7 +92,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
setSubmitting(true) setSubmitting(true)
setError(null) setError(null)
try { try {
const result = await proposeRFC({ const result = await proposeRFC(projectId, {
title: title.trim(), title: title.trim(),
slug, slug,
pitch: pitch.trim(), pitch: pitch.trim(),