Merge feat/multi-project (§22 M1+M2) into main

Integrates the multi-project work so far — the §22 design drafts, M1 (the
schema spine), and M2 (project-scoped authorization + the §22.7 resolver) —
on top of the v0.32.0 retire/§13 changes that landed on main meanwhile.

Integration decisions:
- Renumbered the M1 projects migration 025_projects.sql -> 026_projects.sql.
  v0.32.0 shipped 025_retired_state.sql, which rebuilds cached_rfcs and
  predates the project_id column; running projects *after* the retire rebuild
  is required so project_id survives on a fresh database (the runner applies
  *.sql in filename order). Comment/doc references bumped to match.
- Resolved the get_rfc conflict in api.py by composing both gates: compute the
  viewer once, apply the §22.5 visibility gate, then v0.32.0's §13.7
  retired-entry owner-only check.
- api_graduation.py / api_discussion.py auto-merged cleanly (M2's threaded
  viewer/visibility gate coexists with the new retire endpoints + retired
  state).

Full suite 401 passed on a fresh DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-03 04:56:39 -07:00
18 changed files with 1967 additions and 114 deletions
+16 -7
View File
@@ -199,7 +199,7 @@ def make_router(
@router.get("/api/rfcs/{slug}/blocking-prs")
async def list_blocking_prs(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_super_draft(slug)
rfc = _require_super_draft(slug, viewer)
rows = db.conn().execute(
"""
SELECT pr_number, title, opened_by, opened_at, head_branch, pr_kind
@@ -218,7 +218,7 @@ def make_router(
can_merge = (
viewer is not None
and (
viewer.role in ("owner", "admin")
auth.is_project_superuser(viewer, rfc["project_id"])
or viewer.gitea_login in owners
or viewer.gitea_login in arbiters
)
@@ -258,7 +258,7 @@ def make_router(
slug: str, request: Request,
) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_super_draft(slug)
rfc = _require_super_draft(slug, viewer)
del viewer # no permission gate — the dialog only shows up for
# admins/owners, but the check itself is read-only.
@@ -313,7 +313,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/graduate")
async def graduate(slug: str, body: GraduateBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug)
rfc = _require_super_draft(slug, viewer)
# §13: only owners/arbiters of the RFC and app admins/owners may
# graduate. Until §13.1's claim runs the entry has no owners, so
# the set collapses to app admins/owners for unclaimed entries.
@@ -458,7 +458,13 @@ def make_router(
@router.post("/api/rfcs/{slug}/claim")
async def claim_ownership(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug)
rfc = _require_super_draft(slug, viewer)
# §22.6/§22.7: claiming an unclaimed super-draft is a contribute action.
# On a public project this is the pre-M2 baseline (an unclaimed entry
# has no owners, so any granted contributor qualifies); on a gated
# project it requires a project_contributor/admin grant.
if not auth.can_contribute_to_rfc(viewer, slug):
raise HTTPException(403, "You do not have contribute access to this project")
existing_owners = json.loads(rfc["owners_json"] or "[]")
if viewer.gitea_login in existing_owners:
return {"ok": True, "noop": True}
@@ -567,10 +573,12 @@ def make_router(
# Helpers
# -------------------------------------------------------------------
def _require_super_draft(slug: str):
def _require_super_draft(slug: str, viewer):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive, §22.7): gated → 404 to non-members.
auth.require_project_readable(viewer, row["project_id"])
if row["state"] != "super-draft":
raise HTTPException(409, f"RFC is {row['state']}, not super-draft")
return row
@@ -786,7 +794,8 @@ async def _finish_failed(state: GraduationState, *, failed_at: str, on_behalf_of
def _can_graduate(rfc, viewer) -> bool:
if viewer is None:
return False
if viewer.role in ("owner", "admin"):
# §6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters.
if auth.is_project_superuser(viewer, rfc["project_id"]):
return True
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")