Compare commits

...

4 Commits

Author SHA1 Message Date
Ben Stull f758fe072f Merge pull request '§22.13 step 1: default-project-id re-stamp (v0.39.0)' (#17) from feat/m3-planb-restamp into main 2026-06-04 14:45:21 +00:00
Ben Stull 33c67ccc09 §22.13 step 1: default-project-id re-stamp (v0.39.0)
projects.restamp_default_project(config): at startup after the registry mirror,
renames project_id from the M1 bootstrap 'default' to the configured
DEFAULT_PROJECT_ID across every project-scoped table (discovered by column) and
drops the stale 'default' projects row, so a deployment's original corpus lands
at a meaningful /p/<id>/ and 'default' is never a public URL. FK off for the
rename (parent+children move together) + foreign_key_check backstop. Idempotent;
no-op unless DEFAULT_PROJECT_ID is set to a non-'default' value.

test_restamp_default_project.py (3 tests). 450 backend green.

This is the last framework piece for OHM's clean /p/ohm/ cutover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:45:10 -07:00
Ben Stull 508a8cb6d0 Merge pull request '§22 Plan B write path (propose): project-scoped propose (v0.38.0)' (#16) from feat/m3-planb-write into main 2026-06-04 14:02:29 +00:00
Ben Stull fec51bdbb6 §22 M3-backend Plan B (write path, propose): project-scoped propose (v0.38.0)
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/<id>/ 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) <noreply@anthropic.com>
2026-06-04 07:02:19 -07:00
13 changed files with 373 additions and 34 deletions
+66
View File
@@ -23,6 +23,72 @@ 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.39.0 — 2026-06-04
**Minor — §22.13 step 1: the default-project-id re-stamp. A deployment can
move its original corpus off the bootstrap `default` id onto a meaningful slug
(e.g. `ohm`) so it lands at `/p/<id>/` and `default` is never a public URL.
No-op unless `DEFAULT_PROJECT_ID` is set to a non-`default` value.**
Added:
- **`projects.restamp_default_project(config)`** — at startup, after the
registry mirror, if `DEFAULT_PROJECT_ID` resolves to a non-`default` id and
bootstrap-stamped rows still exist, it renames `project_id` from `default` to
the configured id across **every** project-scoped table (discovered by
column, so it stays correct as the schema grows) and drops the stale
`default` `projects` row (its data has moved to the configured row the
registry mirror created). The rename runs with FK enforcement off — parent
and child rows move together, so the composite FKs stay consistent — with a
`foreign_key_check` backstop before commit. Idempotent.
- **Tests:** `test_restamp_default_project.py` — data + composite-FK children
move to the new id, the stale row is dropped, FK integrity holds, the second
call is a no-op, and an unset `DEFAULT_PROJECT_ID` leaves `default` in place.
450 backend green.
Upgrade steps:
1. **MAY** set `DEFAULT_PROJECT_ID=<slug>` in the backend overlay and add the
matching project (same `id`) to `projects.yaml`. On the next deploy the
re-stamp moves the original corpus onto `<slug>` once; `default` URLs never
become public. Leave it unset to keep the `default` id (no change).
## 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
**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.39.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}")
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
+17 -8
View File
@@ -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,
),
)
+5
View File
@@ -112,6 +112,11 @@ async def lifespan(app: FastAPI):
raise RuntimeError(
f"registry mirror failed at startup ({config.registry_repo_full}/projects.yaml): {e}"
) from e
# §22.13 step 1: re-stamp the M1 bootstrap 'default' project id to the
# deployment's configured id (DEFAULT_PROJECT_ID) once the registry row
# exists, so the original corpus lands at a meaningful /p/<id>/ and
# 'default' is never a public URL. Idempotent no-op once done.
projects.restamp_default_project(config)
if projects.default_content_repo(config) is None:
raise RuntimeError(
f"registry does not describe the default project "
+78
View File
@@ -7,9 +7,13 @@ the registry mirror (`registry.refresh_registry`) is authoritative.
"""
from __future__ import annotations
import logging
from . import db
from .config import Config
log = logging.getLogger(__name__)
DEFAULT_PROJECT_ID = "default"
@@ -20,6 +24,71 @@ def resolved_default_id(config: Config) -> str:
return config.default_project_id.strip() or DEFAULT_PROJECT_ID
def restamp_default_project(config: Config) -> None:
"""§22.13 step 1 — one-time rename of the M1 bootstrap project id
(DEFAULT_PROJECT_ID = 'default') to the deployment's configured default id
(the DEFAULT_PROJECT_ID env var, e.g. 'ohm'), so the deployment's original
corpus lands at a meaningful `/p/<id>/` and `default` is never a public URL.
Renames `project_id` across every project-scoped table (discovered by
column, so it stays correct as the schema grows), then drops the stale
bootstrap `projects` row (its data has moved to the configured row, which
the registry mirror already created). Idempotent and a no-op when the
configured id is still 'default' or no bootstrap rows remain. Runs at
startup after the registry mirror, with FK enforcement off for the rename
(the composite FKs are kept consistent because parent and child rows are
renamed together) and a foreign_key_check backstop before commit.
"""
target = resolved_default_id(config)
if target == DEFAULT_PROJECT_ID:
return
conn = db.conn()
has_rows = conn.execute(
"SELECT 1 FROM cached_rfcs WHERE project_id = ? LIMIT 1", (DEFAULT_PROJECT_ID,)
).fetchone()
stale_proj = conn.execute(
"SELECT 1 FROM projects WHERE id = ? LIMIT 1", (DEFAULT_PROJECT_ID,)
).fetchone()
if not has_rows and not stale_proj:
return
if conn.execute("SELECT 1 FROM projects WHERE id = ? LIMIT 1", (target,)).fetchone() is None:
log.warning("restamp: target project %r not in registry yet; skipping", target)
return
tables = [r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]
pid_tables = [
t for t in tables
if any(c["name"] == "project_id" for c in conn.execute(f"PRAGMA table_info({t})"))
]
conn.execute("PRAGMA foreign_keys = OFF")
try:
conn.execute("BEGIN")
for t in pid_tables:
conn.execute(
f"UPDATE {t} SET project_id = ? WHERE project_id = ?",
(target, DEFAULT_PROJECT_ID),
)
# The bootstrap row's data has moved to the configured (registry) row.
conn.execute("DELETE FROM projects WHERE id = ?", (DEFAULT_PROJECT_ID,))
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
if violations:
conn.execute("ROLLBACK")
raise RuntimeError(
f"restamp left foreign-key violations: {[tuple(v) for v in violations]}"
)
conn.execute("COMMIT")
except Exception:
try:
conn.execute("ROLLBACK")
except Exception:
pass
raise
finally:
conn.execute("PRAGMA foreign_keys = ON")
log.info("restamp: renamed bootstrap project %r -> %r across %d tables",
DEFAULT_PROJECT_ID, target, len(pid_tables))
def default_content_repo(config: Config) -> str | None:
"""The content repo the single-corpus mirror reads, from the default
project's row (filled by the registry mirror). Replaces the retired
@@ -31,6 +100,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)."""
@@ -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
@@ -0,0 +1,64 @@
"""§22.13 step 1 — the bootstrap-id re-stamp: 'default' → the configured
DEFAULT_PROJECT_ID across every project-scoped table, with the composite FKs
kept intact and the stale 'default' projects row dropped. Idempotent."""
from __future__ import annotations
import tempfile
from pathlib import Path
import app.db as db
from app import projects
class _Cfg:
def __init__(self, path, default_id):
self.database_path = path
self.default_project_id = default_id
def _setup(monkeypatch, default_id="ohm"):
path = str(Path(tempfile.mkdtemp()) / "t.db")
cfg = _Cfg(path, default_id)
db.run_migrations(cfg)
monkeypatch.setattr(db, "_CONN", db.connect(path))
conn = db.conn()
# M1 bootstrap row + a registry-mirrored 'ohm' row coexist pre-restamp.
conn.execute("INSERT OR IGNORE INTO projects (id,name,type,content_repo,visibility,initial_state) "
"VALUES ('default','Bootstrap','document','ohm-content','public','super-draft')")
conn.execute("INSERT OR IGNORE INTO projects (id,name,type,content_repo,visibility,initial_state) "
"VALUES ('ohm','Open Human Model','document','ohm-content','public','super-draft')")
conn.execute("INSERT INTO users (id,gitea_login,display_name,role) VALUES (1,'a','A','contributor')")
# default-stamped data with a composite-FK child
conn.execute("INSERT INTO cached_rfcs (slug,title,state,project_id) VALUES ('human','Human','active','default')")
conn.execute("INSERT INTO rfc_collaborators (rfc_slug,user_id,role_in_rfc,project_id) "
"VALUES ('human',1,'contributor','default')")
conn.execute("INSERT INTO stars (user_id,rfc_slug,project_id) VALUES (1,'human','default')")
return cfg, conn
def test_restamp_moves_data_and_drops_bootstrap_row(monkeypatch):
cfg, conn = _setup(monkeypatch, default_id="ohm")
projects.restamp_default_project(cfg)
assert conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE project_id='default'").fetchone()["c"] == 0
assert conn.execute("SELECT project_id FROM cached_rfcs WHERE slug='human'").fetchone()["project_id"] == "ohm"
assert conn.execute("SELECT project_id FROM rfc_collaborators WHERE rfc_slug='human'").fetchone()["project_id"] == "ohm"
assert conn.execute("SELECT project_id FROM stars WHERE rfc_slug='human'").fetchone()["project_id"] == "ohm"
# stale bootstrap projects row removed; 'ohm' remains
assert conn.execute("SELECT 1 FROM projects WHERE id='default'").fetchone() is None
assert conn.execute("SELECT 1 FROM projects WHERE id='ohm'").fetchone() is not None
# FK integrity intact after the rename
assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
def test_restamp_is_idempotent(monkeypatch):
cfg, conn = _setup(monkeypatch, default_id="ohm")
projects.restamp_default_project(cfg)
projects.restamp_default_project(cfg) # second call: no rows left → no-op
assert conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE project_id='ohm'").fetchone()["c"] == 1
def test_restamp_noop_when_default_id_unchanged(monkeypatch):
cfg, conn = _setup(monkeypatch, default_id="") # resolves to 'default'
projects.restamp_default_project(cfg)
# nothing renamed; bootstrap data + row still present
assert conn.execute("SELECT project_id FROM cached_rfcs WHERE slug='human'").fetchone()["project_id"] == "default"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.37.0",
"version": "0.39.0",
"type": "module",
"scripts": {
"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
// for building corpus links this slice; see DeploymentProvider).
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
// query params so they need no prop-threading from deep in a comment
// list. `?propose=<term>` opens the propose modal pre-filled;
@@ -372,12 +377,13 @@ export default function App() {
<ProposeModal
viewer={viewer}
initialTitle={proposeParam || ''}
projectId={currentProjectId}
onClose={() => { 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))
}}
/>
)}
+8 -4
View File
@@ -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
+1 -1
View File
@@ -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(() => {
+2 -2
View File
@@ -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 '<term>'" affordance pre-fills the title
// (App passes the `?propose=<term>` 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(),