§13: make graduation's RFC number optional; add retire (soft delete)
Optional number (§13.2/§13.3): GraduateBody.rfc_id is now optional.
A blank/absent id graduates to `active` with `id: null`, leaving the
slug as the canonical identifier (§2.3). /graduate/check treats a blank
id as valid (ok:true); /graduate only validates the RFC-NNNN regex +
collision check when a number is supplied. The Graduate dialog allows an
empty number and renders number-less entries by slug (no RFC-undefined).
Retire (§3, §3.1, §13.7): new `retired` soft-delete state. An RFC's own
owners (frontmatter) and site `owner`-role holders — not app admins —
retire via POST /api/rfcs/<slug>/retire, an auto-merged meta-repo flip
PR (graduation machinery reused). Retired entries leave every browsing
surface: catalog, get_rfc (404 except site owner), discussion/branch
reads. Un-retire is site-owner-only (POST .../unretire), discoverable
via the owner-gated GET /api/admin/retired-rfcs ("Retired" admin tab).
Migration 025 widens the cached_rfcs.state CHECK to include 'retired'
(table rebuild, all columns preserved).
Tests: graduation no-number cases (active+null id by slug; check accepts
blank) added to test_graduation_vertical.py; new test_retire_vertical.py
covers perms (owner/site-owner allowed, admin/contributor 403),
catalog/read exclusion, and a graduate→retire→unretire round-trip. Full
backend suite 386 passing; frontend builds clean. SPEC §3/§3.1/§13
updated; CHANGELOG + VERSION → 0.33.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -514,6 +514,120 @@ def test_edit_branch_surfaces_normally_after_graduation(app_with_fake_gitea):
|
||||
f"edit branch not in branches: {[b['name'] for b in d['branches']]}"
|
||||
|
||||
|
||||
def test_graduate_check_accepts_blank_id(app_with_fake_gitea):
|
||||
"""§13.2 (optional number): a blank id is VALID — it means "graduate
|
||||
without a number." `can_submit` stays true (owners are set), and an
|
||||
absent `id` param behaves the same as an explicit empty string."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Explicit empty id.
|
||||
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": ""}).json()
|
||||
assert d["id"]["ok"] is True
|
||||
assert d["id"]["error"] is None
|
||||
assert d["can_submit"] is True
|
||||
|
||||
# No id param at all — same default-accepted shape.
|
||||
d = client.get("/api/rfcs/ohm/graduate/check").json()
|
||||
assert d["id"]["ok"] is True
|
||||
assert d["can_submit"] is True
|
||||
|
||||
# A malformed (non-blank) id is still rejected.
|
||||
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-xx"}).json()
|
||||
assert d["id"]["ok"] is False
|
||||
assert d["can_submit"] is False
|
||||
|
||||
|
||||
def test_graduate_without_number_flips_to_active_null_id_by_slug(app_with_fake_gitea):
|
||||
"""§13.2/§13.3 (optional number): graduating with a blank id flips the
|
||||
entry to `active` with `id: null`. The slug is the canonical identifier;
|
||||
the catalog shows the entry as active with no number, and the audit row
|
||||
records rfc_id null."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, entry as entry_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="Open Human Model",
|
||||
pitch=PITCH, owners=["ben"], arbiters=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner", email="ben@test")
|
||||
|
||||
# Blank rfc_id → graduate without a number.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["succeeded"] is True
|
||||
assert d["rfc_id"] is None
|
||||
|
||||
# Meta entry: active, id null, body kept, graduation stamped.
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
graduated = entry_mod.parse(meta_text)
|
||||
assert graduated.state == "active"
|
||||
assert graduated.id is None
|
||||
assert graduated.graduated_by == "ben"
|
||||
assert graduated.graduated_at
|
||||
assert "Open Human Model is a framework" in graduated.body
|
||||
|
||||
# Cache flipped to active with a null rfc_id.
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "active"
|
||||
assert cached["rfc_id"] is None
|
||||
|
||||
# Catalog: present as active, identified by slug (no number).
|
||||
items = client.get("/api/rfcs").json()["items"]
|
||||
ohm = next(i for i in items if i["slug"] == "ohm")
|
||||
assert ohm["state"] == "active"
|
||||
assert ohm["id"] is None
|
||||
|
||||
# Audit: graduate_complete with rfc_id null.
|
||||
complete = db.conn().execute(
|
||||
"""
|
||||
SELECT details FROM actions
|
||||
WHERE rfc_slug = 'ohm' AND action_kind = 'graduate_complete'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
"""
|
||||
).fetchone()
|
||||
assert complete is not None
|
||||
assert _json.loads(complete["details"])["rfc_id"] is None
|
||||
|
||||
|
||||
def test_graduate_with_number_unchanged_when_id_absent_field(app_with_fake_gitea):
|
||||
"""Omitting the rfc_id field entirely is treated the same as blank —
|
||||
graduates without a number — so older clients that drop the field don't
|
||||
break, and the supplied-number path stays exactly as before."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import entry as entry_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
r = client.post("/api/rfcs/ohm/graduate?_sync=1", json={"owners": ["ben"]})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["rfc_id"] is None
|
||||
graduated = entry_mod.parse(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
)
|
||||
assert graduated.state == "active"
|
||||
assert graduated.id is None
|
||||
|
||||
|
||||
def test_claim_opens_meta_pr(app_with_fake_gitea):
|
||||
"""§13.1: any signed-in contributor can claim ownership of an
|
||||
unclaimed super-draft; the result is a meta-repo PR
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""End-to-end integration tests for the §13.7 retire (soft-delete) flow.
|
||||
|
||||
Retire is an in-place frontmatter flip on the meta entry (state →
|
||||
`retired`) committed via an auto-merged PR — the same machinery as
|
||||
graduation, reused. The distinguishing rules under test:
|
||||
|
||||
* Authority (§3.1): RFC owners (frontmatter) and site `owner`-role
|
||||
holders may retire; app admins may NOT. Un-retire is site-owners-only.
|
||||
* Visibility (§13.7): a retired entry drops out of the catalog
|
||||
(`GET /api/rfcs`), and `GET /api/rfcs/<slug>` 404s for everyone except
|
||||
a site owner (so the un-retire affordance has a surface).
|
||||
* Reversibility: a site owner can un-retire, restoring the prior state
|
||||
(and keeping the integer id intact); an RFC owner cannot.
|
||||
|
||||
These walk against the in-process FakeGitea from test_propose_vertical.py,
|
||||
reusing the super-draft seed + sync graduation seam from the graduation
|
||||
suite.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
grant_rfc_collaborator,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_super_draft_vertical import seed_super_draft # noqa: F401
|
||||
from test_graduation_vertical import PITCH, seed_owned_super_draft # noqa: F401
|
||||
|
||||
|
||||
def _catalog_slugs(client) -> set[str]:
|
||||
return {i["slug"] for i in client.get("/api/rfcs").json()["items"]}
|
||||
|
||||
|
||||
def test_rfc_owner_can_retire_and_entry_leaves_every_surface(app_with_fake_gitea):
|
||||
"""An RFC owner (frontmatter, role contributor) retires their own
|
||||
super-draft. The entry flips to `retired`, drops out of the catalog,
|
||||
and `GET /api/rfcs/<slug>` 404s for them (they are not a site owner)."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, entry as entry_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"], arbiters=["carol"])
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
|
||||
assert "ohm" in _catalog_slugs(client)
|
||||
|
||||
r = client.post("/api/rfcs/ohm/retire")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["state"] == "retired"
|
||||
|
||||
# Meta entry on main: state retired, body + fields kept.
|
||||
meta = entry_mod.parse(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
)
|
||||
assert meta.state == "retired"
|
||||
assert "carol" in meta.owners
|
||||
|
||||
# Cache flipped; gone from the catalog.
|
||||
cached = db.conn().execute(
|
||||
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "retired"
|
||||
assert "ohm" not in _catalog_slugs(client)
|
||||
|
||||
# The RFC owner is NOT a site owner → 404 on the entry read.
|
||||
assert client.get("/api/rfcs/ohm").status_code == 404
|
||||
|
||||
# Audit row records the prior state for un-retire.
|
||||
row = db.conn().execute(
|
||||
"SELECT details FROM actions WHERE rfc_slug='ohm' AND action_kind='retire' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert _json.loads(row["details"])["prior_state"] == "super-draft"
|
||||
|
||||
|
||||
def test_site_owner_sees_retired_entry_but_admin_and_others_404(app_with_fake_gitea):
|
||||
"""`GET /api/rfcs/<slug>` for a retired entry: site owner gets 200
|
||||
(so the un-retire UI has a surface); an admin, a non-owner contributor,
|
||||
and an anonymous viewer all get 404."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
provision_user_row(user_id=3, login="dave", role="admin")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
assert client.post("/api/rfcs/ohm/retire").status_code == 200
|
||||
|
||||
# Site owner: 200.
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/rfcs/ohm")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["state"] == "retired"
|
||||
|
||||
# Admin (not site owner): 404.
|
||||
sign_in_as(client, user_id=3, gitea_login="dave",
|
||||
display_name="Dave", role="admin")
|
||||
assert client.get("/api/rfcs/ohm").status_code == 404
|
||||
|
||||
# Non-owner contributor: 404.
|
||||
provision_user_row(user_id=4, login="erin", role="contributor")
|
||||
sign_in_as(client, user_id=4, gitea_login="erin",
|
||||
display_name="Erin", role="contributor")
|
||||
assert client.get("/api/rfcs/ohm").status_code == 404
|
||||
|
||||
|
||||
def test_admin_cannot_retire(app_with_fake_gitea):
|
||||
"""§3.1: retire authority excludes app admins. An admin who is not an
|
||||
RFC owner gets 403 — the one lifecycle action where admin authority
|
||||
does not apply."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=3, login="dave", role="admin")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
sign_in_as(client, user_id=3, gitea_login="dave",
|
||||
display_name="Dave", role="admin")
|
||||
r = client.post("/api/rfcs/ohm/retire")
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
|
||||
def test_non_owner_contributor_cannot_retire(app_with_fake_gitea):
|
||||
"""A signed-in contributor who is not an RFC owner gets 403."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=4, login="erin", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
sign_in_as(client, user_id=4, gitea_login="erin",
|
||||
display_name="Erin", role="contributor")
|
||||
assert client.post("/api/rfcs/ohm/retire").status_code == 403
|
||||
|
||||
|
||||
def test_site_owner_can_retire_active_and_unretire_restores_active_with_id(app_with_fake_gitea):
|
||||
"""Round-trip on an active RFC: graduate (with a number) → retire →
|
||||
un-retire. The integer id survives, and un-retire restores `active`."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, entry as entry_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"], arbiters=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner", email="ben@test")
|
||||
|
||||
# Graduate with a number.
|
||||
assert client.post("/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0042", "owners": ["ben"]}).status_code == 200
|
||||
|
||||
# Retire (site owner).
|
||||
assert client.post("/api/rfcs/ohm/retire").json()["state"] == "retired"
|
||||
assert "ohm" not in _catalog_slugs(client)
|
||||
|
||||
# Un-retire (site owner) restores active, id intact.
|
||||
r = client.post("/api/rfcs/ohm/unretire")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["state"] == "active"
|
||||
|
||||
meta = entry_mod.parse(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
)
|
||||
assert meta.state == "active"
|
||||
assert meta.id == "RFC-0042"
|
||||
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "active"
|
||||
assert cached["rfc_id"] == "RFC-0042"
|
||||
assert "ohm" in _catalog_slugs(client)
|
||||
|
||||
|
||||
def test_rfc_owner_cannot_unretire(app_with_fake_gitea):
|
||||
"""Un-retire is site-owner-only: an RFC owner who could retire cannot
|
||||
bring it back (the soft-delete is recoverable only by the operator)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
assert client.post("/api/rfcs/ohm/retire").status_code == 200
|
||||
# Same RFC owner tries to un-retire → 403.
|
||||
assert client.post("/api/rfcs/ohm/unretire").status_code == 403
|
||||
|
||||
|
||||
def test_admin_retired_list_is_site_owner_only(app_with_fake_gitea):
|
||||
"""GET /api/admin/retired-rfcs lists retired entries for a site owner;
|
||||
an admin (who lacks un-retire authority) gets 403."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
provision_user_row(user_id=3, login="dave", role="admin")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
assert client.post("/api/rfcs/ohm/retire").status_code == 200
|
||||
|
||||
# Admin: 403.
|
||||
sign_in_as(client, user_id=3, gitea_login="dave",
|
||||
display_name="Dave", role="admin")
|
||||
assert client.get("/api/admin/retired-rfcs").status_code == 403
|
||||
|
||||
# Site owner: 200, sees ohm with its restore target.
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/admin/retired-rfcs")
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
ohm = next(i for i in items if i["slug"] == "ohm")
|
||||
assert ohm["restores_to"] == "super-draft"
|
||||
|
||||
|
||||
def test_retired_entry_refuses_discussion_reads(app_with_fake_gitea):
|
||||
"""A retired entry refuses content reads of every shape (§13.7) — the
|
||||
discussion/branch surfaces 404/409 rather than serve a soft-deleted RFC."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
assert client.post("/api/rfcs/ohm/retire").status_code == 200
|
||||
|
||||
# The /main branch surface no longer serves it.
|
||||
assert client.get("/api/rfcs/ohm/main").status_code in (404, 409)
|
||||
Reference in New Issue
Block a user