// GraduateDialog.jsx — the §13.2 Graduate dialog and the §13.3 step stack. // // Renders three editable fields (integer ID, repo name, initial owners) // with debounced server-side validation per §13.2 and a precondition // popover backed by /blocking-prs for the §9.8 open-body-edit-PR gate. // // On confirm, opens the §13.3 SSE stream and renders the five named // steps with per-step states. On failure, the rollback step's events // append to the stack and a "What happened" panel renders below until // the admin dismisses it. import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { graduateCheck, listBlockingPRs, openGraduationProgress, startGraduation, } from '../api' const CHECK_DEBOUNCE_MS = 250 const STEP_KEY_ORDER = ['create_repo', 'seed_files', 'open_pr', 'merge_pr', 'refresh_cache'] 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 [repoName, setRepoName] = useState(`rfc-${stripPrefix(suggestedId)}-${slug}`) const [owners, setOwners] = useState(entry?.owners?.length ? entry.owners : []) const [newOwner, setNewOwner] = useState('') const [checkResult, setCheckResult] = useState(null) const [blockingPRs, setBlockingPRs] = useState([]) const [precondPopover, setPrecondPopover] = useState(false) const [phase, setPhase] = useState('idle') // idle | running | done | rolled_back | error const [streamState, setStreamState] = useState(null) const [submitError, setSubmitError] = useState(null) const esRef = useRef(null) // Initial blocking-PRs probe + ongoing /check polling. useEffect(() => { listBlockingPRs(slug).then(({ items }) => setBlockingPRs(items || [])).catch(() => {}) }, [slug]) useEffect(() => { const t = setTimeout(() => { graduateCheck(slug, { id: rfcId, repo: repoName }) .then(setCheckResult) .catch(() => {}) }, CHECK_DEBOUNCE_MS) return () => clearTimeout(t) }, [slug, rfcId, repoName]) useEffect(() => () => { esRef.current?.close() }, []) const idError = checkResult?.id?.error || null const repoError = checkResult?.repo?.error || null const ownersOk = owners.length > 0 const ownersError = ownersOk ? null : 'Add at least one initial owner' const blockingError = blockingPRs.length > 0 ? `${blockingPRs.length} open body-edit PR${blockingPRs.length === 1 ? '' : 's'} blocking graduation` : null // First-blocker tooltip text per §13.2. const firstBlocker = idError || repoError || ownersError || blockingError const canSubmit = !firstBlocker && phase === 'idle' && checkResult?.id?.ok && checkResult?.repo?.ok 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, repoName, 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('rolled_back') } } }, onError: () => { setSubmitError('Lost connection to the graduation stream — refresh to see current state.') setPhase('error') }, }) }, [slug, rfcId, repoName, 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 its own repo. The meta-repo entry becomes frontmatter-only; the canonical body moves to `RFC.md` in the new repo. The sequence runs as five transactional steps with rollback per §13.3.

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

Pre-filled as the next free integer; editable to reserve gaps.

{idError &&

{idError}

}
setRepoName(e.target.value.trim())} placeholder="rfc-NNNN-slug" disabled={phase !== 'idle'} />

Becomes `<org>/{repoName || 'rfc-…'}` on Gitea.

{repoError &&

{repoError}

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

}
{blockingPRs.length > 0 && (
{precondPopover && (
{blockingPRs.map(pr => (
PR #{pr.pr_number} — {pr.title || '(no title)'}
{pr.author ? `by @${pr.author}` : ''} {pr.last_activity_at ? ` · ${pr.last_activity_at.slice(0, 10)}` : ''}
))}

§9.8: open body-edit PRs would attempt to re-introduce a body to a frontmatter-only entry after step 3. Resolve them (merge or withdraw) and re-open this dialog.

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

What happened

The graduation could not complete. The app rolled back the steps that had already run; nothing was left half-applied on Gitea. Error: {streamState?.error || 'unknown'}.

Read the failure detail next to the red step above. Resolve the underlying cause (a repo-name collision, a network flake, a concurrent PR landing on `rfcs/{slug}.md`) and try again.

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

Graduation complete

`{slug}` is now active as {streamState?.rfc_id}{' '} at {streamState?.repo_full}. The catalog and the RFC view reflect the new state.

)}
)}
{phase === 'idle' && ( <> )} {phase === 'running' && ( Running graduation sequence… )} {(phase === 'rolled_back' || phase === 'error') && ( )} {phase === 'done' && ( )}
{submitError && phase !== 'rolled_back' && (
Error: {submitError}
)}
) } function StepStack({ steps, rollbackSteps }) { return (
{steps.map(s => )} {rollbackSteps.length > 0 && (
Rollback
)} {rollbackSteps.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')}` } function stripPrefix(rfcId) { return rfcId?.startsWith('RFC-') ? rfcId.slice(4) : rfcId }