v0.31.0: meta-only repository topology (ROADMAP #36)
Retire the per-RFC-repo model. RFCs now live in their meta-repo entry (rfcs/<slug>.md) for their whole life; graduation is an in-place super-draft → active state flip that keeps the body in the entry — no repo creation, no body-strip, no five-step transaction, no rollback. SPEC: §1 topology rewritten (one meta/content repository, no per-RFC repos) with a deployer-facing "single content repository" framing; §2 (repo: always-null), §3 (active is in-place), §4, §9.8 (handoff frictions dissolve), and §13 fully rewritten (two-field dialog, "The flip", §13.6 RFC-0001 fold-back record). Code: graduation collapses to open+merge one frontmatter PR; branch/PR/ chat dispatch re-keyed on meta-residency (repo IS NULL) so active RFCs edit on the meta repo exactly as super-drafts do; the two "RFC has no repo" 409 guards removed; promote-to-branch slug-embeds an active RFC's auto-branch (edit-<slug>-<hex>) for shared-repo cache attribution; refresh_meta_branches + hygiene branch-resolution include meta-resident actives; the §9.8 read-only guard + pre_graduation_history scoped to legacy per-repo only; dead bot primitives + GraduateDialog repo field + blocking-PR popover removed. The repo: frontmatter field and the /blocking-prs endpoint are retained (schema stability / informational). Tests: graduation suite rewritten to the flip model; e2e + hygiene updated. Full backend suite 375 passed; frontend builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,70 +1,55 @@
|
||||
// GraduateDialog.jsx — the §13.2 Graduate dialog and the §13.3 step stack.
|
||||
// GraduateDialog.jsx — the §13.2 Graduate dialog and the §13.3 flip.
|
||||
//
|
||||
// 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.
|
||||
// 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,
|
||||
listBlockingPRs,
|
||||
openGraduationProgress,
|
||||
startGraduation,
|
||||
} from '../api'
|
||||
|
||||
const CHECK_DEBOUNCE_MS = 250
|
||||
|
||||
const STEP_KEY_ORDER = ['create_repo', 'seed_files', 'open_pr', 'merge_pr', 'refresh_cache']
|
||||
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 [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 [phase, setPhase] = useState('idle') // idle | running | done | failed | 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 })
|
||||
graduateCheck(slug, { id: rfcId })
|
||||
.then(setCheckResult)
|
||||
.catch(() => {})
|
||||
}, CHECK_DEBOUNCE_MS)
|
||||
return () => clearTimeout(t)
|
||||
}, [slug, rfcId, repoName])
|
||||
}, [slug, rfcId])
|
||||
|
||||
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 firstBlocker = idError || ownersError
|
||||
const canSubmit = !firstBlocker && phase === 'idle' && checkResult?.id?.ok && ownersOk
|
||||
|
||||
const handleAddOwner = useCallback(() => {
|
||||
const v = newOwner.trim().toLowerCase()
|
||||
@@ -81,7 +66,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
setSubmitError(null)
|
||||
setPhase('running')
|
||||
try {
|
||||
await startGraduation(slug, { rfcId, repoName, owners })
|
||||
await startGraduation(slug, { rfcId, owners })
|
||||
} catch (err) {
|
||||
setPhase('idle')
|
||||
setSubmitError(err.message)
|
||||
@@ -96,7 +81,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
// Short hold per §13.3, then dismiss.
|
||||
setTimeout(() => onCompleted?.(payload), 1500)
|
||||
} else {
|
||||
setPhase('rolled_back')
|
||||
setPhase('failed')
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -105,7 +90,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
setPhase('error')
|
||||
},
|
||||
})
|
||||
}, [slug, rfcId, repoName, owners, onCompleted])
|
||||
}, [slug, rfcId, owners, onCompleted])
|
||||
|
||||
// ----- Render -----
|
||||
|
||||
@@ -122,10 +107,10 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
{!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.
|
||||
§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, integer ID, and the
|
||||
graduation stamps). No new repository is created.
|
||||
</p>
|
||||
|
||||
<div className="form-row">
|
||||
@@ -141,19 +126,6 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
{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">
|
||||
@@ -189,68 +161,24 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
</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' && (
|
||||
<StepStack steps={streamState?.steps || []} />
|
||||
{phase === 'failed' && (
|
||||
<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>.
|
||||
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 repo-name collision, a network flake,
|
||||
a concurrent PR landing on `rfcs/{slug}.md`) and try again.
|
||||
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>
|
||||
)}
|
||||
@@ -258,9 +186,8 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
<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.
|
||||
`{slug}` is now active as <strong>{streamState?.rfc_id}</strong>.
|
||||
The catalog and the RFC view reflect the new state.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -277,23 +204,23 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
disabled={!canSubmit}
|
||||
title={canSubmit ? '' : firstBlocker || ''}
|
||||
>
|
||||
Graduate to RFC repo
|
||||
Graduate
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{phase === 'running' && (
|
||||
<span className="modal-progress-note">Running graduation sequence…</span>
|
||||
<span className="modal-progress-note">Graduating…</span>
|
||||
)}
|
||||
{(phase === 'rolled_back' || phase === 'error') && (
|
||||
{(phase === 'failed' || phase === 'error') && (
|
||||
<button className="btn-secondary" onClick={onClose}>Close</button>
|
||||
)}
|
||||
{phase === 'done' && (
|
||||
<button className="btn-primary" onClick={() => onCompleted?.(streamState)}>
|
||||
View the new RFC
|
||||
View the RFC
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{submitError && phase !== 'rolled_back' && (
|
||||
{submitError && phase !== 'failed' && (
|
||||
<div className="modal-error">Error: {submitError}</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -302,14 +229,10 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
}
|
||||
|
||||
|
||||
function StepStack({ steps, rollbackSteps }) {
|
||||
function StepStack({ steps }) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -350,8 +273,3 @@ function suggestNextRfcId(existing) {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user