1b0968a9a2
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>
358 lines
13 KiB
React
358 lines
13 KiB
React
// 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 `<org>/{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
|
||
{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
|
||
}
|