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:
@@ -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
|
||||||
|
|||||||
+59
-16
@@ -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
@@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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 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;
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user