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:
+36
-21
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
+14
-9
@@ -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:
|
||||
|
||||
+48
-77
@@ -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-<slug>-<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-<slug>-<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/<slug>.md` to `state:
|
||||
<target_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
|
||||
`<verb>-<slug>-<6hex>`.
|
||||
"""§13.7: open a PR flipping an entry to `state: <target_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 `<verb>-<slug>-<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/<slug>.md`. Branch shape is
|
||||
`claim/<slug>` — 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/<slug>` — 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")
|
||||
|
||||
@@ -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