fix(§22/G-15): make the branch/edit/body subsystem three-tier aware (v0.53.0)

§22 migrated the READ/catalog path to the three-tier (project/collection)
model but the WRITE/branch/body subsystem still hardcoded the default
project's default collection — resolving every meta-resident entry to the
default content repo at rfcs/<slug>.md, ignoring the entry's project (its own
content repo) and collection (a <subfolder>/rfcs/ prefix). An entry outside
the default collection rendered a blank canonical body and every edit/PR/
body-write path hit the wrong file.

- New single resolver: projects.content_repo_for_collection() +
  projects.entry_location(config, cid, slug) -> (org, repo, md_path)
  (collection -> project -> content_repo + subfolder; falls back to the
  default repo for a legacy/unknown collection).
- Every entry write/read path resolves via it: api_branches (body GET + all
  branch write paths), api_prs (pr-draft/open/merge/withdraw/review/
  resolution-branch), api_graduation (graduate/claim/retire/unretire +
  orchestrator + state-flip), api.py mark-reviewed + idea-PR merge/decline/
  withdraw + proposal preview, api_metadata. bot.open_metadata_pr and
  bot.mark_entry_reviewed gained a file_path param. refresh_meta_branches,
  the webhook corpus-refresh dispatch, and the hygiene branch-delete now
  span every project's content repo, not just the default.
- New additive collection-scoped body-read routes:
  GET /api/projects/{pid}/collections/{cid}/rfcs/{slug}/main and
  .../branches/{branch} disambiguate a slug across collections (G-5) and read
  the entry's own repo. Slug-only routes kept (now collection-aware via the
  cached row). Frontend getRFCMain/getBranch take optional pid+cid; RFCView
  threads them (mirrors the v0.52.1 entry-detail fix).

No migration, no config change. Existing default-collection entries
unaffected. Tests: backend resolver + collection-scoped branch/body + graduate-
in-subfolder write path; frontend api unit. backend 677 / frontend 60 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-08 22:39:46 -07:00
parent afa8d26378
commit 4e7410f90b
18 changed files with 720 additions and 152 deletions
+40
View File
@@ -0,0 +1,40 @@
// §22/G-15 — getRFCMain/getBranch build collection-scoped URLs when a project +
// collection are supplied (so an entry outside the default collection reads its
// own content repo / subfolder), and fall back to the slug-only routes otherwise.
import { describe, it, expect, vi, afterEach } from 'vitest'
import { getRFCMain, getBranch } from './api.js'
function mockFetch() {
const fn = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}) }))
global.fetch = fn
return fn
}
afterEach(() => { vi.restoreAllMocks() })
describe('G-15 collection-scoped branch/body URLs', () => {
it('getRFCMain scopes to project+collection when both are given', async () => {
const f = mockFetch()
await getRFCMain('login', 'rfc-app', 'specs')
expect(f).toHaveBeenCalledWith('/api/projects/rfc-app/collections/specs/rfcs/login/main')
})
it('getRFCMain falls back to the slug-only route without a collection', async () => {
const f = mockFetch()
await getRFCMain('login')
expect(f).toHaveBeenCalledWith('/api/rfcs/login/main')
})
it('getBranch scopes to project+collection and encodes the branch', async () => {
const f = mockFetch()
await getBranch('login', 'edit/foo', 'rfc-app', 'specs')
expect(f).toHaveBeenCalledWith(
'/api/projects/rfc-app/collections/specs/rfcs/login/branches/edit%2Ffoo')
})
it('getBranch falls back to the slug-only route without a collection', async () => {
const f = mockFetch()
await getBranch('login', 'main')
expect(f).toHaveBeenCalledWith('/api/rfcs/login/branches/main')
})
})
+18 -2
View File
@@ -448,11 +448,27 @@ export async function listModels(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/models`))
}
export async function getRFCMain(slug) {
export async function getRFCMain(slug, projectId, collectionId) {
// §22/G-15: when the caller knows the entry's project + collection, read the
// collection-scoped route so a slug that exists in two collections resolves
// unambiguously and the entry's own content repo / subfolder is used. The
// bare-slug form stays for back-compat (default-collection callers).
if (projectId && collectionId) {
return jsonOrThrow(await fetch(
`/api/projects/${projectId}/collections/${collectionId}/rfcs/${slug}/main`
))
}
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/main`))
}
export async function getBranch(slug, branch) {
export async function getBranch(slug, branch, projectId, collectionId) {
// §22/G-15: collection-scoped branch-body read (the canonical-body GET); see
// getRFCMain for the rationale. Falls back to the slug-only route.
if (projectId && collectionId) {
return jsonOrThrow(await fetch(
`/api/projects/${projectId}/collections/${collectionId}/rfcs/${slug}/branches/${encodeURIComponent(branch)}`
))
}
return jsonOrThrow(await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}`
))
+14 -13
View File
@@ -221,8 +221,8 @@ export default function RFCView({ viewer }) {
setSelection(null)
setMode('discuss')
getRFCMain(slug).then(setMainView).catch(err => setError(err.message))
getBranch(slug, branchParam)
getRFCMain(slug, pid, cid).then(setMainView).catch(err => setError(err.message))
getBranch(slug, branchParam, pid, cid)
.then(view => {
setBranchView(view)
setEditorContent(view.body || '')
@@ -231,7 +231,8 @@ export default function RFCView({ viewer }) {
setChanges(view.changes || [])
})
.catch(err => setError(err.message))
}, [slug, branchParam, entry])
// §22/G-15: pid+cid scope the body reads; re-run if the collection changes.
}, [slug, branchParam, entry, pid, cid])
// Load chat messages whenever the branch's main thread id resolves.
useEffect(() => {
@@ -266,7 +267,7 @@ export default function RFCView({ viewer }) {
paragraphCount: manualPending?.paragraphCount || 1,
})
if (!res.noop) {
const fresh = await getBranch(slug, branchParam)
const fresh = await getBranch(slug, branchParam, pid, cid)
setBranchView(fresh)
setChanges(fresh.changes || [])
setPreviewContent(fresh.body || '')
@@ -408,7 +409,7 @@ export default function RFCView({ viewer }) {
))
}
// Re-pull authoritative state: changes have been materialized server-side.
const fresh = await getBranch(slug, branchParam)
const fresh = await getBranch(slug, branchParam, pid, cid)
setChanges(fresh.changes || [])
// If we're in discuss mode and the new turn produced pending AI changes,
// surface them as discuss-mode buffered count.
@@ -447,7 +448,7 @@ export default function RFCView({ viewer }) {
anchor_payload: { quote },
label,
})
const fresh = await getBranch(slug, branchParam)
const fresh = await getBranch(slug, branchParam, pid, cid)
setBranchView(fresh)
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
} catch (err) {
@@ -463,7 +464,7 @@ export default function RFCView({ viewer }) {
// body. The proper tracked-changes overlay lives in Phase 3's
// preview pane; with CM6 as the source editor there is no HTML
// surface to inject `<span class="tracked-*">` into.
const fresh = await getBranch(slug, branchParam)
const fresh = await getBranch(slug, branchParam, pid, cid)
setBranchView(fresh)
setChanges(fresh.changes || [])
setEditorContent(fresh.body || '')
@@ -477,7 +478,7 @@ export default function RFCView({ viewer }) {
const handleDecline = useCallback(async (changeId) => {
try {
await apiDecline(slug, branchParam, changeId)
const fresh = await getBranch(slug, branchParam)
const fresh = await getBranch(slug, branchParam, pid, cid)
setChanges(fresh.changes || [])
} catch (err) {
setError(err.message)
@@ -487,7 +488,7 @@ export default function RFCView({ viewer }) {
const handleReask = useCallback(async (changeId) => {
try {
await reaskChange(slug, branchParam, changeId)
const fresh = await getBranch(slug, branchParam)
const fresh = await getBranch(slug, branchParam, pid, cid)
setBranchView(fresh)
setChanges(fresh.changes || [])
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
@@ -499,7 +500,7 @@ export default function RFCView({ viewer }) {
const handleResolveThread = useCallback(async (threadId) => {
try {
await resolveThread(slug, branchParam, threadId)
const fresh = await getBranch(slug, branchParam)
const fresh = await getBranch(slug, branchParam, pid, cid)
setBranchView(fresh)
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
} catch (err) {
@@ -977,7 +978,7 @@ export default function RFCView({ viewer }) {
current={branchView.visibility}
onClose={() => setShowVisibility(false)}
onSaved={async () => {
const fresh = await getBranch(slug, branchParam)
const fresh = await getBranch(slug, branchParam, pid, cid)
setBranchView(fresh)
setShowVisibility(false)
}}
@@ -993,7 +994,7 @@ export default function RFCView({ viewer }) {
setShowGraduateDialog(false)
// The catalog row and the RFC view now reflect `active`.
getRFC(pid, slug, cid).then(setEntry).catch(() => {})
getRFCMain(slug).then(setMainView).catch(() => {})
getRFCMain(slug, pid, cid).then(setMainView).catch(() => {})
}}
/>
)}
@@ -1016,7 +1017,7 @@ export default function RFCView({ viewer }) {
setShowMetadataPane(false)
// Refresh main view so the new open PR surfaces in the
// breadcrumb meta count immediately.
getRFCMain(slug).then(setMainView).catch(() => {})
getRFCMain(slug, pid, cid).then(setMainView).catch(() => {})
navigate(entryPrPath(pid, slug, prNumber))
}}
/>