Slice 1: scaffolding + propose-to-super-draft vertical
Brings the §1 bot wrapper, the §4 cache (webhook + reconciler), the §5 schema (six numbered migrations), Gitea OAuth + §6 user provisioning, the §7 catalog left pane, and the propose-to-merge vertical: propose modal opens an idea PR against the meta repo, an owner merges from the pending-idea view, the cache picks it up via webhook or reconciler sweep, and the catalog renders the new super-draft. Per §1 the bot is the only Git writer; every commit, branch creation, and PR merge carries the §6.5 On-behalf-of: trailer and an `actions` audit row. Per §4 the cache is never written from a user action — it's webhook+reconciler only. Covered by `backend/tests/test_propose_vertical.py` against an in-process Gitea simulator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
"""Meta-repo entry file shape per §2.1.
|
||||
|
||||
One markdown file per RFC under rfcs/<slug>.md, frontmatter on top, body
|
||||
below. The frontmatter carries the canonical RFC state — id, repo,
|
||||
owners, arbiters, graduation timestamps — and the body holds the pitch
|
||||
(for super-drafts) or is empty (for graduated entries per §13.3 step 3).
|
||||
|
||||
This module contains the parser, the serializer, and a small validator
|
||||
for the frontmatter shape. The parser is intentionally lenient about
|
||||
unknown keys — future fields land in frontmatter without breaking older
|
||||
readers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.DOTALL)
|
||||
|
||||
SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entry:
|
||||
slug: str
|
||||
title: str
|
||||
state: str = "super-draft" # super-draft | active | withdrawn
|
||||
id: str | None = None # 'RFC-NNNN' or None
|
||||
repo: str | None = None
|
||||
proposed_by: str = ""
|
||||
proposed_at: str = "" # ISO date
|
||||
graduated_at: str | None = None
|
||||
graduated_by: str | None = None
|
||||
owners: list[str] = field(default_factory=list)
|
||||
arbiters: list[str] = field(default_factory=list)
|
||||
tags: list[str] = field(default_factory=list)
|
||||
body: str = ""
|
||||
|
||||
|
||||
def parse(text: str) -> Entry:
|
||||
match = FRONTMATTER_RE.match(text)
|
||||
if not match:
|
||||
raise ValueError("Entry file missing frontmatter")
|
||||
fm = yaml.safe_load(match.group(1)) or {}
|
||||
body = match.group(2).lstrip("\n")
|
||||
return Entry(
|
||||
slug=str(fm.get("slug") or ""),
|
||||
title=str(fm.get("title") or ""),
|
||||
state=str(fm.get("state") or "super-draft"),
|
||||
id=fm.get("id") or None,
|
||||
repo=fm.get("repo") or None,
|
||||
proposed_by=str(fm.get("proposed_by") or ""),
|
||||
proposed_at=str(fm.get("proposed_at") or ""),
|
||||
graduated_at=fm.get("graduated_at"),
|
||||
graduated_by=fm.get("graduated_by"),
|
||||
owners=list(fm.get("owners") or []),
|
||||
arbiters=list(fm.get("arbiters") or []),
|
||||
tags=list(fm.get("tags") or []),
|
||||
body=body,
|
||||
)
|
||||
|
||||
|
||||
def serialize(entry: Entry) -> str:
|
||||
"""Emit canonical entry file text — frontmatter then body."""
|
||||
fm: dict[str, Any] = {
|
||||
"slug": entry.slug,
|
||||
"title": entry.title,
|
||||
"state": entry.state,
|
||||
"id": entry.id,
|
||||
"repo": entry.repo,
|
||||
"proposed_by": entry.proposed_by,
|
||||
"proposed_at": entry.proposed_at,
|
||||
"graduated_at": entry.graduated_at,
|
||||
"graduated_by": entry.graduated_by,
|
||||
"owners": entry.owners,
|
||||
"arbiters": entry.arbiters,
|
||||
"tags": entry.tags,
|
||||
}
|
||||
yaml_text = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False).rstrip()
|
||||
body = entry.body.lstrip("\n")
|
||||
if body:
|
||||
return f"---\n{yaml_text}\n---\n\n{body}\n"
|
||||
return f"---\n{yaml_text}\n---\n"
|
||||
|
||||
|
||||
def slugify(title: str) -> str:
|
||||
"""Deterministic kebab-case per §9.1."""
|
||||
s = title.lower().strip()
|
||||
s = re.sub(r"[^a-z0-9]+", "-", s)
|
||||
return s.strip("-")
|
||||
|
||||
|
||||
def today() -> str:
|
||||
return date.today().isoformat()
|
||||
|
||||
|
||||
def is_valid_slug(slug: str) -> bool:
|
||||
return bool(SLUG_RE.match(slug)) and len(slug) <= 80
|
||||
Reference in New Issue
Block a user