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:
Ben Stull
2026-06-07 18:57:54 -07:00
parent 734290f344
commit aee9b582e5
8 changed files with 325 additions and 181 deletions
+36 -21
View File
@@ -29,7 +29,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field 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 .bot import Bot
from .config import Config from .config import Config
from .gitea import Gitea, GiteaError from .gitea import Gitea, GiteaError
@@ -40,6 +40,37 @@ log = logging.getLogger(__name__)
RFC_FILE_PATH = "RFC.md" 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 # Request bodies
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1163,28 +1194,12 @@ def make_router(
return RFC_FILE_PATH return RFC_FILE_PATH
def _extract_body(rfc, file_contents: str, branch: str = "main") -> str: def _extract_body(rfc, file_contents: str, branch: str = "main") -> str:
"""For super-draft entries (and active-RFC pre-graduation reads return _extract_body_pure(
per §9.8) the file on disk is the full frontmatter+body envelope; rfc, file_contents, branch, is_meta=_is_meta_target(rfc, branch))
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
def _wrap_body(rfc, prior_contents: str, new_body: str, branch: str = "main") -> str: 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 return _wrap_body_pure(
envelope, preserving the prior frontmatter exactly.""" rfc, prior_contents, new_body, branch, is_meta=_is_meta_target(rfc, branch))
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)
async def _refresh_cache_for(rfc) -> None: async def _refresh_cache_for(rfc) -> None:
if _is_meta_resident(rfc): if _is_meta_resident(rfc):
+43 -49
View File
@@ -42,7 +42,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field 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 .bot import Actor, Bot
from .config import Config from .config import Config
from .gitea import Gitea, GiteaError from .gitea import Gitea, GiteaError
@@ -341,19 +341,16 @@ def make_router(
if _rfc_id_taken(rfc_id, excluding_slug=slug): if _rfc_id_taken(rfc_id, excluding_slug=slug):
raise HTTPException(409, f"Integer ID {rfc_id} is already taken") 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 # Dual-read the meta-repo entry once (§22.4a sidecar-aware) — we need its
# graduation PR's update_file call and the body to carry through # git state for the graduation commit and the body to carry through
# unchanged (meta-only keeps the body in the entry, §13.3). # unchanged (meta-only keeps the body in the entry, §13.3).
fetched = await gitea.read_file( st = await metadata_mod.read_entry_from_git(
config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main", 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") raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
meta_text, meta_sha = fetched super_draft_entry = st.entry
try:
super_draft_entry = entry_mod.parse(meta_text)
except Exception as e:
raise HTTPException(500, f"Meta entry malformed: {e}")
arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1] 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. # through graduation rather than dropping them on the rebuild.
extra=dict(super_draft_entry.extra), 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( state = _new_active(
slug, rfc_id=rfc_id, owners=owners, arbiters=arbiters, slug, rfc_id=rfc_id, owners=owners, arbiters=arbiters,
@@ -400,8 +398,7 @@ def make_router(
coro = _orchestrate( coro = _orchestrate(
config=config, gitea=gitea, bot=bot, config=config, gitea=gitea, bot=bot,
actor=viewer.as_actor(), state=state, actor=viewer.as_actor(), state=state,
graduated_contents=graduated_contents, graduation_files=graduation_files,
meta_file_sha=meta_sha,
) )
if request.query_params.get("_sync") == "1": if request.query_params.get("_sync") == "1":
await coro await coro
@@ -481,26 +478,23 @@ def make_router(
if already: if already:
raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}") raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}")
fetched = await gitea.read_file( st = await metadata_mod.read_entry_from_git(
config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main", 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") raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
meta_text, meta_sha = fetched if viewer.gitea_login in st.entry.owners:
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:
return {"ok": True, "noop": True} return {"ok": True, "noop": True}
ent.owners = ent.owners + [viewer.gitea_login] ent = metadata_mod.apply_values(
new_contents = entry_mod.serialize(ent) st.entry, {"owners": st.entry.owners + [viewer.gitea_login]})
files = metadata_mod.write_entry_files(f"rfcs/{slug}.md", ent, st)
try: try:
pr = await bot.open_claim_pr( pr = await bot.open_claim_pr(
viewer.as_actor(), viewer.as_actor(),
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""), org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
slug=slug, slug=slug,
new_file_contents=new_contents, prior_sha=meta_sha, files=files,
) )
except GiteaError as e: except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}") 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" 403, "Only this RFC's owners or a site owner may retire it"
) )
prior_state = rfc["state"] prior_state = rfc["state"]
entry, sha = await _read_meta_entry(slug) st = await _read_meta_entry(slug)
entry.state = "retired" 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( await _run_state_flip(
config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(), 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", verb="retire", target_state="retired",
) )
_audit( _audit(
@@ -557,11 +552,12 @@ def make_router(
raise HTTPException(403, "Only a site owner may un-retire an RFC") raise HTTPException(403, "Only a site owner may un-retire an RFC")
_require_retired(slug) _require_retired(slug)
restored = _prior_state_before_retire(slug) restored = _prior_state_before_retire(slug)
entry, sha = await _read_meta_entry(slug) st = await _read_meta_entry(slug)
entry.state = restored 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( await _run_state_flip(
config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(), 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, verb="unretire", target_state=restored,
) )
_audit( _audit(
@@ -602,17 +598,17 @@ def make_router(
raise HTTPException(409, f"RFC is {row['state']}, not retired") raise HTTPException(409, f"RFC is {row['state']}, not retired")
return row return row
async def _read_meta_entry(slug: str) -> tuple[entry_mod.Entry, str]: async def _read_meta_entry(slug: str):
fetched = await gitea.read_file( """Dual-read an entry from meta-main → EntryGitState (sidecar-aware,
config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main", §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") raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
text, file_sha = fetched return st
try:
return entry_mod.parse(text), file_sha
except Exception as e:
raise HTTPException(500, f"Meta entry malformed: {e}")
async def _refresh_catalog() -> None: async def _refresh_catalog() -> None:
# Inline refresh so the catalog reflects the flip immediately; the # Inline refresh so the catalog reflects the flip immediately; the
@@ -640,8 +636,7 @@ async def _orchestrate(
bot: Bot, bot: Bot,
actor: Actor, actor: Actor,
state: GraduationState, state: GraduationState,
graduated_contents: str, graduation_files: list[dict],
meta_file_sha: str,
) -> None: ) -> None:
"""Open the flip PR, then merge it. Two steps, no transaction: """Open the flip PR, then merge it. Two steps, no transaction:
@@ -661,8 +656,7 @@ async def _orchestrate(
actor, actor,
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""), org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
slug=state.slug, slug=state.slug,
new_file_contents=graduated_contents, files=graduation_files,
prior_sha=meta_file_sha,
rfc_id=state.rfc_id, rfc_id=state.rfc_id,
owners=state.owners, owners=state.owners,
) )
@@ -850,12 +844,12 @@ async def _run_state_flip(
bot: Bot, bot: Bot,
actor: Actor, actor: Actor,
slug: str, slug: str,
new_contents: str, files: list[dict],
prior_sha: str,
verb: str, verb: str,
target_state: str, target_state: str,
) -> None: ) -> 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 inline (no SSE — the flip is a single quick state change, unlike the
multi-step graduation that streams progress). On an open failure multi-step graduation that streams progress). On an open failure
nothing was created; on a merge failure the half-open PR/branch is 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( pr = await bot.open_retire_flip_pr(
actor, actor,
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""), 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, verb=verb, target_state=target_state,
) )
except GiteaError as e: except GiteaError as e:
+14 -9
View File
@@ -23,7 +23,7 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field 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 .bot import Bot
from .config import Config from .config import Config
from .gitea import Gitea, GiteaError 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: 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: if not is_super_draft:
return content return content
try: return metadata_mod.strip_frontmatter(content)
return entry_mod.parse(content).body
except Exception:
return content
def _wrap_body_for_replay(is_super_draft: bool, prior_content: str, new_body: str) -> str: 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: if not is_super_draft:
return new_body return nb
entry = entry_mod.parse(prior_content) if entry_mod.FRONTMATTER_RE.match(prior_content):
entry.body = new_body if new_body.endswith("\n") else new_body + "\n" entry = entry_mod.parse(prior_content)
return entry_mod.serialize(entry) entry.body = nb
return entry_mod.serialize(entry)
return nb
def _resolution_branch_name(original_branch: str) -> str: def _resolution_branch_name(original_branch: str) -> str:
+48 -77
View File
@@ -856,35 +856,29 @@ class Bot:
org: str, org: str,
meta_repo: str, meta_repo: str,
slug: str, slug: str,
new_file_contents: str, files: list[dict],
prior_sha: str,
rfc_id: str | None, rfc_id: str | None,
owners: list[str], owners: list[str],
) -> dict: ) -> dict:
"""§13.3 (meta-only): open a PR against the meta repo that flips the """§13.3 (meta-only): open a PR against the meta repo that flips the
entry's frontmatter to `state: active` with the graduation stamps entry to `state: active` with the graduation stamps and — **optionally**
and — **optionally** — the integer `id`, **keeping the body — the integer `id`, **keeping the body unchanged** (§1 meta-only
unchanged** (§1 meta-only topology; no repo is created and no body topology; no repo is created and no body is stripped). The graduation
is stripped). When `rfc_id` is None the entry graduates without a metadata is written to the entry's sidecar (§22.4a) via `files`; a legacy
number (id stays null, slug is canonical per §2.3, §13.2). Branch `.md` is lazy-migrated to body-only in the same commit. When `rfc_id` is
name uses the `graduate-<slug>-<6hex>` shape — dash-separated like None the entry graduates without a number (id stays null, slug is
the other meta-repo branches per the §19.2 path-routing candidate. canonical per §2.3, §13.2). Branch name uses the `graduate-<slug>-<6hex>`
shape — dash-separated like the other meta-repo branches per the §19.2
path-routing candidate.
""" """
import secrets import secrets
branch = f"graduate-{slug}-{secrets.token_hex(3)}" branch = f"graduate-{slug}-{secrets.token_hex(3)}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main") 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_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.commit_entry_files(
result = await self._gitea.update_file( actor, org=org, repo=meta_repo, files=files,
org, meta_repo, f"rfcs/{slug}.md", message=commit_subject, branch=branch)
content=new_file_contents,
sha=prior_sha,
message=commit_message,
branch=branch,
author_name=actor.display_name, author_email=ae,
)
commit_sha = ( commit_sha = (
result.get("commit", {}).get("sha") result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha") or result.get("content", {}).get("sha")
@@ -973,35 +967,26 @@ class Bot:
org: str, org: str,
meta_repo: str, meta_repo: str,
slug: str, slug: str,
new_file_contents: str, files: list[dict],
prior_sha: str,
verb: str, verb: str,
target_state: str, target_state: str,
) -> dict: ) -> dict:
"""§13.7: open a PR flipping `rfcs/<slug>.md` to `state: """§13.7: open a PR flipping an entry to `state: <target_state>` — for
<target_state>` — for retire (`verb='retire'`, target `retired`) retire (`verb='retire'`, target `retired`) or un-retire
or un-retire (`verb='unretire'`, target the restored prior state). (`verb='unretire'`, target the restored prior state). The `state` change
Only the frontmatter `state` changes; the body and every other is written to the entry's metadata sidecar (§22.4a), keeping the `.md`
field (including the integer `id`) are kept, so an un-retire body and every other field, so an un-retire restores the entry exactly.
restores the entry exactly. Branch shape mirrors graduation's `files` come from `metadata.write_entry_files`. Branch shape mirrors
`<verb>-<slug>-<6hex>`. graduation's `<verb>-<slug>-<6hex>`.
""" """
import secrets import secrets
branch = f"{verb}-{slug}-{secrets.token_hex(3)}" branch = f"{verb}-{slug}-{secrets.token_hex(3)}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main") 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" verb_title = "Retire" if verb == "retire" else "Un-retire"
commit_subject = f"{verb_title} {slug}" result = await self.commit_entry_files(
commit_message = _stamp_single(commit_subject, actor) actor, org=org, repo=meta_repo, files=files,
result = await self._gitea.update_file( message=f"{verb_title} {slug}", branch=branch)
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,
)
commit_sha = ( commit_sha = (
result.get("commit", {}).get("sha") result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha") or result.get("content", {}).get("sha")
@@ -1171,30 +1156,23 @@ class Bot:
org: str, org: str,
meta_repo: str, meta_repo: str,
slug: str, slug: str,
new_file_contents: str, files: list[dict],
prior_sha: str,
) -> dict: ) -> dict:
"""§13.1: open a PR adding the actor to the entry's `owners:` list. """§13.1: open a PR adding the actor to the entry's `owners:` list.
Touches only the frontmatter of `rfcs/<slug>.md`. Branch shape is Writes the updated `owners:` to the entry's metadata sidecar (§22.4a)
`claim/<slug>` — single attempt per super-draft per actor (Gitea via `files`; a legacy `.md` is lazy-migrated to body-only in the same
refuses duplicate branch creation, which is the right behavior: commit. Branch shape is `claim/<slug>` — single attempt per super-draft
if the claim is still open, point the contributor at the existing per actor (Gitea refuses duplicate branch creation, which is the right
PR rather than opening a second one). behavior: if the claim is still open, point the contributor at the
existing PR rather than opening a second one).
""" """
branch = f"claim/{slug}" branch = f"claim/{slug}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main") 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_subject = f"Claim ownership of {slug} for {actor.gitea_login}"
commit_message = _stamp_single(commit_subject, actor) result = await self.commit_entry_files(
result = await self._gitea.update_file( actor, org=org, repo=meta_repo, files=files,
org, meta_repo, f"rfcs/{slug}.md", message=commit_subject, branch=branch)
content=new_file_contents,
sha=prior_sha,
message=commit_message,
branch=branch,
author_name=actor.display_name, author_email=ae,
)
commit_sha = ( commit_sha = (
result.get("commit", {}).get("sha") result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha") or result.get("content", {}).get("sha")
@@ -1233,29 +1211,22 @@ class Bot:
reviewed_by: str, reviewed_by: str,
reviewed_at: str, reviewed_at: str,
) -> None: ) -> None:
"""Clear §22.4c unreviewed on an active entry by rewriting its """Clear §22.4c unreviewed on an active entry by writing its metadata
frontmatter on main. Stamps the commit with the §6.5 On-behalf-of sidecar on main (§22.4a). Dual-reads the entry (so a migrated body-only
trailer and writes an actions-log row, mirroring the graduation `.md` doesn't crash) and lazy-migrates a legacy `.md` to body-only in the
stamp's bot-write shape.""" 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" path = f"rfcs/{slug}.md"
result = await self._gitea.read_file(org, meta_repo, path, ref="main") st = await metadata_mod.read_entry_from_git(self._gitea, org, meta_repo, path)
if result is None: if st is None:
raise GiteaError(404, f"{path} not found") raise GiteaError(404, f"{path} not found")
text, sha = result e = metadata_mod.apply_values(st.entry, {
e = entry_mod.parse(text) "unreviewed": False, "reviewed_at": reviewed_at, "reviewed_by": reviewed_by,
e.unreviewed = False })
e.reviewed_at = reviewed_at files = metadata_mod.write_entry_files(path, e, st)
e.reviewed_by = reviewed_by result = await self.commit_entry_files(
commit_message = _stamp_single(f"Mark {slug} reviewed", actor) actor, org=org, repo=meta_repo, files=files,
result = await self._gitea.update_file( message=f"Mark {slug} reviewed", branch="main")
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",
)
commit_sha = ( commit_sha = (
result.get("commit", {}).get("sha") result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha") or result.get("content", {}).get("sha")
+18 -11
View File
@@ -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, def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str,
owners: list[str], arbiters: list[str] | None = None, owners: list[str], arbiters: list[str] | None = None,
proposed_by: str = "alice", tags: list[str] | None = 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 k[1].startswith("rfc-0042") for k in fake.repos
), f"a per-RFC repo was created: {fake.repos}" ), f"a per-RFC repo was created: {fake.repos}"
# Meta entry on main: state flipped, body KEPT, repo null. # Meta entry on main: state flipped (sidecar), body KEPT (.md), repo null.
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"] graduated = _entry_from_git(fake, "ohm")
graduated = entry_mod.parse(meta_text)
assert graduated.state == "active" assert graduated.state == "active"
assert graduated.id == "RFC-0042" assert graduated.id == "RFC-0042"
assert graduated.repo is None 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"]}) json={"rfc_id": "RFC-0042", "owners": ["ben"]})
assert r.status_code == 200, r.text 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.state == "active"
assert graduated.extra.get("priority") == "P1" 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 assert d["rfc_id"] is None
# Meta entry: active, id null, body kept, graduation stamped. # Meta entry: active, id null, body kept, graduation stamped.
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"] graduated = _entry_from_git(fake, "ohm")
graduated = entry_mod.parse(meta_text)
assert graduated.state == "active" assert graduated.state == "active"
assert graduated.id is None assert graduated.id is None
assert graduated.graduated_by == "ben" 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"]}) r = client.post("/api/rfcs/ohm/graduate?_sync=1", json={"owners": ["ben"]})
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert r.json()["rfc_id"] is None assert r.json()["rfc_id"] is None
graduated = entry_mod.parse( graduated = _entry_from_git(fake, "ohm")
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
)
assert graduated.state == "active" assert graduated.state == "active"
assert graduated.id is None assert graduated.id is None
@@ -677,8 +685,7 @@ def test_claim_opens_meta_pr(app_with_fake_gitea):
d = r.json() d = r.json()
assert d["branch_name"] == "claim/ohm" assert d["branch_name"] == "claim/ohm"
text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"] ent = _entry_from_git(fake, "ohm", branch="claim/ohm")
ent = entry_mod.parse(text)
assert "alice" in ent.owners assert "alice" in ent.owners
row = db.conn().execute( row = db.conn().execute(
+8 -5
View File
@@ -47,12 +47,15 @@ def test_mark_reviewed_clears_flag(app_with_fake_gitea):
assert row["unreviewed"] == 0 assert row["unreviewed"] == 0
assert row["reviewed_by"] == "ben" assert row["reviewed_by"] == "ben"
assert row["reviewed_at"] # provenance stamped assert row["reviewed_at"] # provenance stamped
# git-side: the entry file on main was rewritten with the cleared flag. # git-side (§22.4a SLICE-4): the cleared flag now lands in the metadata
from app import entry as entry_mod # 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"] written = fake.files[("wiggleverse", "meta", "main", "rfcs/feat.md")]["content"]
e = entry_mod.parse(written) assert "---" not in written # body-only, no frontmatter
assert e.unreviewed is False
assert e.reviewed_by == "ben"
def test_mark_reviewed_forbidden_for_non_superuser(app_with_fake_gitea): def test_mark_reviewed_forbidden_for_non_superuser(app_with_fake_gitea):
+145
View File
@@ -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
+13 -9
View File
@@ -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.status_code == 200, r.text
assert r.json()["state"] == "retired" assert r.json()["state"] == "retired"
# Meta entry on main: state retired, body + fields kept. # §22.4a SLICE-4: the state flip lands in the metadata sidecar and the
meta = entry_mod.parse( # `.md` is lazy-migrated to a clean body-only file (INV-2). Fields kept.
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 == "retired" assert sc["state"] == "retired"
assert "carol" in meta.owners assert "carol" in sc["owners"]
assert "---" not in fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
# Cache flipped; gone from the catalog. # Cache flipped; gone from the catalog.
cached = db.conn().execute( 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.status_code == 200, r.text
assert r.json()["state"] == "active" assert r.json()["state"] == "active"
meta = entry_mod.parse( import yaml as _yaml
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"] sc = _yaml.safe_load(
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.meta.yaml")]["content"]
) )
assert meta.state == "active" assert sc["state"] == "active"
assert meta.id == "RFC-0042" assert sc["id"] == "RFC-0042"
cached = db.conn().execute( cached = db.conn().execute(
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'" "SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"