diff --git a/README.md b/README.md index 83c746e..fe81636 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,24 @@ After bring-up: - `gitea ls /api/v1/repos/wiggleverse/meta/contents/rfcs` after a proposal merges should show one new `.md`. +## Seeding an active RFC for §8 testing + +Slice 2 (the active-RFC view per §8) needs an entry whose `state` is +`active` and whose per-RFC repo exists. Slice 5's graduation flow +will land the proper path; until then, `scripts/seed_test_rfc.py` is +the dev shortcut. Sign in once via OAuth so a `users` row exists, +then: + +```sh +cd backend && .venv/bin/python ../scripts/seed_test_rfc.py \ + --slug open-human-model \ + --title "Open Human Model" +``` + +The script creates `wiggleverse/rfc-NNNN-`, seeds `RFC.md` on +`main`, registers the webhook, and graduates the meta entry. The §8 +surface at `/rfc/` then has something real to render. + ## Troubleshooting - **The catalog stays empty after a merge.** Check that the webhook diff --git a/scripts/seed_test_rfc.py b/scripts/seed_test_rfc.py new file mode 100755 index 0000000..4b87bd7 --- /dev/null +++ b/scripts/seed_test_rfc.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Seed a per-RFC repo for manual testing of the §8 active-RFC view. + +Slice 2 ships the active-RFC surface against a per-RFC repo at +`/rfc-NNNN-`. Slice 5 owns the §13 graduation flow that +actually creates such a repo from a super-draft. Until Slice 5 lands, +this script is the dev shortcut: it creates the per-RFC repo, seeds +`RFC.md` on `main`, and graduates the meta-repo entry to `state: active` +with the integer ID and repo URL filled in — so a browser session can +exercise the §8 surface end-to-end. + +Per §1 the per-RFC repo's content commits go through the bot wrapper. +The meta-entry frontmatter update is a one-shot bootstrap write — same +pattern `seed_meta_repo.py` uses for the initial meta-repo seed. + +Usage: + cd backend && .venv/bin/python ../scripts/seed_test_rfc.py \\ + --slug open-human-model \\ + --title "Open Human Model" \\ + [--id 0001] \\ + [--body-file path/to/RFC.md] \\ + [--owner ben] \\ + [--arbiter ben] + +Re-running is safe; every step is upsert-shaped. If the meta entry +doesn't exist, it's created as a super-draft and graduated in one +pass. If it's already active, the script reports and exits without +clobbering anything. +""" +from __future__ import annotations + +import argparse +import asyncio +import base64 +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "backend")) + +from app import db, entry as entry_mod # noqa: E402 +from app.bot import Actor, Bot # noqa: E402 +from app.config import load_config # noqa: E402 +from app.gitea import Gitea, GiteaError # noqa: E402 + + +DEFAULT_RFC_MD = """# {title} + +*Placeholder body for the active-RFC view.* + +Edit this through the app — the §8 surface is the only intended +contribution path. This file was seeded by `scripts/seed_test_rfc.py` +as a dev shortcut for exercising Slice 2 before Slice 5's graduation +flow lands. + +## Why this RFC exists + +A short paragraph the contributor would normally write before +graduation, preserved here so the editor has something legible to +render on first open. +""" + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="Seed a per-RFC repo for §8 testing") + p.add_argument("--slug", required=True, help="Kebab-case slug, e.g. open-human-model") + p.add_argument("--title", required=True, help='Display title, e.g. "Open Human Model"') + p.add_argument("--id", default=None, help="Integer ID without prefix, e.g. 0001") + p.add_argument("--body-file", default=None, help="Path to a markdown file for RFC.md") + p.add_argument("--owner", action="append", default=None, help="Gitea login; repeatable") + p.add_argument("--arbiter", action="append", default=None, help="Gitea login; repeatable") + p.add_argument("--seed-actor", default=None, + help="Gitea login to record as On-behalf-of (default: OWNER_GITEA_LOGIN)") + return p.parse_args() + + +async def main() -> None: + args = parse_args() + config = load_config() + db.run_migrations(config) + db.init(config) + + if not entry_mod.is_valid_slug(args.slug): + sys.exit(f"invalid slug: {args.slug!r}") + + integer_id = compute_integer_id(args.id) + rfc_id = f"RFC-{integer_id}" + repo_name = f"rfc-{integer_id}-{args.slug}" + repo_full = f"{config.gitea_org}/{repo_name}" + + body = read_body(args) + owners = args.owner or [config.owner_gitea_login or "ben"] + arbiters = args.arbiter or owners[:1] + + gitea = Gitea(config) + bot = Bot(gitea) + actor = _seed_actor(config, args) + + try: + # 1) Per-RFC repo + RFC.md (via the bot wrapper). + print(f"ensuring per-RFC repo {repo_full}") + await bot.ensure_rfc_repo_seed( + actor, + owner=config.gitea_org, + repo=repo_name, + slug=args.slug, + title=args.title, + body=body, + ) + + # 2) Webhook on the per-RFC repo so live events flow per §4.1. + if config.webhook_secret: + url = f"{config.app_url}/api/webhooks/gitea" + print(f"ensuring webhook on {repo_full} -> {url}") + await gitea.ensure_webhook( + config.gitea_org, repo_name, + url=url, secret=config.webhook_secret, + events=["push", "pull_request", "create", "delete", "repository"], + ) + else: + print("skipping webhook setup (GITEA_WEBHOOK_SECRET not set)") + + # 3) Meta-repo entry: create if missing, graduate to active. + await graduate_meta_entry( + config, gitea, + slug=args.slug, title=args.title, rfc_id=rfc_id, repo_full=repo_full, + owners=owners, arbiters=arbiters, body_for_super_draft=body, + ) + finally: + await gitea.close() + + print() + print(f"✓ seeded {rfc_id} — {args.title}") + print(f" repo: {repo_full}") + print(f" meta: rfcs/{args.slug}.md") + print(f" app URL: {config.app_url}/rfc/{args.slug}") + print() + print("On next reconciler sweep (or webhook fire) the cache will update.") + print("You can force a sweep by restarting the backend.") + + +def compute_integer_id(arg: str | None) -> str: + """Pick an integer ID. With --id provided, use it verbatim; otherwise + take the next free 4-digit integer over the cached_rfcs.rfc_id column. + """ + if arg: + n = int(arg) + return f"{n:04d}" + rows = db.conn().execute( + "SELECT rfc_id FROM cached_rfcs WHERE rfc_id LIKE 'RFC-%'" + ).fetchall() + used = set() + for r in rows: + try: + used.add(int(r["rfc_id"].split("-", 1)[1])) + except (IndexError, ValueError): + continue + nxt = (max(used) + 1) if used else 1 + return f"{nxt:04d}" + + +def read_body(args: argparse.Namespace) -> str: + if args.body_file: + return Path(args.body_file).read_text() + return DEFAULT_RFC_MD.format(title=args.title) + + +def _seed_actor(config, args: argparse.Namespace) -> Actor: + """Synthesize an `Actor` for the seed write. The seed script is + bootstrap-shaped; we record it under whichever login the operator + designates (default: OWNER_GITEA_LOGIN) so the §6.5 audit trail + has a real human attribution. + """ + login = args.seed_actor or config.owner_gitea_login or "seed" + row = db.conn().execute( + "SELECT id, gitea_login, display_name, email FROM users WHERE gitea_login = ?", + (login,), + ).fetchone() + if row: + return Actor( + user_id=row["id"], + gitea_login=row["gitea_login"], + display_name=row["display_name"], + email=row["email"] or "", + ) + # The seed script's writes go through the §1 bot wrapper, which + # records an `actions` row whose actor_user_id FK references + # `users.id`. If `` hasn't signed in yet, there's no row to + # point at — refuse instead of synthesizing one, since a fake row + # would muddy the user table that §6 / §15.9 read from. + sys.exit( + f"no users row for gitea_login={login!r}. Sign in via OAuth at " + f"{config.app_url} first (or pass --seed-actor for an " + f"already-provisioned account)." + ) + + +async def graduate_meta_entry( + config, gitea: Gitea, *, + slug: str, title: str, rfc_id: str, repo_full: str, + owners: list[str], arbiters: list[str], body_for_super_draft: str, +) -> None: + """Idempotent: create the meta entry as super-draft if missing, + then flip its frontmatter to `state: active` with the integer ID + and repo URL filled in. Active entries are left alone. + """ + path = f"rfcs/{slug}.md" + existing = await gitea.read_file(config.gitea_org, config.meta_repo, path, ref="main") + + if existing is None: + print(f"creating meta entry {path}") + new_entry = entry_mod.Entry( + slug=slug, + title=title, + state="active", + id=rfc_id, + repo=repo_full, + proposed_by=owners[0], + proposed_at=entry_mod.today(), + graduated_at=entry_mod.today(), + graduated_by=owners[0], + owners=owners, + arbiters=arbiters, + tags=[], + body="", + ) + text = entry_mod.serialize(new_entry) + await _bootstrap_create(gitea, config, path, text, f"Seed: graduate {slug}") + return + + text, sha = existing + entry = entry_mod.parse(text) + if entry.state == "active": + print(f"meta entry {path} is already active — leaving alone") + return + + print(f"graduating meta entry {path} -> active") + entry.state = "active" + entry.id = rfc_id + entry.repo = repo_full + entry.graduated_at = entry_mod.today() + entry.graduated_by = (entry.owners or owners)[0] + if not entry.owners: + entry.owners = owners + if not entry.arbiters: + entry.arbiters = arbiters + new_text = entry_mod.serialize(entry) + await _bootstrap_update(gitea, config, path, new_text, sha, f"Seed: graduate {slug}") + + +async def _bootstrap_create(gitea: Gitea, config, path: str, content: str, message: str) -> None: + # Same bootstrap-only direct write the meta-repo seed uses. + await gitea.create_file( + config.gitea_org, config.meta_repo, path, + content=content, message=message, branch="main", + ) + + +async def _bootstrap_update(gitea: Gitea, config, path: str, content: str, sha: str, message: str) -> None: + await gitea.update_file( + config.gitea_org, config.meta_repo, path, + content=content, sha=sha, message=message, branch="main", + ) + + +if __name__ == "__main__": + asyncio.run(main())