// 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 (
§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.
Pre-filled as the next free integer; editable to reserve gaps.
{idError &&{idError}
}Becomes `<org>/{repoName || 'rfc-…'}` on Gitea.
{repoError &&{repoError}
}{ownersError}
}§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.
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.
`{slug}` is now active as {streamState?.rfc_id}{' '}
at {streamState?.repo_full}. The catalog and the
RFC view reflect the new state.