Files
rfc-app/frontend/src/api.js
T
Ben Stull 1b0968a9a2 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>
2026-05-24 21:52:29 -07:00

402 lines
13 KiB
JavaScript

// api.js — every backend call lives here.
//
// All write requests pass {credentials: 'include'} implicitly because
// the dev proxy and the production deploy serve the API from the same
// origin as the frontend. If you split origins later, change here.
async function jsonOrThrow(res) {
if (!res.ok) {
let detail = ''
try {
const body = await res.json()
detail = body.detail || JSON.stringify(body)
} catch {
detail = await res.text()
}
const error = new Error(detail || `HTTP ${res.status}`)
error.status = res.status
throw error
}
return res.json()
}
export async function getMe() {
const res = await fetch('/api/auth/me')
return jsonOrThrow(res)
}
export async function listRFCs() {
return jsonOrThrow(await fetch('/api/rfcs'))
}
export async function getRFC(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}`))
}
export async function listProposals() {
return jsonOrThrow(await fetch('/api/proposals'))
}
export async function getProposal(prNumber) {
return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`))
}
export async function proposeRFC({ title, slug, pitch, tags }) {
const res = await fetch('/api/rfcs/propose', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, slug, pitch, tags: tags || [] }),
})
return jsonOrThrow(res)
}
export async function mergeProposal(prNumber) {
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
return jsonOrThrow(res)
}
export async function declineProposal(prNumber, comment) {
const res = await fetch(`/api/proposals/${prNumber}/decline`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ comment }),
})
return jsonOrThrow(res)
}
export async function withdrawProposal(prNumber) {
const res = await fetch(`/api/proposals/${prNumber}/withdraw`, { method: 'POST' })
return jsonOrThrow(res)
}
// ── Slice 2: active-RFC view (§8) ─────────────────────────────────────────
export async function listModels() {
return jsonOrThrow(await fetch('/api/models'))
}
export async function getRFCMain(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/main`))
}
export async function getBranch(slug, branch) {
return jsonOrThrow(await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}`
))
}
export async function promoteToBranch(slug, body = {}) {
const res = await fetch(`/api/rfcs/${slug}/branches/main/promote-to-branch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return jsonOrThrow(res)
}
export async function acceptChange(slug, branch, changeId, { proposed, wasEdited, forceApplyStale }) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/accept`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
proposed,
was_edited_before_accept: !!wasEdited,
force_apply_stale: !!forceApplyStale,
}),
},
)
return jsonOrThrow(res)
}
export async function declineChange(slug, branch, changeId) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/decline`,
{ method: 'POST' },
)
return jsonOrThrow(res)
}
export async function reaskChange(slug, branch, changeId) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/reask`,
{ method: 'POST' },
)
return jsonOrThrow(res)
}
export async function manualFlush(slug, branch, { newContent, paragraphCount }) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/manual-flush`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_content: newContent, paragraph_count: paragraphCount }),
},
)
return jsonOrThrow(res)
}
export async function setBranchVisibility(slug, branch, { readPublic, contributeMode }) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/visibility`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
read_public: readPublic,
contribute_mode: contributeMode,
}),
},
)
return jsonOrThrow(res)
}
export async function createThread(slug, branch, body) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
)
return jsonOrThrow(res)
}
export async function listThreads(slug, branch) {
return jsonOrThrow(await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads`,
))
}
export async function getThreadMessages(slug, branch, threadId) {
return jsonOrThrow(await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/messages`,
))
}
export async function postThreadMessage(slug, branch, threadId, { text, quote }) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/messages`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, quote }),
},
)
return jsonOrThrow(res)
}
export async function resolveThread(slug, branch, threadId) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/resolve`,
{ method: 'POST' },
)
return jsonOrThrow(res)
}
// ── Slice 4: super-draft body editing (§9.5) ─────────────────────────────
export async function startEditBranch(slug, body = {}) {
const res = await fetch(`/api/rfcs/${slug}/start-edit-branch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return jsonOrThrow(res)
}
export async function editMetadata(slug, { title, tags, prDescription }) {
const res = await fetch(`/api/rfcs/${slug}/metadata`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title ?? null,
tags: tags ?? null,
pr_description: prDescription ?? null,
}),
})
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) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/pr-draft`,
{ method: 'POST' },
)
return jsonOrThrow(res)
}
export async function openPR(slug, branch, { title, description }) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/open-pr`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description }),
},
)
return jsonOrThrow(res)
}
export async function getPR(slug, prNumber) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/prs/${prNumber}`))
}
export async function advancePRSeen(slug, prNumber, { lastSeenCommitSha, lastSeenMessageId }) {
const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/seen`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
last_seen_commit_sha: lastSeenCommitSha,
last_seen_message_id: lastSeenMessageId,
}),
})
return jsonOrThrow(res)
}
export async function postPRReview(slug, prNumber, { text, anchorPayload, quote }) {
const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/review`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, anchor_payload: anchorPayload || {}, quote: quote || null }),
})
return jsonOrThrow(res)
}
export async function mergePR(slug, prNumber) {
const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/merge`, { method: 'POST' })
return jsonOrThrow(res)
}
export async function withdrawPR(slug, prNumber) {
const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/withdraw`, { method: 'POST' })
return jsonOrThrow(res)
}
export async function editPRDescription(slug, prNumber, { title, description }) {
const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/description`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description }),
})
return jsonOrThrow(res)
}
export async function startResolutionBranch(slug, prNumber) {
const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/resolution-branch`, {
method: 'POST',
})
return jsonOrThrow(res)
}
// Stream a chat turn into a per-branch thread. Calls onChunk for each
// text fragment, onChanges when the trailing `changes` event arrives,
// and onDone at the terminal DONE marker. Returns the response headers
// (so the caller can pull X-Assistant-Message-Id without re-streaming).
export async function streamChatTurn(slug, branch, threadId, { text, quote, model }, { onChunk, onChanges, onDone }) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/chat`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, quote, model }),
},
)
if (!res.ok) {
const detail = await res.text()
throw new Error(`Chat failed: ${detail || res.status}`)
}
const assistantId = res.headers.get('X-Assistant-Message-Id')
const userMsgId = res.headers.get('X-User-Message-Id')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let currentEvent = null
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split('\n\n')
buffer = parts.pop()
for (const part of parts) {
const lines = part.split('\n')
let dataLine = null
let event = null
for (const line of lines) {
if (line.startsWith('event: ')) event = line.slice(7).trim()
if (line.startsWith('data: ')) dataLine = line.slice(6).trim()
}
if (dataLine === null) continue
if (event === 'changes') {
try { onChanges?.(JSON.parse(dataLine)) } catch {}
continue
}
if (dataLine === 'DONE') { onDone?.(); break }
try {
const text = new TextDecoder().decode(
Uint8Array.from(atob(dataLine), c => c.charCodeAt(0))
)
onChunk?.(text)
} catch {
// partial chunk
}
}
}
onDone?.()
return { assistantId, userMsgId }
}