// 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 (
{ if (e.target === e.currentTarget && phase === 'idle') onClose?.() }}>

Graduate `{slug}` to active

{phase === 'idle' && }
{!showStack && (

§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.

setRfcId(e.target.value.trim())} placeholder="RFC-NNNN — or leave blank" disabled={phase !== 'idle'} />

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}

}
{owners.length === 0 && No owners yet — add at least one.} {owners.map(o => ( {o} ))}
setNewOwner(e.target.value)} placeholder="Gitea login" disabled={phase !== 'idle'} onKeyDown={(e) => { if (e.key === 'Enter') handleAddOwner() }} />
{ownersError &&

{ownersError}

}
)} {showStack && (
{phase === 'failed' && (

What happened

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.

)} {phase === 'done' && (

Graduation complete

{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.

)}
)}
{phase === 'idle' && ( <> )} {phase === 'running' && ( Graduating… )} {(phase === 'failed' || phase === 'error') && ( )} {phase === 'done' && ( )}
{submitError && phase !== 'failed' && (
Error: {submitError}
)}
) } function StepStack({ steps }) { return (
{steps.map(s => )}
) } function StepRow({ step }) { return (
{step.label}
{step.detail &&
{step.detail}
}
{labelFor(step.status)}
) } 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')}` }