diff --git a/backend/app/api.py b/backend/app/api.py index 37cddab..bf4c7a3 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -611,13 +611,16 @@ def make_router( # --------------------------------------------------------------- @router.get("/api/rfcs") - async def list_rfcs(request: Request) -> dict[str, Any]: + async def list_rfcs(request: Request, unreviewed: str | None = None) -> dict[str, Any]: """§7's left pane data. The chip-filter / sort / search combinatorics live on the client — the server returns the full set and lets the chips narrow it. The set is small (hundreds, not thousands) for the foreseeable future, so paginating here would buy nothing. + + §22.4c: pass `?unreviewed=true` to narrow to active entries + still awaiting owner review. """ viewer = auth.current_user(request) viewer_id = viewer.user_id if viewer else None @@ -627,6 +630,10 @@ def make_router( if not visible: return {"items": []} placeholders = ",".join("?" for _ in visible) + params = list(visible) + unreviewed_clause = "" + if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"): + unreviewed_clause = " AND unreviewed = 1 AND state = 'active'" rows = db.conn().execute( f""" SELECT slug, title, state, rfc_id, repo, @@ -634,10 +641,10 @@ def make_router( last_main_commit_at, last_entry_commit_at, updated_at FROM cached_rfcs WHERE state IN ('super-draft', 'active') - AND project_id IN ({placeholders}) + AND project_id IN ({placeholders}){unreviewed_clause} ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC """, - visible, + params, ).fetchall() starred = set() @@ -700,6 +707,40 @@ def make_router( payload["proposed_use_case"] = uc["use_case"] if uc else None return payload + # --------------------------------------------------------------- + # §22.4c: mark-reviewed — clear an active entry's `unreviewed` flag + # --------------------------------------------------------------- + + @router.post("/api/projects/{project_id}/rfcs/{slug}/mark-reviewed") + async def mark_reviewed(project_id: str, slug: str, request: Request) -> dict[str, Any]: + """§22.4c — clear an active entry's `unreviewed` flag. Authority is the + §22.7 project superuser (project_admin or deployment owner/admin).""" + viewer = auth.require_user(request) + auth.require_project_readable(viewer, project_id) + if not auth.is_project_superuser(viewer, project_id): + raise HTTPException(403, "Only a project owner/admin can mark an entry reviewed") + row = db.conn().execute( + "SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND project_id = ?", + (slug, project_id), + ).fetchone() + if row is None: + raise HTTPException(404, "Not found") + if row["state"] != "active" or not row["unreviewed"]: + raise HTTPException(409, "Entry is not an unreviewed active entry") + try: + await bot.mark_entry_reviewed( + viewer.as_actor(), + org=config.gitea_org, + meta_repo=projects_mod.default_content_repo(config), + slug=slug, + reviewed_by=viewer.gitea_login, + reviewed_at=entry_mod.today(), + ) + except GiteaError as e: + raise HTTPException(502, f"Gitea: {e.detail}") + await cache.refresh_meta_repo(config, gitea) + return {"ok": True} + # --------------------------------------------------------------- # §7.3 / §9.3: pending ideas # --------------------------------------------------------------- diff --git a/backend/app/bot.py b/backend/app/bot.py index 4436a92..9170f2f 100644 --- a/backend/app/bot.py +++ b/backend/app/bot.py @@ -1069,6 +1069,56 @@ class Bot: ) return pr + # ----- §22.4c: mark-reviewed (direct main write) ----- + + async def mark_entry_reviewed( + self, + actor: Actor, + *, + org: str, + meta_repo: str, + slug: str, + 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.""" + from . import entry as entry_mod + + path = f"rfcs/{slug}.md" + result = await self._gitea.read_file(org, meta_repo, path, ref="main") + if result 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) + result2 = 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", + ) + commit_sha = ( + result2.get("commit", {}).get("sha") + or result2.get("content", {}).get("sha") + or "" + ) + _log( + actor, + "mark_reviewed", + rfc_slug=slug, + bot_commit_sha=commit_sha, + details={"reviewed_by": reviewed_by, "reviewed_at": reviewed_at}, + ) + # ----- Per-RFC repo: seeding (test/dev fixtures, future graduation) ----- async def ensure_rfc_repo_seed( diff --git a/backend/tests/test_mark_reviewed.py b/backend/tests/test_mark_reviewed.py new file mode 100644 index 0000000..7894ddc --- /dev/null +++ b/backend/tests/test_mark_reviewed.py @@ -0,0 +1,58 @@ +"""§22.4c — owner/admin mark-reviewed clears the flag; catalog unreviewed filter.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from test_propose_vertical import ( # noqa: F401 + app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as, +) + + +def _seed_unreviewed_active(fake, slug="feat"): + """Put an active+unreviewed entry on the meta repo main + cache.""" + from app import cache, entry as entry_mod + body = entry_mod.serialize(entry_mod.Entry( + slug=slug, title="Feat", state="active", unreviewed=True, + owners=["ben"], proposed_by="ben", + )) + fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {"content": body, "sha": "s1"} + cache._upsert_cached_rfc(entry_mod.parse(body), body_sha="s1") + + +def test_catalog_unreviewed_filter(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _seed_unreviewed_active(fake, "feat") + from app import cache, entry as entry_mod + ok = entry_mod.serialize(entry_mod.Entry(slug="ok", title="OK", state="active", owners=["ben"])) + fake.files[("wiggleverse", "meta", "main", "rfcs/ok.md")] = {"content": ok, "sha": "s2"} + cache._upsert_cached_rfc(entry_mod.parse(ok), body_sha="s2") + r = client.get("/api/rfcs", params={"unreviewed": "true"}) + slugs = {i["slug"] for i in r.json()["items"]} + assert slugs == {"feat"} + + +def test_mark_reviewed_clears_flag(app_with_fake_gitea): + from app import db + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _seed_unreviewed_active(fake, "feat") + 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") + r = client.post("/api/projects/default/rfcs/feat/mark-reviewed") + assert r.status_code == 200 + row = db.conn().execute( + "SELECT unreviewed, reviewed_by FROM cached_rfcs WHERE slug='feat'" + ).fetchone() + assert row["unreviewed"] == 0 + assert row["reviewed_by"] == "ben" + + +def test_mark_reviewed_forbidden_for_non_superuser(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _seed_unreviewed_active(fake, "feat") + provision_user_row(user_id=2, login="carol", role="contributor") + sign_in_as(client, user_id=2, gitea_login="carol", display_name="Carol", role="contributor") + r = client.post("/api/projects/default/rfcs/feat/mark-reviewed") + assert r.status_code == 403