// 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 (
§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.
Pre-filled as the next free integer; editable to reserve gaps, or clear it to graduate without a number — the slug stays the canonical identifier.
{idError &&{idError}
}{ownersError}
}
The graduation could not complete. Because it is a single
in-place flip, nothing was left half-applied — `{slug}` stays
a super-draft. Error: {streamState?.error || 'unknown'}.
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.
{streamState?.rfc_id ? <>`{slug}` is now active as {streamState.rfc_id}.> : <>`{slug}` is now active (no number — identified by its slug).>} {' '}The catalog and the RFC view reflect the new state.