Files
rfc-app/frontend/src/components/GraduateDialog.jsx
T
Ben Stull 49ba06e0c2 §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>
2026-06-01 22:27:46 -07:00

283 lines
9.8 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// GraduateDialog.jsx — the §13.2 Graduate dialog and the §13.3 flip.
//
// Meta-only topology (SPEC §1): graduation is an in-place state flip on
// the meta entry — no per-RFC repo is created and the body is kept. The
// dialog renders two editable fields (integer ID + initial owners) with
// debounced server-side validation per §13.2. On confirm it opens the
// §13.3 SSE stream and renders the two named steps (open the flip PR,
// merge it). There is no rollback step and no repo-name field.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
graduateCheck,
openGraduationProgress,
startGraduation,
} from '../api'
const CHECK_DEBOUNCE_MS = 250
const STEP_KEY_ORDER = ['open_pr', 'merge_pr']
export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
// Suggest defaults from the catalog.
const suggestedId = useMemo(() => suggestNextRfcId(entry?.allKnownIds || []), [entry])
const [rfcId, setRfcId] = useState(suggestedId)
const [owners, setOwners] = useState(entry?.owners?.length ? entry.owners : [])
const [newOwner, setNewOwner] = useState('')
const [checkResult, setCheckResult] = useState(null)
const [phase, setPhase] = useState('idle') // idle | running | done | failed | error
const [streamState, setStreamState] = useState(null)
const [submitError, setSubmitError] = useState(null)
const esRef = useRef(null)
useEffect(() => {
const t = setTimeout(() => {
graduateCheck(slug, { id: rfcId })
.then(setCheckResult)
.catch(() => {})
}, CHECK_DEBOUNCE_MS)
return () => clearTimeout(t)
}, [slug, rfcId])
useEffect(() => () => { esRef.current?.close() }, [])
const idError = checkResult?.id?.error || null
const ownersOk = owners.length > 0
const ownersError = ownersOk ? null : 'Add at least one initial owner'
// First-blocker tooltip text per §13.2.
const firstBlocker = idError || ownersError
const canSubmit = !firstBlocker && phase === 'idle' && checkResult?.id?.ok && ownersOk
const handleAddOwner = useCallback(() => {
const v = newOwner.trim().toLowerCase()
if (!v || owners.includes(v)) return
setOwners(prev => [...prev, v])
setNewOwner('')
}, [newOwner, owners])
const handleRemoveOwner = useCallback((login) => {
setOwners(prev => prev.filter(o => o !== login))
}, [])
const handleConfirm = useCallback(async () => {
setSubmitError(null)
setPhase('running')
try {
await startGraduation(slug, { rfcId, owners })
} catch (err) {
setPhase('idle')
setSubmitError(err.message)
return
}
esRef.current = openGraduationProgress(slug, {
onUpdate: (payload) => {
setStreamState(payload)
if (payload?.finished) {
if (payload.succeeded) {
setPhase('done')
// Short hold per §13.3, then dismiss.
setTimeout(() => onCompleted?.(payload), 1500)
} else {
setPhase('failed')
}
}
},
onError: () => {
setSubmitError('Lost connection to the graduation stream — refresh to see current state.')
setPhase('error')
},
})
}, [slug, rfcId, owners, onCompleted])
// ----- Render -----
const showStack = phase !== 'idle' && (streamState?.steps?.length || 0) > 0
return (
<div className="modal-overlay" onClick={(e) => { if (e.target === e.currentTarget && phase === 'idle') onClose?.() }}>
<div className="modal modal-wide">
<div className="modal-header">
<h2>Graduate `{slug}` to active</h2>
{phase === 'idle' && <button className="modal-close" onClick={onClose}>×</button>}
</div>
{!showStack && (
<div className="modal-body">
<p className="modal-intro">
§13: graduate the super-draft to active. This is an in-place
state flip the entry keeps its body and stays in the meta
repo; only the frontmatter changes (state, the optional
integer ID, and the graduation stamps). No new repository is
created.
</p>
<div className="form-row">
<label>Integer ID <span className="field-optional">(optional)</span></label>
<input
type="text"
value={rfcId}
onChange={(e) => setRfcId(e.target.value.trim())}
placeholder="RFC-NNNN — or leave blank"
disabled={phase !== 'idle'}
/>
<p className="field-help">
Pre-filled as the next free integer; editable to reserve gaps,
or <strong>clear it to graduate without a number</strong> the
slug stays the canonical identifier.
</p>
{idError && <p className="field-error">{idError}</p>}
</div>
<div className="form-row">
<label>Initial owners</label>
<div className="owner-list">
{owners.length === 0 && <span className="owner-empty">No owners yet add at least one.</span>}
{owners.map(o => (
<span key={o} className="owner-chip">
{o}
<button
type="button"
className="owner-chip-x"
onClick={() => handleRemoveOwner(o)}
disabled={phase !== 'idle'}
aria-label={`Remove ${o}`}
>×</button>
</span>
))}
</div>
<div className="owner-picker">
<input
type="text"
value={newOwner}
onChange={(e) => setNewOwner(e.target.value)}
placeholder="Gitea login"
disabled={phase !== 'idle'}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddOwner() }}
/>
<button
type="button"
className="btn-secondary"
onClick={handleAddOwner}
disabled={phase !== 'idle' || !newOwner.trim()}
>Add</button>
</div>
{ownersError && <p className="field-error">{ownersError}</p>}
</div>
</div>
)}
{showStack && (
<div className="modal-body">
<StepStack steps={streamState?.steps || []} />
{phase === 'failed' && (
<div className="what-happened">
<h3>What happened</h3>
<p>
The graduation could not complete. Because it is a single
in-place flip, nothing was left half-applied `{slug}` stays
a super-draft. Error: <code>{streamState?.error || 'unknown'}</code>.
</p>
<p>
Read the failure detail next to the red step above, resolve
the underlying cause (a concurrent PR landing on{' '}
`rfcs/{slug}.md`, a network flake), and try again.
</p>
</div>
)}
{phase === 'done' && (
<div className="graduation-complete">
<h3>Graduation complete</h3>
<p>
{streamState?.rfc_id
? <>`{slug}` is now active as <strong>{streamState.rfc_id}</strong>.</>
: <>`{slug}` is now active (no number identified by its slug).</>}
{' '}The catalog and the RFC view reflect the new state.
</p>
</div>
)}
</div>
)}
<div className="modal-actions">
{phase === 'idle' && (
<>
<button className="btn-secondary" onClick={onClose}>Cancel</button>
<button
className="btn-primary"
onClick={handleConfirm}
disabled={!canSubmit}
title={canSubmit ? '' : firstBlocker || ''}
>
Graduate
</button>
</>
)}
{phase === 'running' && (
<span className="modal-progress-note">Graduating</span>
)}
{(phase === 'failed' || phase === 'error') && (
<button className="btn-secondary" onClick={onClose}>Close</button>
)}
{phase === 'done' && (
<button className="btn-primary" onClick={() => onCompleted?.(streamState)}>
View the RFC
</button>
)}
</div>
{submitError && phase !== 'failed' && (
<div className="modal-error">Error: {submitError}</div>
)}
</div>
</div>
)
}
function StepStack({ steps }) {
return (
<div className="step-stack">
{steps.map(s => <StepRow key={s.key} step={s} />)}
</div>
)
}
function StepRow({ step }) {
return (
<div className={`step-row step-${step.status}`}>
<span className={`step-marker step-marker-${step.status}`} />
<div className="step-text">
<div className="step-label">{step.label}</div>
{step.detail && <div className="step-detail">{step.detail}</div>}
</div>
<div className={`step-status-pill pill-${step.status}`}>{labelFor(step.status)}</div>
</div>
)
}
function labelFor(status) {
switch (status) {
case 'pending': return 'pending'
case 'running': return 'running'
case 'done': return 'done'
case 'failed': return 'failed'
case 'not-reached': return 'not reached'
default: return status
}
}
function suggestNextRfcId(existing) {
const used = new Set()
for (const id of existing) {
const m = /^RFC-(\d+)$/.exec(id || '')
if (m) used.add(Number(m[1]))
}
const next = used.size === 0 ? 1 : (Math.max(...used) + 1)
return `RFC-${String(next).padStart(4, '0')}`
}