4e7410f90b
§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>
193 lines
8.8 KiB
Python
193 lines
8.8 KiB
Python
"""G-15 — the branch/body subsystem is three-tier (project/collection) aware.
|
|
|
|
Before G-15 the branch-body GET resolved every meta-resident entry to the
|
|
default project's content repo at `rfcs/<slug>.md`, so an entry in a named
|
|
collection (subfolder) or a non-default project's repo rendered a BLANK
|
|
canonical body and its edit/PR/body-write paths hit the wrong file. These tests
|
|
seed entries outside the default collection and assert the collection-scoped
|
|
body-read routes (and the now-collection-aware slug-only routes) read the
|
|
correct repo + subfolder.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
from app import cache as cache_mod, db, gitea as gitea_mod
|
|
from app.config import load_config
|
|
|
|
from fastapi.testclient import TestClient # noqa: E402
|
|
from test_propose_vertical import ( # noqa: E402,F401
|
|
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
|
)
|
|
|
|
|
|
def _entry_md(slug, title, state="active"):
|
|
return f"---\nslug: {slug}\ntitle: {title}\nstate: {state}\n---\nthe canonical body\n"
|
|
|
|
|
|
def _add_features_collection():
|
|
"""A named 'features' collection (subfolder 'features') under the seeded
|
|
default project — same content repo ('meta'), distinct subfolder."""
|
|
db.conn().execute(
|
|
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, "
|
|
"initial_state, visibility, name, created_at, updated_at) VALUES "
|
|
"('features','default','document','features','super-draft','public','Features', "
|
|
"datetime('now'), datetime('now'))")
|
|
|
|
|
|
def _add_distinct_project():
|
|
"""A second project with its OWN content repo + a default (root) collection
|
|
— the live OHM dogfood shape (rfc-app project at rfc-app-content)."""
|
|
db.conn().execute(
|
|
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
|
|
"VALUES ('rfc-app','RFC App','rfc-app-content','public', datetime('now'))")
|
|
db.conn().execute(
|
|
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, "
|
|
"initial_state, visibility, name, created_at, updated_at) VALUES "
|
|
"('rfc-app','rfc-app','document','','super-draft','public','RFC App', "
|
|
"datetime('now'), datetime('now'))")
|
|
|
|
|
|
def _mirror():
|
|
cfg = load_config()
|
|
asyncio.run(cache_mod.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
|
|
|
|
|
|
# --- named collection (subfolder) under the default project -------------------
|
|
|
|
def test_branch_view_named_collection_reads_subfolder(app_with_fake_gitea):
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_add_features_collection()
|
|
fake.files[("wiggleverse", "meta", "main", "features/rfcs/feat.md")] = {
|
|
"content": _entry_md("feat", "Feature Entry"), "sha": "sf"}
|
|
_mirror()
|
|
# cached_rfcs is keyed by the named collection.
|
|
assert db.conn().execute(
|
|
"SELECT collection_id FROM cached_rfcs WHERE slug='feat'"
|
|
).fetchone()["collection_id"] == "features"
|
|
|
|
# Collection-scoped branch view renders the body (was blank pre-G-15).
|
|
r = client.get(
|
|
"/api/projects/default/collections/features/rfcs/feat/branches/main")
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["body"] == "the canonical body\n"
|
|
|
|
# The slug-only legacy route is now collection-aware too: it resolves
|
|
# the entry's own subfolder via the cached row, so it also renders.
|
|
r2 = client.get("/api/rfcs/feat/branches/main")
|
|
assert r2.status_code == 200, r2.text
|
|
assert r2.json()["body"] == "the canonical body\n"
|
|
|
|
|
|
def test_main_view_named_collection_scoped_route(app_with_fake_gitea):
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_add_features_collection()
|
|
fake.files[("wiggleverse", "meta", "main", "features/rfcs/feat.md")] = {
|
|
"content": _entry_md("feat", "Feature Entry"), "sha": "sf"}
|
|
_mirror()
|
|
r = client.get("/api/projects/default/collections/features/rfcs/feat/main")
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["title"] == "Feature Entry"
|
|
|
|
|
|
# --- non-default project with a DISTINCT content repo (the OHM dogfood) -------
|
|
|
|
def test_branch_view_distinct_project_repo(app_with_fake_gitea):
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_add_distinct_project()
|
|
fake.files[("wiggleverse", "rfc-app-content", "main", "rfcs/scoped.md")] = {
|
|
"content": _entry_md("scoped", "Scoped Admin IA"), "sha": "s1"}
|
|
_mirror()
|
|
assert db.conn().execute(
|
|
"SELECT collection_id FROM cached_rfcs WHERE slug='scoped'"
|
|
).fetchone()["collection_id"] == "rfc-app"
|
|
|
|
# Scoped read resolves the entry's OWN content repo (rfc-app-content).
|
|
r = client.get(
|
|
"/api/projects/rfc-app/collections/rfc-app/rfcs/scoped/branches/main")
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["body"] == "the canonical body\n"
|
|
|
|
# And the slug-only route resolves the right repo via the cached row.
|
|
r2 = client.get("/api/rfcs/scoped/branches/main")
|
|
assert r2.status_code == 200, r2.text
|
|
assert r2.json()["body"] == "the canonical body\n"
|
|
|
|
|
|
# --- guards + regression ------------------------------------------------------
|
|
|
|
def test_scoped_branch_route_404_for_collection_outside_project(app_with_fake_gitea):
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
r = client.get(
|
|
"/api/projects/default/collections/nope/rfcs/x/branches/main")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def _seed_super_draft_in_collection(fake, *, slug, collection_id, subfolder, owners):
|
|
import json as _json
|
|
|
|
import yaml
|
|
md_path = f"{subfolder}/rfcs/{slug}.md" if subfolder else f"rfcs/{slug}.md"
|
|
fm = {"slug": slug, "title": slug.title(), "state": "super-draft", "id": None,
|
|
"repo": None, "proposed_by": owners[0], "proposed_at": "2026-05-23",
|
|
"graduated_at": None, "graduated_by": None,
|
|
"owners": owners, "arbiters": owners[:1], "tags": []}
|
|
body = "the body\n"
|
|
text = f"---\n{yaml.safe_dump(fm, sort_keys=False).rstrip()}\n---\n\n{body}"
|
|
sha = fake._next_sha()
|
|
fake.files[("wiggleverse", "meta", "main", md_path)] = {"content": text, "sha": sha}
|
|
db.conn().execute(
|
|
"INSERT OR REPLACE INTO cached_rfcs (slug, title, state, rfc_id, repo, "
|
|
"proposed_by, proposed_at, owners_json, arbiters_json, tags_json, body, "
|
|
"body_sha, collection_id, last_main_commit_at, last_entry_commit_at) "
|
|
"VALUES (?,?, 'super-draft', NULL, NULL, ?, '2026-05-23', ?, ?, '[]', ?, ?, ?, "
|
|
"datetime('now'), datetime('now'))",
|
|
(slug, slug.title(), owners[0], _json.dumps(owners), _json.dumps(owners[:1]),
|
|
body, sha, collection_id))
|
|
|
|
|
|
def test_graduate_in_named_collection_writes_to_subfolder(app_with_fake_gitea):
|
|
"""G-15 write path: graduating a super-draft that lives in a named
|
|
collection flips the entry in that collection's `<subfolder>/rfcs/<slug>.md`,
|
|
not the default `rfcs/<slug>.md`."""
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_add_features_collection()
|
|
provision_user_row(user_id=1, login="ben", role="owner")
|
|
_seed_super_draft_in_collection(
|
|
fake, slug="gradme", collection_id="features", subfolder="features",
|
|
owners=["ben"])
|
|
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
|
role="owner", email="ben@x")
|
|
r = client.post("/api/rfcs/gradme/graduate?_sync=1",
|
|
json={"rfc_id": "RFC-0007", "owners": ["ben"]})
|
|
assert r.status_code == 200, r.text
|
|
# The flip landed in the collection's subfolder, not the repo root.
|
|
sc = fake.files.get(
|
|
("wiggleverse", "meta", "main", "features/rfcs/gradme.meta.yaml"))
|
|
assert sc is not None, "sidecar not written under the collection subfolder"
|
|
import yaml as _yaml
|
|
assert _yaml.safe_load(sc["content"])["state"] == "active"
|
|
# Nothing was written to the default repo-root path.
|
|
assert ("wiggleverse", "meta", "main", "rfcs/gradme.meta.yaml") not in fake.files
|
|
|
|
|
|
def test_default_collection_entry_still_renders(app_with_fake_gitea):
|
|
"""Regression: the default-collection path (repo root `rfcs/<slug>.md` in
|
|
the default content repo) is unchanged by the G-15 resolution."""
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/base.md")] = {
|
|
"content": _entry_md("base", "Baseline"), "sha": "sb"}
|
|
_mirror()
|
|
r = client.get("/api/rfcs/base/branches/main")
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["body"] == "the canonical body\n"
|
|
r2 = client.get("/api/projects/default/collections/default/rfcs/base/branches/main")
|
|
assert r2.status_code == 200, r2.text
|
|
assert r2.json()["body"] == "the canonical body\n"
|