Files
rfc-app/frontend/src/components/GraduateDialog.jsx
T
Ben Stull 1b0968a9a2 Slice 5: graduation per §13
The §13.3 transactional sequence flips a super-draft to active —
five steps with paired undoes, an in-process orchestrator fed by
an asyncio.Queue, the §17 SSE endpoint streaming step transitions
to the dialog. Each step is a new bot primitive that logs an
`actions` row, bracketed by `graduate_start` / `graduate_complete`
for the linkable audit sequence. Rollback runs the undoes in
reverse from the last completed step; merge_pr has no undo by
design per §13.5.

The §9.8 precondition gate is enforced server-side at the top of
POST /graduate so the §13.3 rollback complexity does not grow.
The §13.4 chat migration is a database semantic no-op — the
(slug, branch_name='main') threads keep their identity, only the
interpretation changes. The §9.8 pre-graduation history surfaces
via a new _is_meta_target(rfc, branch) dispatch helper and lands
as pre_graduation_history on /main.

§13.1 claim flow landed alongside since it's the prerequisite for
non-admin graduation — bot.open_claim_pr plus broadening
api_prs._require_pr to accept meta_claim.

45/45 tests green; ten new integration tests cover the validator,
the §9.8 precondition refusal, happy path with audit verification,
mid-sequence rollback at steps 2 and 3, concurrent refusal,
chat-survives-without-data-movement, pre-graduation history, and
the §13.1 claim PR cycle.

SPEC.md §19.1 rewritten for Slice 6 (notifications); §19.2 grew
four candidates surfaced during the slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:52:29 -07:00

358 lines
13 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 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 (
<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 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.
</p>
<div className="form-row">
<label>Integer ID</label>
<input
type="text"
value={rfcId}
onChange={(e) => setRfcId(e.target.value.trim())}
placeholder="RFC-NNNN"
disabled={phase !== 'idle'}
/>
<p className="field-help">Pre-filled as the next free integer; editable to reserve gaps.</p>
{idError && <p className="field-error">{idError}</p>}
</div>
<div className="form-row">
<label>Repo name</label>
<input
type="text"
value={repoName}
onChange={(e) => setRepoName(e.target.value.trim())}
placeholder="rfc-NNNN-slug"
disabled={phase !== 'idle'}
/>
<p className="field-help">Becomes `&lt;org&gt;/{repoName || 'rfc-…'}` on Gitea.</p>
{repoError && <p className="field-error">{repoError}</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>
{blockingPRs.length > 0 && (
<div className="precondition-block">
<button
type="button"
className="precondition-toggle"
onClick={() => setPrecondPopover(p => !p)}
>
{blockingPRs.length} open body-edit PR{blockingPRs.length === 1 ? '' : 's'} blocking graduation
&nbsp;{precondPopover ? '▾' : '▸'}
</button>
{precondPopover && (
<div className="precondition-popover">
{blockingPRs.map(pr => (
<div key={pr.pr_number} className="precondition-row">
<div className="precondition-row-main">
<strong>PR #{pr.pr_number}</strong> {pr.title || '(no title)'}
<div className="precondition-row-meta">
{pr.author ? `by @${pr.author}` : ''}
{pr.last_activity_at ? ` · ${pr.last_activity_at.slice(0, 10)}` : ''}
</div>
</div>
<div className="precondition-row-actions">
<a
className="btn-link"
href={`/rfc/${slug}/pr/${pr.pr_number}`}
target="_blank"
rel="noreferrer"
>Open </a>
</div>
</div>
))}
<p className="precondition-help">
§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.
</p>
</div>
)}
</div>
)}
</div>
)}
{showStack && (
<div className="modal-body">
<StepStack
steps={streamState?.steps || []}
rollbackSteps={streamState?.rollback_steps || []}
/>
{phase === 'rolled_back' && (
<div className="what-happened">
<h3>What happened</h3>
<p>
The graduation could not complete. The app rolled back the
steps that had already run; nothing was left half-applied on
Gitea. Error: <code>{streamState?.error || 'unknown'}</code>.
</p>
<p>
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.
</p>
</div>
)}
{phase === 'done' && (
<div className="graduation-complete">
<h3>Graduation complete</h3>
<p>
`{slug}` is now active as <strong>{streamState?.rfc_id}</strong>{' '}
at <code>{streamState?.repo_full}</code>. 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 to RFC repo
</button>
</>
)}
{phase === 'running' && (
<span className="modal-progress-note">Running graduation sequence</span>
)}
{(phase === 'rolled_back' || phase === 'error') && (
<button className="btn-secondary" onClick={onClose}>Close</button>
)}
{phase === 'done' && (
<button className="btn-primary" onClick={() => onCompleted?.(streamState)}>
View the new RFC
</button>
)}
</div>
{submitError && phase !== 'rolled_back' && (
<div className="modal-error">Error: {submitError}</div>
)}
</div>
</div>
)
}
function StepStack({ steps, rollbackSteps }) {
return (
<div className="step-stack">
{steps.map(s => <StepRow key={s.key} step={s} />)}
{rollbackSteps.length > 0 && (
<div className="rollback-divider">Rollback</div>
)}
{rollbackSteps.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')}`
}
function stripPrefix(rfcId) {
return rfcId?.startsWith('RFC-') ? rfcId.slice(4) : rfcId
}