§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:
@@ -44,7 +44,7 @@ import ChangePanel, { diffWords } from './ChangePanel.jsx'
|
||||
import PRModal from './PRModal.jsx'
|
||||
import GraduateDialog from './GraduateDialog.jsx'
|
||||
import InvitationsModal from './InvitationsModal.jsx'
|
||||
import { claimOwnership } from '../api'
|
||||
import { claimOwnership, retireRFC, unretireRFC } from '../api'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
|
||||
@@ -154,6 +154,35 @@ export default function RFCView({ viewer }) {
|
||||
// gates the underlying endpoints regardless, so a leaked toggle
|
||||
// can't actually leak anything.
|
||||
const [showInvitationsModal, setShowInvitationsModal] = useState(false)
|
||||
// §13.7 retire / un-retire. RFC owners + site owners may retire; only
|
||||
// site owners may un-retire. The backend gates both regardless.
|
||||
const [actionError, setActionError] = useState(null)
|
||||
const canRetire = !!viewer && (entry?.state === 'super-draft' || entry?.state === 'active') && (
|
||||
viewer.role === 'owner' || (entry?.owners || []).includes(viewer.gitea_login)
|
||||
)
|
||||
const handleRetire = useCallback(async () => {
|
||||
if (!window.confirm(
|
||||
`Retire “${entry?.title || slug}”? It will be soft-deleted — removed `
|
||||
+ `from the catalog and every view. A site owner can un-retire it later.`
|
||||
)) return
|
||||
setActionError(null)
|
||||
try {
|
||||
await retireRFC(slug)
|
||||
navigate('/') // the entry is now hidden; return to the catalog
|
||||
} catch (err) {
|
||||
setActionError(err.message)
|
||||
}
|
||||
}, [slug, entry, navigate])
|
||||
const handleUnretire = useCallback(async () => {
|
||||
setActionError(null)
|
||||
try {
|
||||
const res = await unretireRFC(slug)
|
||||
getRFC(slug).then(setEntry).catch(() => {})
|
||||
if (res?.state) navigate(`/rfc/${slug}`)
|
||||
} catch (err) {
|
||||
setActionError(err.message)
|
||||
}
|
||||
}, [slug, navigate])
|
||||
|
||||
// Load main view + branch view whenever slug/branch changes.
|
||||
useEffect(() => {
|
||||
@@ -493,7 +522,27 @@ export default function RFCView({ viewer }) {
|
||||
if (error) return <article className="entry-view"><p>Error: {error}</p></article>
|
||||
if (!entry) return <article className="entry-view">Loading…</article>
|
||||
if (entry.state !== 'active' && entry.state !== 'super-draft') {
|
||||
return <article className="entry-view"><p>This RFC is {entry.state}.</p></article>
|
||||
// §13.7: a retired entry is only reachable here by a site owner (the
|
||||
// backend 404s everyone else), so this doubles as the direct-nav
|
||||
// un-retire surface alongside the admin "Retired" list.
|
||||
const canUnretire = entry.state === 'retired' && viewer?.role === 'owner'
|
||||
return (
|
||||
<article className="entry-view">
|
||||
<p>This RFC is {entry.state}.</p>
|
||||
{canUnretire && (
|
||||
<>
|
||||
<p className="muted">
|
||||
Retired entries are hidden from the catalog and every view.
|
||||
Un-retiring restores it to its prior state.
|
||||
</p>
|
||||
<button type="button" className="btn-primary" onClick={handleUnretire}>
|
||||
Un-retire
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{actionError && <p className="rfc-error-banner">{actionError}</p>}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
if (!branchView) return <article className="entry-view">Loading branch…</article>
|
||||
|
||||
@@ -653,8 +702,22 @@ export default function RFCView({ viewer }) {
|
||||
Invitations
|
||||
</button>
|
||||
)}
|
||||
{/* §13.7: Retire (soft delete). RFC owners + site owners only. */}
|
||||
{canRetire && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link btn-retire"
|
||||
onClick={handleRetire}
|
||||
title="§13.7 — soft-delete this RFC (removes it from every view; a site owner can un-retire it)"
|
||||
>
|
||||
Retire
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{actionError && (
|
||||
<div className="rfc-error-banner">Retire failed: {actionError}</div>
|
||||
)}
|
||||
{claimError && (
|
||||
<div className="rfc-error-banner">Claim failed: {claimError}</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user