49ba06e0c2
Optional number (§13.2/§13.3): GraduateBody.rfc_id is now optional.
A blank/absent id graduates to `active` with `id: null`, leaving the
slug as the canonical identifier (§2.3). /graduate/check treats a blank
id as valid (ok:true); /graduate only validates the RFC-NNNN regex +
collision check when a number is supplied. The Graduate dialog allows an
empty number and renders number-less entries by slug (no RFC-undefined).
Retire (§3, §3.1, §13.7): new `retired` soft-delete state. An RFC's own
owners (frontmatter) and site `owner`-role holders — not app admins —
retire via POST /api/rfcs/<slug>/retire, an auto-merged meta-repo flip
PR (graduation machinery reused). Retired entries leave every browsing
surface: catalog, get_rfc (404 except site owner), discussion/branch
reads. Un-retire is site-owner-only (POST .../unretire), discoverable
via the owner-gated GET /api/admin/retired-rfcs ("Retired" admin tab).
Migration 025 widens the cached_rfcs.state CHECK to include 'retired'
(table rebuild, all columns preserved).
Tests: graduation no-number cases (active+null id by slug; check accepts
blank) added to test_graduation_vertical.py; new test_retire_vertical.py
covers perms (owner/site-owner allowed, admin/contributor 403),
catalog/read exclusion, and a graduate→retire→unretire round-trip. Full
backend suite 386 passing; frontend builds clean. SPEC §3/§3.1/§13
updated; CHANGELOG + VERSION → 0.33.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
283 lines
9.8 KiB
React
283 lines
9.8 KiB
React
// 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 (
|
||
<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 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.
|
||
</p>
|
||
|
||
<div className="form-row">
|
||
<label>Integer ID <span className="field-optional">(optional)</span></label>
|
||
<input
|
||
type="text"
|
||
value={rfcId}
|
||
onChange={(e) => setRfcId(e.target.value.trim())}
|
||
placeholder="RFC-NNNN — or leave blank"
|
||
disabled={phase !== 'idle'}
|
||
/>
|
||
<p className="field-help">
|
||
Pre-filled as the next free integer; editable to reserve gaps,
|
||
or <strong>clear it to graduate without a number</strong> — the
|
||
slug stays the canonical identifier.
|
||
</p>
|
||
{idError && <p className="field-error">{idError}</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>
|
||
</div>
|
||
)}
|
||
|
||
{showStack && (
|
||
<div className="modal-body">
|
||
<StepStack steps={streamState?.steps || []} />
|
||
{phase === 'failed' && (
|
||
<div className="what-happened">
|
||
<h3>What happened</h3>
|
||
<p>
|
||
The graduation could not complete. Because it is a single
|
||
in-place flip, nothing was left half-applied — `{slug}` stays
|
||
a super-draft. Error: <code>{streamState?.error || 'unknown'}</code>.
|
||
</p>
|
||
<p>
|
||
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.
|
||
</p>
|
||
</div>
|
||
)}
|
||
{phase === 'done' && (
|
||
<div className="graduation-complete">
|
||
<h3>Graduation complete</h3>
|
||
<p>
|
||
{streamState?.rfc_id
|
||
? <>`{slug}` is now active as <strong>{streamState.rfc_id}</strong>.</>
|
||
: <>`{slug}` is now active (no number — identified by its slug).</>}
|
||
{' '}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
|
||
</button>
|
||
</>
|
||
)}
|
||
{phase === 'running' && (
|
||
<span className="modal-progress-note">Graduating…</span>
|
||
)}
|
||
{(phase === 'failed' || phase === 'error') && (
|
||
<button className="btn-secondary" onClick={onClose}>Close</button>
|
||
)}
|
||
{phase === 'done' && (
|
||
<button className="btn-primary" onClick={() => onCompleted?.(streamState)}>
|
||
View the RFC
|
||
</button>
|
||
)}
|
||
</div>
|
||
{submitError && phase !== 'failed' && (
|
||
<div className="modal-error">Error: {submitError}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
|
||
function StepStack({ steps }) {
|
||
return (
|
||
<div className="step-stack">
|
||
{steps.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')}`
|
||
}
|