// 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 } }