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>
This commit is contained in:
Ben Stull
2026-05-24 21:52:29 -07:00
parent 4565a6cb95
commit 1b0968a9a2
14 changed files with 2872 additions and 172 deletions
+75
View File
@@ -40,6 +40,8 @@ import ChatPanel from './ChatPanel.jsx'
import ChangePanel from './ChangePanel.jsx'
import DiffView from './DiffView.jsx'
import PRModal from './PRModal.jsx'
import GraduateDialog from './GraduateDialog.jsx'
import { claimOwnership } from '../api'
const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
const MANUAL_DEBOUNCE_MS = 800
@@ -108,6 +110,8 @@ export default function RFCView({ viewer }) {
// metadata pane, and the start-contributing dispatch target.
const isSuperDraft = entry?.state === 'super-draft'
const [showMetadataPane, setShowMetadataPane] = useState(false)
const [showGraduateDialog, setShowGraduateDialog] = useState(false)
const [claimError, setClaimError] = useState(null)
// Load main view + branch view whenever slug/branch changes.
useEffect(() => {
@@ -538,8 +542,42 @@ export default function RFCView({ viewer }) {
Metadata
</button>
)}
{isSuperDraft && viewer && entry?.owners && !entry.owners.includes(viewer.gitea_login) && (
<button
type="button"
className="btn-link"
onClick={async () => {
setClaimError(null)
try {
const result = await claimOwnership(slug)
if (result?.noop) return
if (result?.pr_number) {
navigate(`/rfc/${slug}/pr/${result.pr_number}`)
}
} catch (err) {
setClaimError(err.message)
}
}}
title="§13.1 — open a claim PR adding you to the entry's owners"
>
Claim ownership
</button>
)}
{isSuperDraft && viewer && (viewer.role === 'owner' || viewer.role === 'admin' || (entry?.owners || []).includes(viewer.gitea_login)) && (
<button
type="button"
className="btn-primary btn-graduate"
onClick={() => setShowGraduateDialog(true)}
title="§13 — graduate this super-draft to a per-RFC repo"
>
Graduate to RFC repo
</button>
)}
</div>
</div>
{claimError && (
<div className="rfc-error-banner">Claim failed: {claimError}</div>
)}
{/* Two columns: editor + chat */}
<div className="rfc-body">
@@ -693,6 +731,20 @@ export default function RFCView({ viewer }) {
/>
)}
{showGraduateDialog && (
<GraduateDialog
slug={slug}
entry={entry}
onClose={() => setShowGraduateDialog(false)}
onCompleted={() => {
setShowGraduateDialog(false)
// The catalog row and the RFC view now reflect `active`.
getRFC(slug).then(setEntry).catch(() => {})
getRFCMain(slug).then(setMainView).catch(() => {})
}}
/>
)}
{showMetadataPane && (
<MetadataPaneModal
slug={slug}
@@ -776,6 +828,7 @@ function BranchDropdown({ current, mainView, isSuperDraft, onPick }) {
// RFCs it is literally `main` per §8.1.
const mainLabel = isSuperDraft ? 'canonical body' : 'main'
const items = [{ name: 'main', label: mainLabel }, ...(mainView?.branches || [])]
const preGrad = mainView?.pre_graduation_history || []
const currentLabel = current === 'main' ? mainLabel : current
return (
<div className="branch-dropdown">
@@ -804,6 +857,28 @@ function BranchDropdown({ current, mainView, isSuperDraft, onPick }) {
)}
</button>
))}
{preGrad.length > 0 && (
<>
<div className="branch-dropdown-section">
Pre-graduation history ({preGrad.length})
</div>
{preGrad.map(b => (
<button
key={b.branch_name}
type="button"
className={`branch-dropdown-item pre-graduation ${b.branch_name === current ? 'active' : ''}`}
onClick={() => { setOpen(false); onPick(b.branch_name) }}
title="§9.8: meta-repo edit branch from before graduation — read-only"
>
<span className="branch-name">{b.branch_name}</span>
<span className="branch-meta">
{b.thread_count} thread{b.thread_count === 1 ? '' : 's'}
{b.change_count ? ` · ${b.change_count} change${b.change_count === 1 ? '' : 's'}` : ''}
</span>
</button>
))}
</>
)}
</div>
)}
</div>