fix(slice4): make all entry write paths sidecar-aware (§22.4a carried from SLICE-1)
graduate, claim, retire/unretire, _read_meta_entry, mark_entry_reviewed, body extract/wrap (api_branches + api_prs replay) now dual-read and write metadata to the sidecar via write_entry_files + bot.commit_entry_files/ open_entry_pr — a migrated body-only .md no longer crashes entry.parse or re-grows frontmatter; legacy entries lazy-migrate on first metadata write. Existing tests updated to assert the sidecar (INV-2 clean docs). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,18 @@ PITCH = (
|
||||
)
|
||||
|
||||
|
||||
def _entry_from_git(fake, slug, branch="main"):
|
||||
"""§22.4a SLICE-4: read an entry's combined metadata+body from git via the
|
||||
dual-read parser — graduation/claim now write metadata to the sidecar and
|
||||
keep the body in the `.md`, so an Entry is reconstructed from both."""
|
||||
from app import metadata
|
||||
md = fake.files[("wiggleverse", "meta", branch, f"rfcs/{slug}.md")]["content"]
|
||||
sc = fake.files.get(
|
||||
("wiggleverse", "meta", branch, f"rfcs/{slug}.meta.yaml"), {}).get("content")
|
||||
e, _ = metadata.read_entry(md, sc, fallback_slug=slug)
|
||||
return e
|
||||
|
||||
|
||||
def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str,
|
||||
owners: list[str], arbiters: list[str] | None = None,
|
||||
proposed_by: str = "alice", tags: list[str] | None = None) -> None:
|
||||
@@ -190,9 +202,8 @@ def test_graduate_happy_path_flips_in_place_keeping_body(app_with_fake_gitea):
|
||||
k[1].startswith("rfc-0042") for k in fake.repos
|
||||
), f"a per-RFC repo was created: {fake.repos}"
|
||||
|
||||
# Meta entry on main: state flipped, body KEPT, repo null.
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
graduated = entry_mod.parse(meta_text)
|
||||
# Meta entry on main: state flipped (sidecar), body KEPT (.md), repo null.
|
||||
graduated = _entry_from_git(fake, "ohm")
|
||||
assert graduated.state == "active"
|
||||
assert graduated.id == "RFC-0042"
|
||||
assert graduated.repo is None
|
||||
@@ -247,7 +258,7 @@ def test_graduate_preserves_unknown_frontmatter_keys(app_with_fake_gitea):
|
||||
json={"rfc_id": "RFC-0042", "owners": ["ben"]})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
graduated = entry_mod.parse(fake.files[key]["content"])
|
||||
graduated = _entry_from_git(fake, "ohm")
|
||||
assert graduated.state == "active"
|
||||
assert graduated.extra.get("priority") == "P1"
|
||||
|
||||
@@ -599,8 +610,7 @@ def test_graduate_without_number_flips_to_active_null_id_by_slug(app_with_fake_g
|
||||
assert d["rfc_id"] is None
|
||||
|
||||
# Meta entry: active, id null, body kept, graduation stamped.
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
graduated = entry_mod.parse(meta_text)
|
||||
graduated = _entry_from_git(fake, "ohm")
|
||||
assert graduated.state == "active"
|
||||
assert graduated.id is None
|
||||
assert graduated.graduated_by == "ben"
|
||||
@@ -649,9 +659,7 @@ def test_graduate_with_number_unchanged_when_id_absent_field(app_with_fake_gitea
|
||||
r = client.post("/api/rfcs/ohm/graduate?_sync=1", json={"owners": ["ben"]})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["rfc_id"] is None
|
||||
graduated = entry_mod.parse(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
)
|
||||
graduated = _entry_from_git(fake, "ohm")
|
||||
assert graduated.state == "active"
|
||||
assert graduated.id is None
|
||||
|
||||
@@ -677,8 +685,7 @@ def test_claim_opens_meta_pr(app_with_fake_gitea):
|
||||
d = r.json()
|
||||
assert d["branch_name"] == "claim/ohm"
|
||||
|
||||
text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"]
|
||||
ent = entry_mod.parse(text)
|
||||
ent = _entry_from_git(fake, "ohm", branch="claim/ohm")
|
||||
assert "alice" in ent.owners
|
||||
|
||||
row = db.conn().execute(
|
||||
|
||||
@@ -47,12 +47,15 @@ def test_mark_reviewed_clears_flag(app_with_fake_gitea):
|
||||
assert row["unreviewed"] == 0
|
||||
assert row["reviewed_by"] == "ben"
|
||||
assert row["reviewed_at"] # provenance stamped
|
||||
# git-side: the entry file on main was rewritten with the cleared flag.
|
||||
from app import entry as entry_mod
|
||||
# git-side (§22.4a SLICE-4): the cleared flag now lands in the metadata
|
||||
# sidecar and the `.md` is lazy-migrated to a clean body-only file (INV-2).
|
||||
import yaml
|
||||
sidecar = fake.files[("wiggleverse", "meta", "main", "rfcs/feat.meta.yaml")]["content"]
|
||||
sc = yaml.safe_load(sidecar)
|
||||
assert not sc.get("unreviewed") # cleared (omitted when False)
|
||||
assert sc.get("reviewed_by") == "ben"
|
||||
written = fake.files[("wiggleverse", "meta", "main", "rfcs/feat.md")]["content"]
|
||||
e = entry_mod.parse(written)
|
||||
assert e.unreviewed is False
|
||||
assert e.reviewed_by == "ben"
|
||||
assert "---" not in written # body-only, no frontmatter
|
||||
|
||||
|
||||
def test_mark_reviewed_forbidden_for_non_superuser(app_with_fake_gitea):
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""SLICE-4 — write paths are sidecar-aware: a migrated (body-only `.md` +
|
||||
sidecar) entry never crashes `entry.parse` nor re-grows frontmatter."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from app import gitea as gitea_mod, metadata
|
||||
from app.bot import Actor, Bot
|
||||
from app.config import load_config
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
BODY_ONLY = "Alpha prose body.\n"
|
||||
SIDECAR = ("slug: alpha\ntitle: Alpha\nstate: active\n"
|
||||
"owners:\n- ben.stull\ntags:\n- one\npriority: P1\n")
|
||||
|
||||
|
||||
def _seed_migrated(fake, repo="meta"):
|
||||
fake.files[("wiggleverse", repo, "main", "rfcs/alpha.md")] = {
|
||||
"content": BODY_ONLY, "sha": "m1"}
|
||||
fake.files[("wiggleverse", repo, "main", "rfcs/alpha.meta.yaml")] = {
|
||||
"content": SIDECAR, "sha": "m2"}
|
||||
|
||||
|
||||
def _actor():
|
||||
return Actor(user_id=1, gitea_login="ben.stull", display_name="Ben", email="ben@x.io")
|
||||
|
||||
|
||||
def test_mark_entry_reviewed_on_migrated_entry(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
_seed_migrated(fake)
|
||||
gitea = gitea_mod.Gitea(load_config())
|
||||
bot = Bot(gitea)
|
||||
with TestClient(app):
|
||||
provision_user_row(user_id=1, login="ben.stull", role="owner")
|
||||
# Must not raise (legacy code parsed body-only .md → ValueError).
|
||||
asyncio.run(bot.mark_entry_reviewed(
|
||||
_actor(), org="wiggleverse", meta_repo="meta", slug="alpha",
|
||||
reviewed_by="ben.stull", reviewed_at="2026-06-07"))
|
||||
md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
|
||||
sc = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"]
|
||||
assert "---" not in md # body stays clean (no re-grown FM)
|
||||
assert "reviewed_by: ben.stull" in sc # review stamp landed in the sidecar
|
||||
|
||||
|
||||
# ---- Task 3.2: body extract/wrap helpers (api_branches) ----
|
||||
|
||||
def test_extract_wrap_body_on_body_only_md():
|
||||
from app import api_branches
|
||||
rfc = {"state": "super-draft", "repo": None, "slug": "alpha", "collection_id": "default"}
|
||||
body = api_branches._extract_body_pure(rfc, BODY_ONLY, "main", is_meta=True)
|
||||
assert body == BODY_ONLY
|
||||
wrapped = api_branches._wrap_body_pure(rfc, BODY_ONLY, "new body\n", "main", is_meta=True)
|
||||
assert wrapped == "new body\n" # stays clean — no re-grown frontmatter
|
||||
|
||||
|
||||
def test_wrap_body_preserves_legacy_frontmatter():
|
||||
from app import api_branches
|
||||
legacy = "---\nslug: alpha\ntitle: Alpha\nstate: active\n---\n\nold body\n"
|
||||
rfc = {"state": "super-draft", "repo": None, "slug": "alpha", "collection_id": "default"}
|
||||
wrapped = api_branches._wrap_body_pure(rfc, legacy, "new body\n", "main", is_meta=True)
|
||||
assert wrapped.startswith("---") # legacy frontmatter preserved
|
||||
assert "new body" in wrapped
|
||||
|
||||
|
||||
# ---- Task 3.3: PR-replay body wrappers (api_prs) ----
|
||||
|
||||
def test_replay_wrappers_on_body_only():
|
||||
from app import api_prs
|
||||
assert api_prs._extract_body_for_replay(True, BODY_ONLY) == BODY_ONLY
|
||||
out = api_prs._wrap_body_for_replay(True, BODY_ONLY, "new\n")
|
||||
assert out == "new\n" # clean, no re-grown frontmatter
|
||||
legacy = "---\nslug: a\ntitle: A\nstate: active\n---\n\nold\n"
|
||||
out2 = api_prs._wrap_body_for_replay(True, legacy, "new\n")
|
||||
assert out2.startswith("---") # legacy preserved
|
||||
|
||||
|
||||
# ---- Task 3.4: retire a fully-migrated (body-only + sidecar) entry ----
|
||||
|
||||
def test_retire_already_migrated_entry_does_not_crash(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import cache, db
|
||||
from app.config import load_config
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
_seed_migrated(fake)
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
from test_propose_vertical import sign_in_as
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
# Ingest the migrated entry so the catalog/cache knows it.
|
||||
asyncio.run(cache.refresh_meta_repo(load_config(), gitea_mod.Gitea(load_config())))
|
||||
assert db.conn().execute(
|
||||
"SELECT state FROM cached_rfcs WHERE slug='alpha'").fetchone()["state"] == "active"
|
||||
r = client.post("/api/rfcs/alpha/retire")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["state"] == "retired"
|
||||
import yaml as _yaml
|
||||
sc = _yaml.safe_load(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"])
|
||||
assert sc["state"] == "retired"
|
||||
md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
|
||||
assert "---" not in md # body stayed clean
|
||||
|
||||
|
||||
# ---- Task 3.5: graduate a fully-migrated super-draft entry ----
|
||||
|
||||
SUPER_SIDECAR = ("slug: alpha\ntitle: Alpha\nstate: super-draft\n"
|
||||
"owners:\n- ben\ntags:\n- one\npriority: P1\n")
|
||||
|
||||
|
||||
def test_graduate_already_migrated_super_draft(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import cache, db
|
||||
from app.config import load_config
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||
"content": BODY_ONLY, "sha": "m1"}
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")] = {
|
||||
"content": SUPER_SIDECAR, "sha": "m2"}
|
||||
with TestClient(app) as client:
|
||||
from test_propose_vertical import sign_in_as
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
asyncio.run(cache.refresh_meta_repo(load_config(), gitea_mod.Gitea(load_config())))
|
||||
assert db.conn().execute(
|
||||
"SELECT state FROM cached_rfcs WHERE slug='alpha'").fetchone()["state"] == "super-draft"
|
||||
r = client.post("/api/rfcs/alpha/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0007", "owners": ["ben"]})
|
||||
assert r.status_code == 200, r.text
|
||||
import yaml as _yaml
|
||||
sc = _yaml.safe_load(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"])
|
||||
assert sc["state"] == "active"
|
||||
assert sc["id"] == "RFC-0007"
|
||||
assert sc.get("priority") == "P1" # INV-7 carried through graduation
|
||||
md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
|
||||
assert "---" not in md # body stayed clean
|
||||
@@ -57,12 +57,15 @@ def test_rfc_owner_can_retire_and_entry_leaves_every_surface(app_with_fake_gitea
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["state"] == "retired"
|
||||
|
||||
# Meta entry on main: state retired, body + fields kept.
|
||||
meta = entry_mod.parse(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
# §22.4a SLICE-4: the state flip lands in the metadata sidecar and the
|
||||
# `.md` is lazy-migrated to a clean body-only file (INV-2). Fields kept.
|
||||
import yaml as _yaml
|
||||
sc = _yaml.safe_load(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.meta.yaml")]["content"]
|
||||
)
|
||||
assert meta.state == "retired"
|
||||
assert "carol" in meta.owners
|
||||
assert sc["state"] == "retired"
|
||||
assert "carol" in sc["owners"]
|
||||
assert "---" not in fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
|
||||
# Cache flipped; gone from the catalog.
|
||||
cached = db.conn().execute(
|
||||
@@ -177,11 +180,12 @@ def test_site_owner_can_retire_active_and_unretire_restores_active_with_id(app_w
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["state"] == "active"
|
||||
|
||||
meta = entry_mod.parse(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
import yaml as _yaml
|
||||
sc = _yaml.safe_load(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.meta.yaml")]["content"]
|
||||
)
|
||||
assert meta.state == "active"
|
||||
assert meta.id == "RFC-0042"
|
||||
assert sc["state"] == "active"
|
||||
assert sc["id"] == "RFC-0042"
|
||||
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
|
||||
Reference in New Issue
Block a user