diff --git a/backend/app/api_branches.py b/backend/app/api_branches.py index 6d46f4c..2a66b66 100644 --- a/backend/app/api_branches.py +++ b/backend/app/api_branches.py @@ -29,7 +29,7 @@ from fastapi import APIRouter, HTTPException, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field -from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver, projects as projects_mod +from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, metadata as metadata_mod, models_resolver, projects as projects_mod from .bot import Bot from .config import Config from .gitea import Gitea, GiteaError @@ -40,6 +40,37 @@ log = logging.getLogger(__name__) RFC_FILE_PATH = "RFC.md" +# --------------------------------------------------------------------------- +# §22.4a SLICE-4: sidecar-aware body extract/wrap (pure, unit-testable) +# --------------------------------------------------------------------------- + + +def _extract_body_pure(rfc, file_contents: str, branch: str, *, is_meta: bool) -> str: + """Editable body of an entry file. Meta-resident files carry a frontmatter + envelope (legacy) or are already body-only (migrated, §22.4a); per-RFC repo + files are body-only. Dual-read tolerant: a body-only `.md` returns as-is.""" + if not is_meta: + return file_contents + return metadata_mod.strip_frontmatter(file_contents) + + +def _wrap_body_pure(rfc, prior_contents: str, new_body: str, branch: str, *, is_meta: bool) -> str: + """Inverse of `_extract_body_pure`. Under §22.4a the body lives in the `.md` + and metadata in the sidecar, so wrapping is identity for body-only files — + frontmatter is never re-grown here. A legacy un-migrated meta file still has + its metadata in the `.md` frontmatter (no sidecar yet), so preserve it rather + than silently dropping it on a pure body edit; it is migrated to body-only on + its next *metadata* edit.""" + nb = new_body if new_body.endswith("\n") else new_body + "\n" + if not is_meta: + return nb + if entry_mod.FRONTMATTER_RE.match(prior_contents): + e = entry_mod.parse(prior_contents) + e.body = nb + return entry_mod.serialize(e) + return nb + + # --------------------------------------------------------------------------- # Request bodies # --------------------------------------------------------------------------- @@ -1163,28 +1194,12 @@ def make_router( return RFC_FILE_PATH def _extract_body(rfc, file_contents: str, branch: str = "main") -> str: - """For super-draft entries (and active-RFC pre-graduation reads - per §9.8) the file on disk is the full frontmatter+body envelope; - the editable body is entry.body. For active RFCs reading their - per-RFC repo the file is just RFC.md and the whole thing is body.""" - if not _is_meta_target(rfc, branch): - return file_contents - try: - entry = entry_mod.parse(file_contents) - except Exception: - return file_contents - return entry.body + return _extract_body_pure( + rfc, file_contents, branch, is_meta=_is_meta_target(rfc, branch)) def _wrap_body(rfc, prior_contents: str, new_body: str, branch: str = "main") -> str: - """Inverse of _extract_body: re-wrap a new body into the entry - envelope, preserving the prior frontmatter exactly.""" - if not _is_meta_target(rfc, branch): - return new_body - entry = entry_mod.parse(prior_contents) - # Ensure exactly one trailing newline so the serializer's - # round-trip is stable. - entry.body = new_body if new_body.endswith("\n") else new_body + "\n" - return entry_mod.serialize(entry) + return _wrap_body_pure( + rfc, prior_contents, new_body, branch, is_meta=_is_meta_target(rfc, branch)) async def _refresh_cache_for(rfc) -> None: if _is_meta_resident(rfc): diff --git a/backend/app/api_graduation.py b/backend/app/api_graduation.py index 240e26f..6161cf6 100644 --- a/backend/app/api_graduation.py +++ b/backend/app/api_graduation.py @@ -42,7 +42,7 @@ from fastapi import APIRouter, HTTPException, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field -from . import auth, cache, db, entry as entry_mod, projects as projects_mod +from . import auth, cache, db, entry as entry_mod, metadata as metadata_mod, projects as projects_mod from .bot import Actor, Bot from .config import Config from .gitea import Gitea, GiteaError @@ -341,19 +341,16 @@ def make_router( if _rfc_id_taken(rfc_id, excluding_slug=slug): raise HTTPException(409, f"Integer ID {rfc_id} is already taken") - # Read the meta-repo entry once — we need the file's sha for the - # graduation PR's update_file call and the body to carry through + # Dual-read the meta-repo entry once (§22.4a sidecar-aware) — we need its + # git state for the graduation commit and the body to carry through # unchanged (meta-only keeps the body in the entry, §13.3). - fetched = await gitea.read_file( - config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main", + st = await metadata_mod.read_entry_from_git( + gitea, config.gitea_org, + (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ) - if fetched is None: + if st is None: raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main") - meta_text, meta_sha = fetched - try: - super_draft_entry = entry_mod.parse(meta_text) - except Exception as e: - raise HTTPException(500, f"Meta entry malformed: {e}") + super_draft_entry = st.entry arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1] @@ -380,7 +377,8 @@ def make_router( # through graduation rather than dropping them on the rebuild. extra=dict(super_draft_entry.extra), ) - graduated_contents = entry_mod.serialize(graduated_entry) + graduation_files = metadata_mod.write_entry_files( + f"rfcs/{slug}.md", graduated_entry, st) state = _new_active( slug, rfc_id=rfc_id, owners=owners, arbiters=arbiters, @@ -400,8 +398,7 @@ def make_router( coro = _orchestrate( config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(), state=state, - graduated_contents=graduated_contents, - meta_file_sha=meta_sha, + graduation_files=graduation_files, ) if request.query_params.get("_sync") == "1": await coro @@ -481,26 +478,23 @@ def make_router( if already: raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}") - fetched = await gitea.read_file( - config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main", + st = await metadata_mod.read_entry_from_git( + gitea, config.gitea_org, + (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ) - if fetched is None: + if st is None: raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main") - meta_text, meta_sha = fetched - try: - ent = entry_mod.parse(meta_text) - except Exception as e: - raise HTTPException(500, f"Meta entry malformed: {e}") - if viewer.gitea_login in ent.owners: + if viewer.gitea_login in st.entry.owners: return {"ok": True, "noop": True} - ent.owners = ent.owners + [viewer.gitea_login] - new_contents = entry_mod.serialize(ent) + ent = metadata_mod.apply_values( + st.entry, {"owners": st.entry.owners + [viewer.gitea_login]}) + files = metadata_mod.write_entry_files(f"rfcs/{slug}.md", ent, st) try: pr = await bot.open_claim_pr( viewer.as_actor(), org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""), slug=slug, - new_file_contents=new_contents, prior_sha=meta_sha, + files=files, ) except GiteaError as e: raise HTTPException(502, f"Gitea: {e.detail}") @@ -527,11 +521,12 @@ def make_router( 403, "Only this RFC's owners or a site owner may retire it" ) prior_state = rfc["state"] - entry, sha = await _read_meta_entry(slug) - entry.state = "retired" + st = await _read_meta_entry(slug) + entry = metadata_mod.apply_values(st.entry, {"state": "retired"}) + files = metadata_mod.write_entry_files(f"rfcs/{slug}.md", entry, st) await _run_state_flip( config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(), - slug=slug, new_contents=entry_mod.serialize(entry), prior_sha=sha, + slug=slug, files=files, verb="retire", target_state="retired", ) _audit( @@ -557,11 +552,12 @@ def make_router( raise HTTPException(403, "Only a site owner may un-retire an RFC") _require_retired(slug) restored = _prior_state_before_retire(slug) - entry, sha = await _read_meta_entry(slug) - entry.state = restored + st = await _read_meta_entry(slug) + entry = metadata_mod.apply_values(st.entry, {"state": restored}) + files = metadata_mod.write_entry_files(f"rfcs/{slug}.md", entry, st) await _run_state_flip( config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(), - slug=slug, new_contents=entry_mod.serialize(entry), prior_sha=sha, + slug=slug, files=files, verb="unretire", target_state=restored, ) _audit( @@ -602,17 +598,17 @@ def make_router( raise HTTPException(409, f"RFC is {row['state']}, not retired") return row - async def _read_meta_entry(slug: str) -> tuple[entry_mod.Entry, str]: - fetched = await gitea.read_file( - config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main", + async def _read_meta_entry(slug: str): + """Dual-read an entry from meta-main → EntryGitState (sidecar-aware, + §22.4a). A migrated body-only `.md` reads cleanly; never raises on bad + metadata (INV-3).""" + st = await metadata_mod.read_entry_from_git( + gitea, config.gitea_org, + (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ) - if fetched is None: + if st is None: raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main") - text, file_sha = fetched - try: - return entry_mod.parse(text), file_sha - except Exception as e: - raise HTTPException(500, f"Meta entry malformed: {e}") + return st async def _refresh_catalog() -> None: # Inline refresh so the catalog reflects the flip immediately; the @@ -640,8 +636,7 @@ async def _orchestrate( bot: Bot, actor: Actor, state: GraduationState, - graduated_contents: str, - meta_file_sha: str, + graduation_files: list[dict], ) -> None: """Open the flip PR, then merge it. Two steps, no transaction: @@ -661,8 +656,7 @@ async def _orchestrate( actor, org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""), slug=state.slug, - new_file_contents=graduated_contents, - prior_sha=meta_file_sha, + files=graduation_files, rfc_id=state.rfc_id, owners=state.owners, ) @@ -850,12 +844,12 @@ async def _run_state_flip( bot: Bot, actor: Actor, slug: str, - new_contents: str, - prior_sha: str, + files: list[dict], verb: str, target_state: str, ) -> None: - """§13.7: open + merge a retire / un-retire frontmatter flip PR. Runs + """§13.7: open + merge a retire / un-retire state-flip PR. The flip is + written to the entry's metadata sidecar (§22.4a) via `files` ops. Runs inline (no SSE — the flip is a single quick state change, unlike the multi-step graduation that streams progress). On an open failure nothing was created; on a merge failure the half-open PR/branch is @@ -865,7 +859,7 @@ async def _run_state_flip( pr = await bot.open_retire_flip_pr( actor, org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""), - slug=slug, new_file_contents=new_contents, prior_sha=prior_sha, + slug=slug, files=files, verb=verb, target_state=target_state, ) except GiteaError as e: diff --git a/backend/app/api_prs.py b/backend/app/api_prs.py index 25ecc17..d71629c 100644 --- a/backend/app/api_prs.py +++ b/backend/app/api_prs.py @@ -23,7 +23,7 @@ from typing import Any from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field -from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver, projects as projects_mod, rfc_links +from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, metadata as metadata_mod, models_resolver, projects as projects_mod, rfc_links from .bot import Bot from .config import Config from .gitea import Gitea, GiteaError @@ -1009,20 +1009,25 @@ async def _replay_changes( def _extract_body_for_replay(is_super_draft: bool, content: str) -> str: + # §22.4a SLICE-4: a meta-resident entry may be legacy (frontmatter+body) or + # migrated (body-only). strip_frontmatter handles both without raising. if not is_super_draft: return content - try: - return entry_mod.parse(content).body - except Exception: - return content + return metadata_mod.strip_frontmatter(content) def _wrap_body_for_replay(is_super_draft: bool, prior_content: str, new_body: str) -> str: + # §22.4a SLICE-4: identity for body-only (migrated) files — never re-grow + # frontmatter; preserve a legacy file's frontmatter until its next metadata + # edit migrates it. + nb = new_body if new_body.endswith("\n") else new_body + "\n" if not is_super_draft: - return new_body - entry = entry_mod.parse(prior_content) - entry.body = new_body if new_body.endswith("\n") else new_body + "\n" - return entry_mod.serialize(entry) + return nb + if entry_mod.FRONTMATTER_RE.match(prior_content): + entry = entry_mod.parse(prior_content) + entry.body = nb + return entry_mod.serialize(entry) + return nb def _resolution_branch_name(original_branch: str) -> str: diff --git a/backend/app/bot.py b/backend/app/bot.py index eb3609d..f1f363b 100644 --- a/backend/app/bot.py +++ b/backend/app/bot.py @@ -856,35 +856,29 @@ class Bot: org: str, meta_repo: str, slug: str, - new_file_contents: str, - prior_sha: str, + files: list[dict], rfc_id: str | None, owners: list[str], ) -> dict: """§13.3 (meta-only): open a PR against the meta repo that flips the - entry's frontmatter to `state: active` with the graduation stamps - and — **optionally** — the integer `id`, **keeping the body - unchanged** (§1 meta-only topology; no repo is created and no body - is stripped). When `rfc_id` is None the entry graduates without a - number (id stays null, slug is canonical per §2.3, §13.2). Branch - name uses the `graduate--<6hex>` shape — dash-separated like - the other meta-repo branches per the §19.2 path-routing candidate. + entry to `state: active` with the graduation stamps and — **optionally** + — the integer `id`, **keeping the body unchanged** (§1 meta-only + topology; no repo is created and no body is stripped). The graduation + metadata is written to the entry's sidecar (§22.4a) via `files`; a legacy + `.md` is lazy-migrated to body-only in the same commit. When `rfc_id` is + None the entry graduates without a number (id stays null, slug is + canonical per §2.3, §13.2). Branch name uses the `graduate--<6hex>` + shape — dash-separated like the other meta-repo branches per the §19.2 + path-routing candidate. """ import secrets branch = f"graduate-{slug}-{secrets.token_hex(3)}" await self._gitea.create_branch(org, meta_repo, branch, from_branch="main") - ae = actor.email or f"{actor.gitea_login}@users.noreply" commit_subject = f"Graduate {slug} → {rfc_id}" if rfc_id else f"Graduate {slug} (no number)" - commit_message = _stamp_single(commit_subject, actor) - result = await self._gitea.update_file( - org, meta_repo, f"rfcs/{slug}.md", - content=new_file_contents, - sha=prior_sha, - message=commit_message, - branch=branch, - author_name=actor.display_name, author_email=ae, - ) + result = await self.commit_entry_files( + actor, org=org, repo=meta_repo, files=files, + message=commit_subject, branch=branch) commit_sha = ( result.get("commit", {}).get("sha") or result.get("content", {}).get("sha") @@ -973,35 +967,26 @@ class Bot: org: str, meta_repo: str, slug: str, - new_file_contents: str, - prior_sha: str, + files: list[dict], verb: str, target_state: str, ) -> dict: - """§13.7: open a PR flipping `rfcs/.md` to `state: - ` — for retire (`verb='retire'`, target `retired`) - or un-retire (`verb='unretire'`, target the restored prior state). - Only the frontmatter `state` changes; the body and every other - field (including the integer `id`) are kept, so an un-retire - restores the entry exactly. Branch shape mirrors graduation's - `--<6hex>`. + """§13.7: open a PR flipping an entry to `state: ` — for + retire (`verb='retire'`, target `retired`) or un-retire + (`verb='unretire'`, target the restored prior state). The `state` change + is written to the entry's metadata sidecar (§22.4a), keeping the `.md` + body and every other field, so an un-retire restores the entry exactly. + `files` come from `metadata.write_entry_files`. Branch shape mirrors + graduation's `--<6hex>`. """ import secrets branch = f"{verb}-{slug}-{secrets.token_hex(3)}" await self._gitea.create_branch(org, meta_repo, branch, from_branch="main") - ae = actor.email or f"{actor.gitea_login}@users.noreply" verb_title = "Retire" if verb == "retire" else "Un-retire" - commit_subject = f"{verb_title} {slug}" - commit_message = _stamp_single(commit_subject, actor) - result = await self._gitea.update_file( - org, meta_repo, f"rfcs/{slug}.md", - content=new_file_contents, - sha=prior_sha, - message=commit_message, - branch=branch, - author_name=actor.display_name, author_email=ae, - ) + result = await self.commit_entry_files( + actor, org=org, repo=meta_repo, files=files, + message=f"{verb_title} {slug}", branch=branch) commit_sha = ( result.get("commit", {}).get("sha") or result.get("content", {}).get("sha") @@ -1171,30 +1156,23 @@ class Bot: org: str, meta_repo: str, slug: str, - new_file_contents: str, - prior_sha: str, + files: list[dict], ) -> dict: """§13.1: open a PR adding the actor to the entry's `owners:` list. - Touches only the frontmatter of `rfcs/.md`. Branch shape is - `claim/` — single attempt per super-draft per actor (Gitea - refuses duplicate branch creation, which is the right behavior: - if the claim is still open, point the contributor at the existing - PR rather than opening a second one). + Writes the updated `owners:` to the entry's metadata sidecar (§22.4a) + via `files`; a legacy `.md` is lazy-migrated to body-only in the same + commit. Branch shape is `claim/` — single attempt per super-draft + per actor (Gitea refuses duplicate branch creation, which is the right + behavior: if the claim is still open, point the contributor at the + existing PR rather than opening a second one). """ branch = f"claim/{slug}" await self._gitea.create_branch(org, meta_repo, branch, from_branch="main") - ae = actor.email or f"{actor.gitea_login}@users.noreply" commit_subject = f"Claim ownership of {slug} for {actor.gitea_login}" - commit_message = _stamp_single(commit_subject, actor) - result = await self._gitea.update_file( - org, meta_repo, f"rfcs/{slug}.md", - content=new_file_contents, - sha=prior_sha, - message=commit_message, - branch=branch, - author_name=actor.display_name, author_email=ae, - ) + result = await self.commit_entry_files( + actor, org=org, repo=meta_repo, files=files, + message=commit_subject, branch=branch) commit_sha = ( result.get("commit", {}).get("sha") or result.get("content", {}).get("sha") @@ -1233,29 +1211,22 @@ class Bot: reviewed_by: str, reviewed_at: str, ) -> None: - """Clear §22.4c unreviewed on an active entry by rewriting its - frontmatter on main. Stamps the commit with the §6.5 On-behalf-of - trailer and writes an actions-log row, mirroring the graduation - stamp's bot-write shape.""" + """Clear §22.4c unreviewed on an active entry by writing its metadata + sidecar on main (§22.4a). Dual-reads the entry (so a migrated body-only + `.md` doesn't crash) and lazy-migrates a legacy `.md` to body-only in the + same commit. Stamps the §6.5 On-behalf-of trailer and writes an + actions-log row, mirroring the graduation stamp's bot-write shape.""" path = f"rfcs/{slug}.md" - result = await self._gitea.read_file(org, meta_repo, path, ref="main") - if result is None: + st = await metadata_mod.read_entry_from_git(self._gitea, org, meta_repo, path) + if st is None: raise GiteaError(404, f"{path} not found") - text, sha = result - e = entry_mod.parse(text) - e.unreviewed = False - e.reviewed_at = reviewed_at - e.reviewed_by = reviewed_by - commit_message = _stamp_single(f"Mark {slug} reviewed", actor) - result = await self._gitea.update_file( - org, meta_repo, path, - content=entry_mod.serialize(e), - sha=sha, - message=commit_message, - branch="main", - author_name=actor.display_name, - author_email=actor.email or f"{actor.gitea_login}@users.noreply", - ) + e = metadata_mod.apply_values(st.entry, { + "unreviewed": False, "reviewed_at": reviewed_at, "reviewed_by": reviewed_by, + }) + files = metadata_mod.write_entry_files(path, e, st) + result = await self.commit_entry_files( + actor, org=org, repo=meta_repo, files=files, + message=f"Mark {slug} reviewed", branch="main") commit_sha = ( result.get("commit", {}).get("sha") or result.get("content", {}).get("sha") diff --git a/backend/tests/test_graduation_vertical.py b/backend/tests/test_graduation_vertical.py index 0706518..2c491d2 100644 --- a/backend/tests/test_graduation_vertical.py +++ b/backend/tests/test_graduation_vertical.py @@ -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( diff --git a/backend/tests/test_mark_reviewed.py b/backend/tests/test_mark_reviewed.py index 6db5c5c..ff8a012 100644 --- a/backend/tests/test_mark_reviewed.py +++ b/backend/tests/test_mark_reviewed.py @@ -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): diff --git a/backend/tests/test_metadata_writepaths.py b/backend/tests/test_metadata_writepaths.py new file mode 100644 index 0000000..01c8947 --- /dev/null +++ b/backend/tests/test_metadata_writepaths.py @@ -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 diff --git a/backend/tests/test_retire_vertical.py b/backend/tests/test_retire_vertical.py index 0561436..fe60417 100644 --- a/backend/tests/test_retire_vertical.py +++ b/backend/tests/test_retire_vertical.py @@ -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'"