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:
@@ -1021,3 +1021,121 @@
|
||||
}
|
||||
.pr-review-quote { font-size: 11px; color: #6b7280; margin-bottom: 6px; }
|
||||
.pr-review-quote pre { background: #fff; padding: 4px 8px; border-radius: 4px; margin: 4px 0 0 0; }
|
||||
|
||||
/* ── Slice 5: §13 graduation dialog ──────────────────────────────────── */
|
||||
|
||||
.modal-wide { width: min(720px, 92vw); }
|
||||
.modal-intro { margin: 0 0 16px 0; font-size: 13px; color: #4b5563; line-height: 1.55; }
|
||||
|
||||
.form-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; }
|
||||
.form-row label { font-weight: 600; font-size: 12px; color: #1a1a1a; }
|
||||
.form-row input, .form-row textarea {
|
||||
font: inherit; padding: 6px 8px; border: 1px solid #d1d5db; border-radius: 4px;
|
||||
}
|
||||
.field-help { font-size: 11px; color: #6b7280; margin: 2px 0 0 0; }
|
||||
.field-error { font-size: 12px; color: #b91c1c; margin: 4px 0 0 0; }
|
||||
|
||||
.owner-list {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; min-height: 28px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.owner-empty { font-size: 12px; color: #6b7280; font-style: italic; }
|
||||
.owner-chip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
background: #eef2ff; color: #3730a3; padding: 2px 8px; border-radius: 99px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.owner-chip-x {
|
||||
background: none; border: none; cursor: pointer; color: #6b7280;
|
||||
font-size: 14px; line-height: 1; padding: 0 2px;
|
||||
}
|
||||
.owner-chip-x:hover { color: #b91c1c; }
|
||||
.owner-picker { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.owner-picker input { flex: 1; }
|
||||
|
||||
.precondition-block { margin-top: 12px; padding: 10px; background: #fef2f2; border-radius: 6px; border: 1px solid #fecaca; }
|
||||
.precondition-toggle {
|
||||
background: none; border: none; cursor: pointer; color: #b91c1c; font-weight: 600;
|
||||
font-size: 13px; padding: 0;
|
||||
}
|
||||
.precondition-popover { margin-top: 8px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.precondition-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
background: #fff; padding: 8px 10px; border-radius: 4px; border: 1px solid #f3f4f6;
|
||||
}
|
||||
.precondition-row-meta { font-size: 11px; color: #6b7280; }
|
||||
.precondition-row-actions a { color: #5b5bd6; }
|
||||
.precondition-help { font-size: 11px; color: #6b7280; margin: 4px 0 0 0; font-style: italic; }
|
||||
|
||||
.btn-graduate { margin-left: 6px; }
|
||||
|
||||
.step-stack { display: flex; flex-direction: column; gap: 6px; margin: 12px 0; }
|
||||
.step-row {
|
||||
display: grid; grid-template-columns: 24px 1fr auto; gap: 10px; align-items: center;
|
||||
padding: 8px 10px; border-radius: 6px; background: #f9fafb; border: 1px solid #f3f4f6;
|
||||
}
|
||||
.step-row.step-running { background: #eff6ff; border-color: #bfdbfe; }
|
||||
.step-row.step-done { background: #f0fdf4; border-color: #bbf7d0; }
|
||||
.step-row.step-failed { background: #fef2f2; border-color: #fecaca; }
|
||||
.step-row.step-not-reached { opacity: 0.45; }
|
||||
.step-marker {
|
||||
display: inline-block; width: 16px; height: 16px; border-radius: 50%;
|
||||
background: #d1d5db;
|
||||
}
|
||||
.step-marker-pending { background: #d1d5db; }
|
||||
.step-marker-running { background: #3b82f6; animation: pulse 1s ease-in-out infinite; }
|
||||
.step-marker-done { background: #10b981; }
|
||||
.step-marker-failed { background: #ef4444; }
|
||||
.step-marker-not-reached { background: #e5e7eb; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
|
||||
.step-label { font-weight: 600; font-size: 13px; color: #1a1a1a; }
|
||||
.step-detail { font-size: 11px; color: #6b7280; margin-top: 2px; }
|
||||
.step-status-pill {
|
||||
font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 3px;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.pill-pending { background: #e5e7eb; color: #4b5563; }
|
||||
.pill-running { background: #3b82f6; color: #fff; }
|
||||
.pill-done { background: #10b981; color: #fff; }
|
||||
.pill-failed { background: #ef4444; color: #fff; }
|
||||
.pill-not-reached { background: #e5e7eb; color: #9ca3af; }
|
||||
|
||||
.rollback-divider {
|
||||
margin: 10px 0 6px 0; font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: #b91c1c; font-weight: 700;
|
||||
}
|
||||
|
||||
.what-happened {
|
||||
margin-top: 14px; padding: 12px; background: #fef2f2;
|
||||
border: 1px solid #fecaca; border-radius: 6px;
|
||||
}
|
||||
.what-happened h3 { margin: 0 0 6px 0; font-size: 14px; color: #991b1b; }
|
||||
.what-happened p { margin: 0 0 6px 0; font-size: 13px; color: #4b5563; line-height: 1.55; }
|
||||
.graduation-complete {
|
||||
margin-top: 14px; padding: 12px; background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0; border-radius: 6px;
|
||||
}
|
||||
.graduation-complete h3 { margin: 0 0 6px 0; font-size: 14px; color: #166534; }
|
||||
.graduation-complete p { margin: 0; font-size: 13px; color: #14532d; line-height: 1.55; }
|
||||
|
||||
.modal-progress-note { font-size: 12px; color: #6b7280; }
|
||||
.modal-error {
|
||||
padding: 8px 12px; background: #fef2f2; color: #991b1b;
|
||||
border-top: 1px solid #fecaca; font-size: 12px;
|
||||
}
|
||||
.rfc-error-banner {
|
||||
padding: 8px 12px; background: #fef2f2; color: #991b1b;
|
||||
border-bottom: 1px solid #fecaca; font-size: 12px;
|
||||
}
|
||||
|
||||
.branch-dropdown-section {
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: #6b7280; padding: 8px 10px 4px 10px; font-weight: 700;
|
||||
}
|
||||
.branch-dropdown-item.pre-graduation {
|
||||
font-style: italic; color: #4b5563;
|
||||
}
|
||||
.branch-dropdown-item.pre-graduation .branch-meta {
|
||||
font-size: 10px; color: #9ca3af; margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -221,6 +221,54 @@ export async function editMetadata(slug, { title, tags, prDescription }) {
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── Slice 5: §13 graduation + §13.1 claim ────────────────────────────────
|
||||
|
||||
export async function claimOwnership(slug) {
|
||||
const res = await fetch(`/api/rfcs/${slug}/claim`, { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function listBlockingPRs(slug) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/blocking-prs`))
|
||||
}
|
||||
|
||||
export async function graduateCheck(slug, { id, repo }) {
|
||||
const params = new URLSearchParams()
|
||||
if (id != null) params.set('id', id)
|
||||
if (repo != null) params.set('repo', repo)
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/graduate/check?${params}`))
|
||||
}
|
||||
|
||||
export async function startGraduation(slug, { rfcId, repoName, owners }) {
|
||||
const res = await fetch(`/api/rfcs/${slug}/graduate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rfc_id: rfcId, repo_name: repoName, owners }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Open an EventSource on the §13.3 progress stream. Returns the
|
||||
// EventSource so the caller can close() on dialog dismiss. Calls
|
||||
// onUpdate with the parsed state payload for every event.
|
||||
export function openGraduationProgress(slug, { onUpdate, onDone, onError }) {
|
||||
const es = new EventSource(`/api/rfcs/${slug}/graduate/progress`)
|
||||
const handle = (e) => {
|
||||
try {
|
||||
const payload = JSON.parse(e.data)
|
||||
onUpdate?.(payload, e.type)
|
||||
if (e.type === 'done' && payload?.finished) onDone?.(payload)
|
||||
} catch (err) {
|
||||
onError?.(err)
|
||||
}
|
||||
}
|
||||
for (const name of ['snapshot', 'step', 'rollback_step', 'completed', 'rolled_back', 'done']) {
|
||||
es.addEventListener(name, handle)
|
||||
}
|
||||
es.onerror = (e) => { onError?.(e); es.close() }
|
||||
return es
|
||||
}
|
||||
|
||||
// ── Slice 3: the §10 PR flow ─────────────────────────────────────────────
|
||||
|
||||
export async function draftPRText(slug, branch) {
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
// 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user