§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>
This commit is contained in:
Ben Stull
2026-06-04 07:02:19 -07:00
parent 455ef33b29
commit fec51bdbb6
11 changed files with 205 additions and 34 deletions
+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,
),
)
+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
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)."""