§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:
Ben Stull
2026-06-01 17:22:49 -07:00
parent 0062510a4e
commit 49ba06e0c2
18 changed files with 1165 additions and 66 deletions
+113 -11
View File
@@ -706,22 +706,24 @@ class Bot:
slug: str,
new_file_contents: str,
prior_sha: str,
rfc_id: str,
rfc_id: str | None,
owners: list[str],
) -> dict:
"""§13.3 (meta-only): open a PR against the meta repo that flips the
entry's frontmatter to `state: active` with the integer `id` and
graduation stamps — **keeping the body unchanged** (§1 meta-only
topology; no repo is created and no body is stripped). Branch name
uses the `graduate-<slug>-<6hex>` shape — dash-separated like the
other meta-repo branches per the §19.2 path-routing candidate.
entry's frontmatter to `state: active` with the graduation stamps
and — **optionally** — the integer `id`, **keeping the body
unchanged** (§1 meta-only topology; no repo is created and no body
is stripped). When `rfc_id` is None the entry graduates without a
number (id stays null, slug is canonical per §2.3, §13.2). Branch
name uses the `graduate-<slug>-<6hex>` shape — dash-separated like
the other meta-repo branches per the §19.2 path-routing candidate.
"""
import secrets
branch = f"graduate-{slug}-{secrets.token_hex(3)}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
ae = actor.email or f"{actor.gitea_login}@users.noreply"
commit_subject = f"Graduate {slug}{rfc_id}"
commit_subject = f"Graduate {slug}{rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
commit_message = _stamp_single(commit_subject, actor)
result = await self._gitea.update_file(
org, meta_repo, f"rfcs/{slug}.md",
@@ -736,11 +738,12 @@ class Bot:
or result.get("content", {}).get("sha")
or ""
)
pr_title = f"Graduate {slug}{rfc_id}"
pr_title = f"Graduate {slug}{rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
owners_str = ", ".join(owners) if owners else "(none)"
id_line = f"- ID: `{rfc_id}`\n" if rfc_id else "- ID: (none — identified by slug)\n"
pr_body_text = (
f"Graduates super-draft `{slug}` to active.\n\n"
f"- ID: `{rfc_id}`\n"
f"{id_line}"
f"- Owners: {owners_str}\n\n"
f"This is an in-place state flip per the meta-only topology\n"
f"(SPEC §1, §13.3): the entry `rfcs/{slug}.md` keeps its body and\n"
@@ -772,7 +775,7 @@ class Bot:
pr_number: int,
head_branch: str,
slug: str,
rfc_id: str,
rfc_id: str | None,
) -> None:
"""§13.3 step 4: auto-merge the graduation PR with the admin as
merge actor. Distinct action_kind so the audit log carries the
@@ -788,7 +791,7 @@ class Bot:
reports the transient state (belt-and-suspenders against the same
race appearing in a different shape).
"""
subject = f"Graduate {slug}{rfc_id}"
subject = f"Graduate {slug}{rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
body = _trailer(actor)
try:
@@ -809,6 +812,105 @@ class Bot:
details={"rfc_id": rfc_id},
)
# ----- §13.7 retire / un-retire: open + merge a state-flip PR -----
async def open_retire_flip_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
slug: str,
new_file_contents: str,
prior_sha: str,
verb: str,
target_state: str,
) -> dict:
"""§13.7: open a PR flipping `rfcs/<slug>.md` to `state:
<target_state>` — for retire (`verb='retire'`, target `retired`)
or un-retire (`verb='unretire'`, target the restored prior state).
Only the frontmatter `state` changes; the body and every other
field (including the integer `id`) are kept, so an un-retire
restores the entry exactly. Branch shape mirrors graduation's
`<verb>-<slug>-<6hex>`.
"""
import secrets
branch = f"{verb}-{slug}-{secrets.token_hex(3)}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
ae = actor.email or f"{actor.gitea_login}@users.noreply"
verb_title = "Retire" if verb == "retire" else "Un-retire"
commit_subject = f"{verb_title} {slug}"
commit_message = _stamp_single(commit_subject, actor)
result = await self._gitea.update_file(
org, meta_repo, f"rfcs/{slug}.md",
content=new_file_contents,
sha=prior_sha,
message=commit_message,
branch=branch,
author_name=actor.display_name, author_email=ae,
)
commit_sha = (
result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha")
or ""
)
pr_title = f"{verb_title} {slug}"
pr_body_text = (
f"{verb_title}s `{slug}` (state → `{target_state}`).\n\n"
f"This is an in-place frontmatter flip per SPEC §13.7 — the\n"
f"entry `rfcs/{slug}.md` keeps its body and every other field;\n"
f"only `state` changes."
)
_subject, pr_body = _stamp("", pr_body_text, actor)
pr = await self._gitea.create_pull(
org, meta_repo,
title=pr_title, body=pr_body, head=branch, base="main",
)
_log(
actor,
f"{verb}_pr_open",
rfc_slug=slug,
branch_name=branch,
pr_number=pr["number"],
bot_commit_sha=commit_sha,
details={"pr_title": pr_title, "target_state": target_state},
)
return pr
async def merge_retire_flip_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
pr_number: int,
head_branch: str,
slug: str,
verb: str,
) -> None:
"""§13.7: auto-merge the retire / un-retire flip PR (same
mergeable-wait + retry shape as `merge_graduation_pr`)."""
verb_title = "Retire" if verb == "retire" else "Un-retire"
subject = f"{verb_title} {slug}"
body = _trailer(actor)
try:
await self._gitea.wait_for_mergeable(org, meta_repo, pr_number)
except TimeoutError as e:
raise GiteaError(409, str(e)) from e
await _merge_with_retry(
self._gitea, org, meta_repo, pr_number,
merge_message_title=subject, merge_message_body=body,
)
_log(
actor,
f"{verb}_pr_merge",
rfc_slug=slug,
branch_name=head_branch,
pr_number=pr_number,
details={},
)
# ----- §13.3 (meta-only): cleanup of an unmerged flip PR -----
async def close_graduation_pr(