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:
@@ -0,0 +1,192 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,108 @@
|
||||
"""G-15 — the §22 three-tier write-path resolver.
|
||||
|
||||
`projects.content_repo_for_collection` and `projects.entry_location` resolve an
|
||||
entry's git location (org, content_repo, md_path) from its *collection*
|
||||
(collection → project → content_repo, plus the collection subfolder) instead of
|
||||
the deployment default. This is the keystone the branch/edit/body/graduation
|
||||
write paths share so an entry outside the default project's default collection
|
||||
reads/writes the correct file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from app import collections as collections_mod, db, projects as projects_mod
|
||||
from app.config import Config
|
||||
|
||||
|
||||
def _db() -> Config:
|
||||
cfg = Config(
|
||||
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="wiggleverse",
|
||||
registry_repo="registry", oauth_client_id="x",
|
||||
oauth_client_secret="x", app_url="x", secret_key="x",
|
||||
database_path=Path(tempfile.mkdtemp(prefix="g15loc-")) / "t.db",
|
||||
owner_gitea_login="x", webhook_secret="x",
|
||||
)
|
||||
db.run_migrations(cfg)
|
||||
if db._CONN is not None:
|
||||
db._CONN.close()
|
||||
db._CONN = None
|
||||
db.init(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
def _seed():
|
||||
# Default project (its content_repo is the deployment default) + a second
|
||||
# project with a DISTINCT content_repo, each with a default + a named
|
||||
# (subfolder) collection.
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
|
||||
"VALUES ('default','Default','meta','public', datetime('now'))")
|
||||
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'))")
|
||||
rows = [
|
||||
("default", "default", ""),
|
||||
("features", "default", "features"),
|
||||
("rfc-app", "rfc-app", ""),
|
||||
("specs", "rfc-app", "specs"),
|
||||
]
|
||||
for cid, pid, sub in rows:
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, "
|
||||
"initial_state, visibility, created_at, updated_at) VALUES "
|
||||
"(?,?, 'document', ?, 'super-draft','public', datetime('now'), datetime('now'))",
|
||||
(cid, pid, sub))
|
||||
|
||||
|
||||
def test_content_repo_for_collection_resolves_per_project():
|
||||
_db()
|
||||
_seed()
|
||||
# Default project's collections → the default content repo.
|
||||
assert projects_mod.content_repo_for_collection("default") == "meta"
|
||||
assert projects_mod.content_repo_for_collection("features") == "meta"
|
||||
# The second project's collections → its own content repo.
|
||||
assert projects_mod.content_repo_for_collection("rfc-app") == "rfc-app-content"
|
||||
assert projects_mod.content_repo_for_collection("specs") == "rfc-app-content"
|
||||
|
||||
|
||||
def test_content_repo_for_collection_unknown_is_none():
|
||||
_db()
|
||||
_seed()
|
||||
assert projects_mod.content_repo_for_collection("nope") is None
|
||||
|
||||
|
||||
def test_entry_location_default_collection_repo_root():
|
||||
cfg = _db()
|
||||
_seed()
|
||||
org, repo, path = projects_mod.entry_location(cfg, "default", "alpha")
|
||||
assert (org, repo, path) == ("wiggleverse", "meta", "rfcs/alpha.md")
|
||||
|
||||
|
||||
def test_entry_location_named_collection_uses_subfolder():
|
||||
cfg = _db()
|
||||
_seed()
|
||||
org, repo, path = projects_mod.entry_location(cfg, "features", "beta")
|
||||
assert (org, repo, path) == ("wiggleverse", "meta", "features/rfcs/beta.md")
|
||||
|
||||
|
||||
def test_entry_location_other_project_distinct_repo():
|
||||
cfg = _db()
|
||||
_seed()
|
||||
# Named collection in a non-default project: distinct repo AND subfolder.
|
||||
org, repo, path = projects_mod.entry_location(cfg, "specs", "gamma")
|
||||
assert (org, repo, path) == ("wiggleverse", "rfc-app-content", "specs/rfcs/gamma.md")
|
||||
# Default collection of the non-default project: distinct repo, repo root.
|
||||
org, repo, path = projects_mod.entry_location(cfg, "rfc-app", "delta")
|
||||
assert (org, repo, path) == ("wiggleverse", "rfc-app-content", "rfcs/delta.md")
|
||||
|
||||
|
||||
def test_entry_location_unknown_collection_falls_back_to_default_repo():
|
||||
cfg = _db()
|
||||
_seed()
|
||||
# An entry whose collection_id is missing/unknown must still resolve to a
|
||||
# usable location (the deployment default repo, repo root) rather than an
|
||||
# empty repo — the legacy single-corpus behaviour.
|
||||
org, repo, path = projects_mod.entry_location(cfg, "nope", "epsilon")
|
||||
assert (org, repo, path) == ("wiggleverse", "meta", "rfcs/epsilon.md")
|
||||
Reference in New Issue
Block a user