Add seed_test_rfc.py for §8 manual testing
Slice 5 will own the §13 graduation flow that creates per-RFC repos from super-drafts; until then, the §8 active-RFC view needs a dev shortcut to bring an `state: active` entry plus a per-RFC repo into existence. The script: - Creates `<org>/rfc-NNNN-<slug>` via bot.ensure_rfc_repo_seed, seeding RFC.md on main. - Registers the §4.1 webhook on the per-RFC repo. - Creates (or graduates) the meta-repo entry with state=active, id=RFC-NNNN, repo=<full>. Requires the operator's gitea_login to have a `users` row (sign in once via OAuth first); refuses to synthesize a fake user since the §6 / §15.9 attribution surfaces read from it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -175,6 +175,24 @@ After bring-up:
|
||||
- `gitea ls /api/v1/repos/wiggleverse/meta/contents/rfcs` after a
|
||||
proposal merges should show one new `<slug>.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-<slug>`, seeds `RFC.md` on
|
||||
`main`, registers the webhook, and graduates the meta entry. The §8
|
||||
surface at `/rfc/<slug>` then has something real to render.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **The catalog stays empty after a merge.** Check that the webhook
|
||||
|
||||
Executable
+266
@@ -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
|
||||
`<org>/rfc-NNNN-<slug>`. 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 `<login>` 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 <login> 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())
|
||||
Reference in New Issue
Block a user