v0.31.0: meta-only repository topology (ROADMAP #36)

Retire the per-RFC-repo model. RFCs now live in their meta-repo entry
(rfcs/<slug>.md) for their whole life; graduation is an in-place
super-draft → active state flip that keeps the body in the entry — no
repo creation, no body-strip, no five-step transaction, no rollback.

SPEC: §1 topology rewritten (one meta/content repository, no per-RFC
repos) with a deployer-facing "single content repository" framing; §2
(repo: always-null), §3 (active is in-place), §4, §9.8 (handoff
frictions dissolve), and §13 fully rewritten (two-field dialog, "The
flip", §13.6 RFC-0001 fold-back record).

Code: graduation collapses to open+merge one frontmatter PR; branch/PR/
chat dispatch re-keyed on meta-residency (repo IS NULL) so active RFCs
edit on the meta repo exactly as super-drafts do; the two "RFC has no
repo" 409 guards removed; promote-to-branch slug-embeds an active RFC's
auto-branch (edit-<slug>-<hex>) for shared-repo cache attribution;
refresh_meta_branches + hygiene branch-resolution include meta-resident
actives; the §9.8 read-only guard + pre_graduation_history scoped to
legacy per-repo only; dead bot primitives + GraduateDialog repo field +
blocking-PR popover removed. The repo: frontmatter field and the
/blocking-prs endpoint are retained (schema stability / informational).

Tests: graduation suite rewritten to the flip model; e2e + hygiene
updated. Full backend suite 375 passed; frontend builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-29 07:11:10 -07:00
parent d581010063
commit 0c972c8af5
15 changed files with 729 additions and 1077 deletions
+59 -37
View File
@@ -150,7 +150,7 @@ def make_router(
# `refresh_meta_branches` writes is internal scaffolding for the
# §10.1 has-commits-ahead check — the §9.4 dropdown's first
# position is rendered separately as 'canonical body'.
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
branch_rows = db.conn().execute(
"""
SELECT branch_name, head_sha, state, last_commit_at, pinned
@@ -180,7 +180,7 @@ def make_router(
# per-RFC repo. For super-draft: meta_body_edit and meta_metadata
# PRs on the meta repo. Same shape either way — the §9.4 dropdown
# treats both as "open work against this entry."
pr_kinds = ("meta_body_edit", "meta_metadata") if _is_super_draft(rfc) else ("rfc_branch",)
pr_kinds = ("meta_body_edit", "meta_metadata") if _is_meta_resident(rfc) else ("rfc_branch",)
placeholders = ",".join("?" * len(pr_kinds))
pr_rows = db.conn().execute(
f"""
@@ -204,17 +204,18 @@ def make_router(
for r in pr_rows
]
# For super-drafts the cached body is entry.body already (see
# cache._upsert_cached_rfc), so no extraction is needed.
# §9.8 / §13.4 pre-graduation history: for active RFCs, surface
# any `threads` or `changes` rows whose `branch_name` starts with
# `edit-<slug>-` so the breadcrumb dropdown can render the
# affordance as a distinct disclosure alongside main, open
# branches, and open PRs. The slug is the canonical key per §2.3
# before and after graduation, so the query is a straightforward
# lookup — no data movement.
# For meta-resident entries the cached body is entry.body already
# (see cache._upsert_cached_rfc), so no extraction is needed.
# Pre-graduation history is a LEGACY-only affordance: under the
# meta-only topology (§1, §13.4) graduation moves nothing, so an
# active RFC's edit branches are its *current* branches and already
# surface in `branches` above — there is no separate pre-graduation
# set. The disclosure is therefore computed only for a legacy
# per-RFC-repo active entry (`repo` set), where edit branches on the
# meta repo genuinely predate the per-RFC repo and would otherwise
# not appear. After the RFC-0001 fold-back (§13.6) nothing matches.
pre_grad: list[dict[str, Any]] = []
if rfc["state"] == "active":
if rfc["state"] == "active" and rfc["repo"]:
pre_grad_rows = db.conn().execute(
"""
SELECT t.branch_name,
@@ -292,8 +293,20 @@ def make_router(
owner, repo = _repo_for(rfc)
new_branch = (body.branch_name or "").strip()
if not new_branch:
new_branch = _auto_branch_name(viewer.gitea_login)
_validate_branch_name(new_branch)
# Meta-only topology (§1): an active RFC's branches live on the
# shared meta repo, so the auto name must embed the slug for the
# cache to attribute it (`edit-<slug>-<hex>`, recovered by
# `_slug_from_branch_name`). A legacy per-RFC-repo entry can use
# the slug-free `<login>-draft-<hex>` since every branch there
# belongs to the one RFC. The auto name is trusted (it carries a
# reserved `edit-` prefix by design); only a user-supplied name
# is validated, mirroring `start_edit_branch`.
new_branch = (
_auto_edit_branch_name(slug) if _is_meta_resident(rfc)
else _auto_branch_name(viewer.gitea_login)
)
else:
_validate_branch_name(new_branch)
try:
await bot.cut_branch_from_main(
viewer.as_actor(),
@@ -326,8 +339,10 @@ def make_router(
_ensure_branch_vis(slug, new_branch, creator_user_id=viewer.user_id)
# Make the cache aware immediately so the breadcrumb reflects
# the new branch without waiting for the webhook hop.
await cache.refresh_rfc_repo(config, gitea, slug)
# the new branch without waiting for the webhook hop. Meta-resident
# entries (§1) refresh meta branches; a legacy per-RFC repo refreshes
# its own — `_refresh_cache_for` dispatches on residency.
await _refresh_cache_for(rfc)
return {"branch_name": new_branch, "slug": slug}
@@ -1081,14 +1096,14 @@ def make_router(
return row
def _require_rfc_with_repo(slug: str):
"""Used by every branch-scoped endpoint. For active RFCs, a repo is
required. For super-drafts, the meta repo is the implicit target
no per-RFC repo check needed."""
"""Used by every branch-scoped endpoint. Under the meta-only
topology (§1) the meta repo is the implicit target for every
entry — super-draft and active alike — so there is no per-RFC
repo check. The name is retained for call-site stability; a
withdrawn entry is still rejected."""
row = _require_rfc(slug)
if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn")
if row["state"] == "active" and not row["repo"]:
raise HTTPException(409, "RFC has no repo")
return row
def _require_active_rfc(slug: str):
@@ -1106,22 +1121,28 @@ def make_router(
def _is_super_draft(rfc) -> bool:
return rfc["state"] == "super-draft"
def _is_meta_resident(rfc) -> bool:
"""Meta-only topology (§1): an entry lives in the meta repo's
`rfcs/<slug>.md` (super-draft or active-in-place) unless it carries
a legacy per-RFC `repo:` — which nothing does after the RFC-0001
fold-back (§13.6)."""
return not rfc["repo"]
def _is_meta_branch_name(name: str) -> bool:
"""A branch name shaped like one of the bot's meta-repo prefixes.
§9.8's pre-graduation history affordance points the new RFC view
at branches matching `edit-<slug>-...` even after the entry is
active; treating those names as meta-repo targets lets the read
path dispatch correctly without a separate endpoint."""
Retained for the legacy per-RFC-repo read path; under meta-only
every entry is already a meta target via `_is_meta_resident`."""
return name != "main" and name.startswith((
"edit-", "edit/", "metadata-", "metadata/", "claim/", "propose/",
"graduate-",
))
def _is_meta_target(rfc, branch: str) -> bool:
"""Either a super-draft branch (active edit branch or the
canonical body) or an active RFC's pre-graduation meta-repo
branch surfaced through the §9.8 history affordance."""
if _is_super_draft(rfc):
"""A meta-resident entry (super-draft or active-in-place, §1)
targets the meta repo for every branch. The branch-name fallback
covers the retired per-RFC-repo case for any legacy entry that
still carries a `repo:`."""
if _is_meta_resident(rfc):
return True
return _is_meta_branch_name(branch)
@@ -1161,7 +1182,7 @@ def make_router(
return entry_mod.serialize(entry)
async def _refresh_cache_for(rfc) -> None:
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea)
else:
@@ -1270,12 +1291,13 @@ def make_router(
return False
if branch == "main":
return False
# §9.8: pre-graduation history branches are read-only on the
# post-graduation surface. The contributor can re-cut against the
# new repo's main if they still want the work, but the meta-repo
# branches that lived on the super-draft are not editable from
# the active-RFC view.
if rfc["state"] == "active" and _is_meta_branch_name(branch):
# §9.8 (LEGACY per-repo only): pre-graduation history branches are
# read-only on the post-graduation surface of a per-RFC-repo active
# entry. Under the meta-only topology (§1, §13.4) an active RFC's
# `edit-<slug>-…` branches are its *current* editable branches, not
# a frozen pre-graduation set, so this guard applies only when a
# legacy `repo:` is set (nothing, after the RFC-0001 fold-back).
if rfc["state"] == "active" and rfc["repo"] and _is_meta_branch_name(branch):
return False
if viewer.role in ("owner", "admin"):
return True
@@ -1302,7 +1324,7 @@ def make_router(
def _require_can_contribute(slug: str, branch: str, viewer) -> None:
rfc = db.conn().execute(
"SELECT state, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?",
"SELECT state, repo, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?",
(slug,),
).fetchone()
if not _can_contribute(rfc, slug, branch, viewer):
+132 -365
View File
@@ -1,26 +1,30 @@
"""Slice 5 API surface — the §13 graduation flow's endpoints and the
in-process orchestrator that runs the §13.3 transactional sequence with
rollback.
"""§13 graduation flow — the meta-only in-place state flip.
Owns four routes per §17:
Under the meta-only topology (SPEC §1), graduation no longer creates a
per-RFC repo. It is a single frontmatter-flipping commit to the entry's
`rfcs/<slug>.md` on the meta repo: open a PR that re-serializes the entry
with `state: active`, the assigned integer `id`, `graduated_at` /
`graduated_by`, and the dialog's owners — **leaving the body unchanged** —
then auto-merge it. There is no repo to create, nothing to seed, and the
body is neither moved nor stripped, so there is no multi-step transaction
and no rollback (§13.3). If the open or merge fails, the entry stays a
super-draft and we clean up the half-open PR/branch (the only artifact a
mid-flip failure can leave behind).
Routes (§17):
- GET /api/rfcs/<slug>/blocking-prs (§13.2 precondition popover)
- GET /api/rfcs/<slug>/graduate/check (§13.2 debounced validator)
- POST /api/rfcs/<slug>/graduate (§13.3 kickoff)
- POST /api/rfcs/<slug>/graduate (§13.3 the flip)
- GET /api/rfcs/<slug>/graduate/progress (§13.3 SSE step stream)
- GET /api/rfcs/<slug>/blocking-prs (informational; no longer a
graduation precondition)
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim), which is
graduation's prerequisite for non-admins per §13.1.
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim).
The orchestrator runs in-process — each in-flight graduation lives in a
small `GraduationState` keyed by slug, with an asyncio.Queue feeding the
SSE handler. Per the §13.3 transactional contract, every forward step is
paired with an undo; rollback runs the undos in reverse order from the
last step that completed. §13.4's chat migration is a database semantic
no-op (the threads' `(rfc_slug, branch_name='main')` rows are interpreted
as super-draft canonical-body before graduation and as new-RFC main
afterwards — same shape, different meaning), so the only DB work the
sequence does is the audit-log rows the bot's `_log` writes per step.
SSE handler. §13.4's chat/branch/history are a database no-op: every row
is keyed by the slug per §2.3 and stays put across the flip.
"""
from __future__ import annotations
@@ -44,24 +48,18 @@ log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Step machine
# Step machine — two steps under meta-only: open the flip PR, merge it.
# ---------------------------------------------------------------------------
STEP_KEYS = (
"create_repo",
"seed_files",
"open_pr",
"merge_pr",
"refresh_cache",
)
STEP_LABELS = {
"create_repo": "Create per-RFC repository",
"seed_files": "Seed RFC.md, README.md, and .rfc/metadata.yaml",
"open_pr": "Open meta-repo graduation PR",
"open_pr": "Open graduation PR (flip state to active)",
"merge_pr": "Merge graduation PR",
"refresh_cache": "Refresh catalog and views",
}
@@ -77,8 +75,6 @@ class StepState:
class GraduationState:
slug: str
rfc_id: str
repo_name: str
repo_full: str
owners: list[str]
arbiters: list[str]
steps: list[StepState]
@@ -86,8 +82,6 @@ class GraduationState:
finished: bool = False
succeeded: bool = False
error: str | None = None
rollback_started: bool = False
rollback_steps: list[StepState] = field(default_factory=list)
new_pr_number: int | None = None
graduation_branch: str | None = None
@@ -95,12 +89,9 @@ class GraduationState:
return {
"slug": self.slug,
"rfc_id": self.rfc_id,
"repo_full": self.repo_full,
"steps": [_step_payload(s) for s in self.steps],
"rollback_steps": [_step_payload(s) for s in self.rollback_steps],
"finished": self.finished,
"succeeded": self.succeeded,
"rolled_back": self.rollback_started,
"error": self.error,
"pr_number": self.new_pr_number,
}
@@ -114,7 +105,7 @@ def _step_payload(s: StepState) -> dict:
# is fine; the registry is keyed by slug to refuse concurrent graduations
# of the same entry (the §13.2 atomic re-check is a separate defense
# against a concurrent attempt of a DIFFERENT slug claiming the same
# integer ID or repo name).
# integer ID).
_active: dict[str, GraduationState] = {}
@@ -122,10 +113,10 @@ def _get_active(slug: str) -> GraduationState | None:
return _active.get(slug)
def _new_active(slug: str, *, rfc_id: str, repo_name: str, repo_full: str,
def _new_active(slug: str, *, rfc_id: str,
owners: list[str], arbiters: list[str]) -> GraduationState:
state = GraduationState(
slug=slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full,
slug=slug, rfc_id=rfc_id,
owners=owners, arbiters=arbiters,
steps=[StepState(key=k, label=STEP_LABELS[k]) for k in STEP_KEYS],
)
@@ -138,17 +129,9 @@ def _new_active(slug: str, *, rfc_id: str, repo_name: str, repo_full: str,
# ---------------------------------------------------------------------------
# §13.2: Gitea repo name pattern. Gitea accepts alphanumerics, dashes,
# dots, and underscores; cannot start with a dot. 100-char cap as a sane
# upper bound — the spec doesn't pin a max but Gitea's enforcement does.
_REPO_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$")
_RFC_ID_RE = re.compile(r"^RFC-\d{4,}$")
def _is_valid_repo_name(name: str) -> bool:
return bool(_REPO_NAME_RE.match(name)) and ".." not in name
def _is_valid_rfc_id(rfc_id: str) -> bool:
return bool(_RFC_ID_RE.match(rfc_id))
@@ -167,13 +150,6 @@ def _suggest_next_rfc_id() -> str:
return f"RFC-{nxt:04d}"
def _suggest_repo_name(slug: str, rfc_id: str) -> str:
# rfc-NNNN-<slug> per §13.2's default. Strip the 'RFC-' prefix and
# lowercase the number-pad.
num = rfc_id.split("-", 1)[1] if "-" in rfc_id else "0001"
return f"rfc-{num}-{slug}"
def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool:
row = db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE rfc_id = ? AND slug != ?",
@@ -189,7 +165,6 @@ def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool:
class GraduateBody(BaseModel):
rfc_id: str = Field(min_length=5, max_length=40)
repo_name: str = Field(min_length=1, max_length=100)
owners: list[str] = Field(min_length=1)
@@ -206,19 +181,17 @@ def make_router(
router = APIRouter()
# -------------------------------------------------------------------
# §13.2: GET /api/rfcs/<slug>/blocking-prs
# Lists open meta-repo PRs against rfcs/<slug>.md per the precondition
# popover. Returns PR number, title, author, last-activity timestamp,
# and the viewer's available actions (merge, withdraw, open-in-new-tab).
# GET /api/rfcs/<slug>/blocking-prs
# Lists open meta-repo body-edit PRs against rfcs/<slug>.md. Under the
# meta-only topology (§9.8) these no longer block graduation — the body
# is kept, so a body-edit PR coexists with the flip. Retained as an
# informational surface (the dialog can show "N body-edit PRs open").
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/blocking-prs")
async def list_blocking_prs(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_super_draft(slug)
# §13's opening paragraph: only body-edit PRs block graduation.
# Bare edit branches without an open PR do not block. The query
# filters cached_prs to open meta_body_edit kinds for this slug.
rows = db.conn().execute(
"""
SELECT pr_number, title, opened_by, opened_at, head_branch, pr_kind
@@ -261,14 +234,15 @@ def make_router(
"open_in_new_tab": True,
},
})
return {"items": items}
# `blocking` is a legacy field name kept for client compatibility;
# under §9.8 these PRs do not block graduation.
return {"items": items, "blocking": False}
# -------------------------------------------------------------------
# §13.2: GET /api/rfcs/<slug>/graduate/check?id=&repo=
# GET /api/rfcs/<slug>/graduate/check?id=
# Inline validation for the Graduate dialog — debounced from the
# client; the dialog calls this as the admin types. Returns per-field
# collision/validity from the catalog cache plus a server-authoritative
# repo-name collision check.
# client. Two fields under meta-only: the integer ID and the owners
# precondition. There is no repo name to validate (§13.2).
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/graduate/check")
@@ -281,16 +255,7 @@ def make_router(
# admins/owners, but the check itself is read-only.
candidate_id = (request.query_params.get("id") or "").strip()
candidate_repo = (request.query_params.get("repo") or "").strip()
owners = json.loads(rfc["owners_json"] or "[]")
blocking_count = db.conn().execute(
"""
SELECT COUNT(*) AS n FROM cached_prs
WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit'
""",
(slug,),
).fetchone()["n"]
# ID field
id_payload: dict[str, Any] = {"value": candidate_id, "ok": True, "error": None}
@@ -304,34 +269,6 @@ def make_router(
id_payload["ok"] = False
id_payload["error"] = f"Integer ID {candidate_id} is already taken"
# Repo field — validate pattern then probe Gitea for an existing
# repo of that name under our org. The repo lookup is a single GET
# so it's cheap to call on every keystroke (debounced from the
# client per §13.2).
repo_payload: dict[str, Any] = {"value": candidate_repo, "ok": True, "error": None}
if not candidate_repo:
repo_payload["ok"] = False
repo_payload["error"] = "Repo name is required"
elif not _is_valid_repo_name(candidate_repo):
repo_payload["ok"] = False
repo_payload["error"] = (
"Repo name must be alphanumerics, dashes, dots, or underscores "
"(start with alphanumeric)"
)
else:
try:
existing = await gitea.get_repo(config.gitea_org, candidate_repo)
except GiteaError as e:
# Network/auth flake — surface as a non-fatal hint; the
# atomic server-side check at POST time is the authority.
existing = None
log.warning("graduate_check: Gitea get_repo error: %s", e)
if existing is not None:
repo_payload["ok"] = False
repo_payload["error"] = (
f"Repo `{config.gitea_org}/{candidate_repo}` already exists"
)
# Owners precondition — §13's opening paragraph.
owners_payload: dict[str, Any] = {
"ok": len(owners) > 0,
@@ -340,28 +277,13 @@ def make_router(
"error": None if len(owners) > 0 else "No owners claimed yet",
}
# Blocking PR precondition — §9.8 / §13's opening paragraph.
prs_payload: dict[str, Any] = {
"ok": blocking_count == 0,
"count": blocking_count,
"error": (
None if blocking_count == 0
else f"{blocking_count} open body-edit PR{'' if blocking_count == 1 else 's'} blocking graduation"
),
}
in_flight = _get_active(slug)
any_invalid = not (
id_payload["ok"] and repo_payload["ok"]
and owners_payload["ok"] and prs_payload["ok"]
)
any_invalid = not (id_payload["ok"] and owners_payload["ok"])
return {
"slug": slug,
"id": id_payload,
"repo": repo_payload,
"owners": owners_payload,
"blocking_prs": prs_payload,
"can_submit": (not any_invalid) and (in_flight is None or in_flight.finished),
"in_flight": (
None if in_flight is None
@@ -370,8 +292,8 @@ def make_router(
}
# -------------------------------------------------------------------
# §13.3: POST /api/rfcs/<slug>/graduate
# Atomic re-validation, then kicks off the sequence as an async task.
# POST /api/rfcs/<slug>/graduate
# Atomic re-validation, then kicks off the flip as an async task.
# The client opens GET /graduate/progress on confirm to watch the SSE.
# -------------------------------------------------------------------
@@ -392,9 +314,8 @@ def make_router(
# §13.2 atomic re-validation. The dialog's debounced check runs
# client-side as the admin types; this is the authoritative check
# that closes the dialog-open-to-confirm race.
# that closes the dialog-open-to-confirm race on the integer ID.
rfc_id = body.rfc_id.strip()
repo_name = body.repo_name.strip()
owners = [o.strip() for o in body.owners if o.strip()]
if not owners:
raise HTTPException(422, "Add at least one initial owner")
@@ -402,35 +323,10 @@ def make_router(
raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)")
if _rfc_id_taken(rfc_id, excluding_slug=slug):
raise HTTPException(409, f"Integer ID {rfc_id} is already taken")
if not _is_valid_repo_name(repo_name):
raise HTTPException(422, "Repo name must be alphanumerics, dashes, dots, or underscores")
try:
existing_repo = await gitea.get_repo(config.gitea_org, repo_name)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
if existing_repo is not None:
raise HTTPException(409, f"Repo `{config.gitea_org}/{repo_name}` already exists")
# §9.8 precondition gate — enforced before the bot starts the
# sequence so the §13.3 rollback complexity does not grow. An
# open body-edit PR against rfcs/<slug>.md would attempt to
# re-introduce a body to a frontmatter-only entry after step 3.
blocking = db.conn().execute(
"""
SELECT COUNT(*) AS n FROM cached_prs
WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit'
""",
(slug,),
).fetchone()["n"]
if blocking > 0:
raise HTTPException(
409,
f"{blocking} open body-edit PR{'' if blocking == 1 else 's'} block graduation",
)
# Read the meta-repo entry once — we need the file's sha for the
# graduation PR's update_file call and the original body so the
# bot can seed RFC.md on the new repo with the migrated body.
# graduation PR's update_file call 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, config.meta_repo, f"rfcs/{slug}.md", ref="main",
)
@@ -442,19 +338,17 @@ def make_router(
except Exception as e:
raise HTTPException(500, f"Meta entry malformed: {e}")
repo_full = f"{config.gitea_org}/{repo_name}"
arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1]
# Compose the graduated frontmatter — body stripped, graduation
# fields filled. The serializer is run now so the PR-open step
# has the contents pre-rendered (single source of truth for the
# body migration vs. the meta-entry update).
# Compose the graduated frontmatter — body KEPT, graduation fields
# filled, repo left null (§1). Serialized now so the PR-open step
# has the contents pre-rendered.
graduated_entry = entry_mod.Entry(
slug=slug,
title=super_draft_entry.title,
state="active",
id=rfc_id,
repo=repo_full,
repo=None,
proposed_by=super_draft_entry.proposed_by,
proposed_at=super_draft_entry.proposed_at,
graduated_at=entry_mod.today(),
@@ -462,37 +356,30 @@ def make_router(
owners=owners,
arbiters=arbiters,
tags=list(super_draft_entry.tags),
body="",
models=super_draft_entry.models,
funder=super_draft_entry.funder,
body=super_draft_entry.body,
)
graduated_contents = entry_mod.serialize(graduated_entry)
state = _new_active(
slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full,
owners=owners, arbiters=arbiters,
slug, rfc_id=rfc_id, owners=owners, arbiters=arbiters,
)
# Audit: graduation started. The terminal `graduate_complete` /
# `graduate_rollback` rows below close the linkable sequence.
# `graduate_failed` rows below close the linkable sequence.
_audit(
viewer.user_id, viewer.gitea_login, "graduate_start",
rfc_slug=slug,
details={
"rfc_id": rfc_id, "repo": repo_full, "owners": owners,
"blocking_prs": blocking,
},
details={"rfc_id": rfc_id, "owners": owners},
)
# Test seam: `?_sync=1` awaits the orchestrator inline so
# integration tests can assert post-conditions without driving
# the SSE. Production clients use the spec-described shape —
# POST returns immediately, the client subscribes to the
# progress SSE.
# the SSE. Production clients POST then subscribe to the SSE.
coro = _orchestrate(
config=config, gitea=gitea, bot=bot,
actor=viewer.as_actor(), state=state,
super_draft_body=super_draft_entry.body,
super_draft_title=super_draft_entry.title,
super_draft_tags=list(super_draft_entry.tags),
graduated_contents=graduated_contents,
meta_file_sha=meta_sha,
)
@@ -505,38 +392,31 @@ def make_router(
"ok": True,
"slug": slug,
"rfc_id": rfc_id,
"repo": repo_full,
"stream_url": f"/api/rfcs/{slug}/graduate/progress",
"finished": state.finished,
"succeeded": state.succeeded,
}
# -------------------------------------------------------------------
# §13.3: GET /api/rfcs/<slug>/graduate/progress
# SSE stream of the step transitions. One event per step transition
# (pending → running → done / failed), plus the trailing rollback
# step's events if any earlier step fails.
# GET /api/rfcs/<slug>/graduate/progress
# SSE stream of the flip's step transitions (open_pr, merge_pr).
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/graduate/progress")
async def graduate_progress(slug: str, request: Request):
# v0.6.0 (item #4): the progress SSE surfaces admin-internal step
# detail (repo name, PR number, rollback steps) that isn't part of
# the v0.3.0 anonymous-read contract for catalog/RFC bodies. The
# corresponding POST /graduate is gated to RFC owners/arbiters and
# app admins/owners via `_can_graduate`; the read SSE shares that
# operator-visible surface, so it requires at least an
# authenticated viewer. We keep the floor at require_user (not
# require_contributor) so a write-muted operator can still observe
# the progress of a graduation they kicked off before being muted.
# The progress SSE surfaces admin-internal step detail (PR number)
# that isn't part of the anonymous-read contract. POST /graduate is
# gated to RFC owners/arbiters and app admins/owners; the read SSE
# shares that operator-visible surface and requires an authenticated
# viewer. We keep the floor at require_user (not require_contributor)
# so a write-muted operator can still observe a graduation they
# kicked off before being muted.
auth.require_user(request)
state = _get_active(slug)
if state is None:
raise HTTPException(404, "No graduation in flight for this slug")
async def event_stream():
# Emit the current snapshot first so a late subscriber sees
# the steps already completed.
yield _sse_event("snapshot", state.to_payload())
if state.finished:
yield _sse_event("done", state.to_payload())
@@ -555,22 +435,16 @@ def make_router(
# §13.1: POST /api/rfcs/<slug>/claim
# Opens a meta-repo PR adding the actor's gitea_login to the entry's
# owners list. Anyone signed in may claim — the merge is gated to
# owners/admins per §13.1 (which collapses to admins for unclaimed
# entries since `owners` is empty).
# owners/admins per §13.1.
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/claim")
async def claim_ownership(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug)
# Refuse if the actor is already in owners — no-op claim.
existing_owners = json.loads(rfc["owners_json"] or "[]")
if viewer.gitea_login in existing_owners:
return {"ok": True, "noop": True}
# Refuse if a claim PR for this actor is already open. The branch
# name `claim/<slug>` collides per actor implicitly since Gitea
# refuses duplicate branch creation; we surface a clean 409 here
# so the client doesn't see a 502.
already = db.conn().execute(
"""
SELECT pr_number FROM cached_prs
@@ -581,8 +455,6 @@ def make_router(
if already:
raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}")
# Compose the new entry contents — owners list with the claimant
# appended.
fetched = await gitea.read_file(
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
)
@@ -626,7 +498,7 @@ def make_router(
# ---------------------------------------------------------------------------
# Orchestrator
# Orchestrator — the §13.3 in-place flip
# ---------------------------------------------------------------------------
@@ -637,57 +509,21 @@ async def _orchestrate(
bot: Bot,
actor: Actor,
state: GraduationState,
super_draft_body: str,
super_draft_title: str,
super_draft_tags: list[str],
graduated_contents: str,
meta_file_sha: str,
) -> None:
"""Run §13.3 step by step. Each step:
"""Open the flip PR, then merge it. Two steps, no transaction:
- marks itself `running` and pushes an event
- calls the bot method (which writes to Gitea + audit log)
- marks itself `done` (or `failed`) and pushes another event
- open_pr fails → nothing was created; the entry stays a super-draft.
- merge_pr fails → close the open PR and delete its branch (the only
artifact a mid-flip failure can leave on the meta repo), then the
entry stays a super-draft.
On failure at step N, every later step is marked `not-reached` and
`_rollback` runs undoes in reverse from N-1 to 1.
There is no rollback of a *merged* flip — once the meta-repo merge has
landed, the path forward is §3's `withdraw` (§13.5).
"""
try:
# ----- Step 1: create per-RFC repo -----
await _start(state, "create_repo", f"Creating `{state.repo_full}`…")
try:
await bot.create_rfc_repo_for_graduation(
actor, org=config.gitea_org, repo_name=state.repo_name,
slug=state.slug, title=super_draft_title,
)
except GiteaError as e:
await _fail(state, "create_repo", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at="create_repo")
return
await _done(state, "create_repo", state.repo_full)
# ----- Step 2: seed RFC.md, README.md, .rfc/metadata.yaml -----
await _start(state, "seed_files", "Writing initial commit on main…")
try:
await bot.seed_graduated_rfc(
actor,
org=config.gitea_org, repo_name=state.repo_name,
slug=state.slug, title=super_draft_title,
rfc_body=super_draft_body, rfc_id=state.rfc_id,
meta_full=config.meta_repo_full,
meta_path=f"rfcs/{state.slug}.md",
owners=state.owners, arbiters=state.arbiters,
tags=super_draft_tags,
)
except GiteaError as e:
await _fail(state, "seed_files", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at="seed_files")
return
await _done(state, "seed_files", "RFC.md, README.md, .rfc/metadata.yaml")
# ----- Step 3: open graduation PR -----
# ----- Step 1: open the graduation PR (flip frontmatter) -----
await _start(state, "open_pr", "Opening graduation PR…")
try:
pr = await bot.open_graduation_pr(
@@ -696,19 +532,18 @@ async def _orchestrate(
slug=state.slug,
new_file_contents=graduated_contents,
prior_sha=meta_file_sha,
rfc_id=state.rfc_id, repo_full=state.repo_full,
rfc_id=state.rfc_id,
owners=state.owners,
)
except GiteaError as e:
await _fail(state, "open_pr", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at="open_pr")
await _finish_failed(state, failed_at="open_pr", on_behalf_of=actor.gitea_login)
return
state.new_pr_number = pr["number"]
state.graduation_branch = pr["head"]["ref"]
await _done(state, "open_pr", f"PR #{state.new_pr_number}")
# ----- Step 4: merge the graduation PR -----
# ----- Step 2: merge the graduation PR -----
await _start(state, "merge_pr", f"Merging PR #{state.new_pr_number}")
try:
await bot.merge_graduation_pr(
@@ -720,35 +555,28 @@ async def _orchestrate(
)
except GiteaError as e:
await _fail(state, "merge_pr", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at="merge_pr")
await _cleanup_unmerged(config=config, bot=bot, actor=actor, state=state)
await _finish_failed(state, failed_at="merge_pr", on_behalf_of=actor.gitea_login)
return
await _done(state, "merge_pr", f"PR #{state.new_pr_number} merged")
# ----- Step 5: refresh the cache so the catalog flips immediately.
# Per §13.3 step 5 the webhook flow is the steady-state path, but
# we refresh inline so the dialog can transition to "graduation
# complete" with the catalog row already showing `active`. A
# cache-refresh failure does not unwind Git state — the
# reconciler will catch up per §4.1.
await _start(state, "refresh_cache", "Refreshing catalog and views…")
# Refresh the cache so the catalog flips immediately. The webhook
# flow is the steady-state path (§13.3); we refresh inline so the
# dialog can transition to "graduation complete" with the catalog
# row already showing `active`. A refresh failure does not unwind
# the merge — the reconciler catches up per §4.1.
try:
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
await cache.refresh_rfc_repo(config, gitea, state.slug)
except Exception as e:
log.warning("graduate refresh_cache failed for %s: %s", state.slug, e)
await _done(state, "refresh_cache", f"Cache will catch up via reconciler ({e})")
else:
await _done(state, "refresh_cache", "Catalog and main view updated")
log.warning("graduate cache refresh failed for %s: %s", state.slug, e)
# Terminal success row in the audit log.
_audit(
None, actor.gitea_login, "graduate_complete",
rfc_slug=state.slug,
details={
"rfc_id": state.rfc_id, "repo": state.repo_full,
"rfc_id": state.rfc_id,
"owners": state.owners, "pr_number": state.new_pr_number,
},
)
@@ -757,109 +585,38 @@ async def _orchestrate(
await state.queue.put({"event": "completed", "payload": state.to_payload()})
except Exception as e:
log.exception("graduate: unexpected error for %s", state.slug)
# Best-effort: mark the in-flight step failed, then roll back.
running = next((s for s in state.steps if s.status == "running"), None)
if running is not None:
await _fail(state, running.key, f"unexpected: {e}")
await _rollback(
config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at=running.key if running else "unknown",
await _finish_failed(
state, failed_at=running.key if running else "unknown",
on_behalf_of=actor.gitea_login,
)
finally:
# Push the sentinel so any open SSE handler returns.
await state.queue.put(None)
async def _rollback(
*,
config: Config, gitea: Gitea, bot: Bot, actor: Actor,
state: GraduationState, failed_at: str,
async def _cleanup_unmerged(
*, config: Config, bot: Bot, actor: Actor, state: GraduationState,
) -> None:
"""Run undoes in reverse order from the last completed step. Each
undo emits its own rollback-step event so the dialog can render the
cleanup as a visible step appended to the stack per §13.3."""
state.rollback_started = True
# Mark every step after the failed one as not-reached so the rendered
# stack is honest about what didn't run.
seen_failure = False
for s in state.steps:
if s.status == "failed":
seen_failure = True
continue
if seen_failure and s.status == "pending":
s.status = "not-reached"
# Walk completed steps in reverse and run their inverses.
for s in reversed(state.steps):
if s.status != "done":
continue
undo = _UNDO_BY_STEP.get(s.key)
if undo is None:
continue
rb = StepState(key=f"undo:{s.key}", label=f"Undo: {s.label}",
status="running", detail="")
state.rollback_steps.append(rb)
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
try:
detail = await undo(
config=config, gitea=gitea, bot=bot, actor=actor, state=state,
)
except Exception as e:
rb.status = "failed"
rb.detail = f"{e}"
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
continue
rb.status = "done"
rb.detail = detail or ""
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
_audit(
None, actor.gitea_login, "graduate_rollback",
rfc_slug=state.slug,
details={
"failed_at": failed_at,
"error": state.error,
"rfc_id": state.rfc_id,
"repo": state.repo_full,
"undone": [s.key for s in state.rollback_steps if s.status == "done"],
},
)
state.finished = True
state.succeeded = False
await state.queue.put({"event": "rolled_back", "payload": state.to_payload()})
async def _undo_create_repo(*, config, gitea, bot, actor, state) -> str:
await bot.delete_rfc_repo(
actor, org=config.gitea_org, repo_name=state.repo_name,
slug=state.slug, reason="graduation rollback",
)
return f"Deleted `{state.repo_full}`"
async def _undo_seed_files(*, config, gitea, bot, actor, state) -> str:
# The seed commits live inside the per-RFC repo created in step 1;
# deleting the repo (step 1's undo) reclaims them at the same time.
# We surface a separate rollback step here so the rendered stack
# mirrors the forward steps, but the work is folded into _undo_create_repo.
return "Folded into repo deletion"
async def _undo_open_pr(*, config, gitea, bot, actor, state) -> str:
"""A merge failure leaves the flip PR open on its `graduate-<slug>-<hex>`
branch. Close the PR and delete the branch so failed attempts don't
accumulate on the meta repo. Best-effort — failures here are logged,
not surfaced as a separate step (the entry already stays a super-draft).
"""
if state.new_pr_number is None:
return "No PR opened"
await bot.close_graduation_pr(
actor,
org=config.gitea_org, meta_repo=config.meta_repo,
pr_number=state.new_pr_number,
head_branch=state.graduation_branch or "",
slug=state.slug, reason="graduation rollback",
)
# Per the §19.2 "graduation rollback's branch cleanup" candidate
# that Slice 8 settles: delete the dash-suffixed branch on rollback
# so failed-graduation branches don't accumulate on the meta repo.
# The §12 hygiene sweep would catch this eventually, but closing
# the loop here removes the chance of pile-up across retries.
return
try:
await bot.close_graduation_pr(
actor,
org=config.gitea_org, meta_repo=config.meta_repo,
pr_number=state.new_pr_number,
head_branch=state.graduation_branch or "",
slug=state.slug, reason="graduation merge failed",
)
except Exception:
log.exception("graduate cleanup: close PR #%s failed", state.new_pr_number)
branch_name = state.graduation_branch or ""
if branch_name:
try:
@@ -870,24 +627,35 @@ async def _undo_open_pr(*, config, gitea, bot, actor, state) -> str:
branch=branch_name,
slug=state.slug,
action_kind="delete_post_merge_branch",
reason="graduation rollback",
reason="graduation merge failed",
)
except Exception:
log.exception("rollback: delete_branch failed for %s", branch_name)
return f"Closed PR #{state.new_pr_number}"
log.exception("graduate cleanup: delete_branch %s failed", branch_name)
# merge_pr's undo is intentionally absent — once the meta-repo merge has
# landed, graduation is irreversible per §13.5. If we ever reach a merged
# state and a later step fails (which can't happen — refresh_cache failures
# fold into success), there is no clean undo path; the user transitions
# via §3's `withdraw` instead.
_UNDO_BY_STEP = {
"create_repo": _undo_create_repo,
"seed_files": _undo_seed_files,
"open_pr": _undo_open_pr,
}
async def _finish_failed(state: GraduationState, *, failed_at: str, on_behalf_of: str) -> None:
"""Mark any step after the failure as not-reached, write the audit
row, and emit the terminal failed event."""
seen_failure = False
for s in state.steps:
if s.status == "failed":
seen_failure = True
continue
if seen_failure and s.status == "pending":
s.status = "not-reached"
_audit(
None, on_behalf_of, "graduate_failed",
rfc_slug=state.slug,
details={
"failed_at": failed_at,
"error": state.error,
"rfc_id": state.rfc_id,
"pr_number": state.new_pr_number,
},
)
state.finished = True
state.succeeded = False
await state.queue.put({"event": "failed", "payload": state.to_payload()})
# ---------------------------------------------------------------------------
@@ -907,7 +675,7 @@ def _can_graduate(rfc, viewer) -> bool:
def _audit(
actor_user_id: int | None,
on_behalf_of: str,
on_behalf_of: str | None,
action_kind: str,
*,
rfc_slug: str | None = None,
@@ -918,7 +686,7 @@ def _audit(
"""Direct audit-log write for graduation lifecycle events that don't
correspond to a single Gitea write. The per-step Gitea writes log
themselves via the bot's `_log`; this is for the bracketing
`graduate_start` / `graduate_complete` / `graduate_rollback` rows."""
`graduate_start` / `graduate_complete` / `graduate_failed` rows."""
db.conn().execute(
"""
INSERT INTO actions
@@ -935,8 +703,7 @@ def _audit(
json.dumps(details) if details else None,
),
)
# §15 chokepoint per Slice 6: the bracket rows (graduate_start,
# graduate_complete) drive their own notifications per §15.1.
# §15 chokepoint: the bracket rows drive their own notifications.
from . import notify
notify.fan_out_from_action(
actor_user_id=actor_user_id,
+15 -11
View File
@@ -603,7 +603,7 @@ def make_router(
repo=repo,
slug=slug,
file_path=_file_path_for(rfc),
is_super_draft=_is_super_draft(rfc),
is_super_draft=_is_meta_resident(rfc),
original_branch=original_branch,
resolution_branch=resolution_branch,
)
@@ -671,32 +671,36 @@ def make_router(
"""Used by the §10 PR-flow read and write paths. Per §17's routing-
collapse rule, a super-draft RFC also routes here — its body-edit
PRs are meta-repo PRs with pr_kind='meta_body_edit', but the API
surface is identical."""
surface is identical. Under the meta-only topology (§1) an active
RFC is meta-resident too (repo is null) — that is normal, not an
error, so there is no per-RFC-repo precondition."""
row = _require_rfc(slug)
if row["state"] not in ("active", "super-draft"):
raise HTTPException(409, f"RFC is {row['state']}")
if row["state"] == "active" and not row["repo"]:
raise HTTPException(409, "RFC has no repo")
return row
def _is_super_draft(rfc) -> bool:
return rfc["state"] == "super-draft"
def _is_meta_resident(rfc) -> bool:
"""Meta-only topology (§1): an entry lives in the meta repo's
`rfcs/<slug>.md` (super-draft or active-in-place) unless it carries
a legacy per-RFC `repo:` — which nothing does after the RFC-0001
fold-back (§13.6). Drives the body/path/repo dispatch below."""
return not rfc["repo"]
def _owner_repo(rfc) -> tuple[str, str]:
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
return config.gitea_org, config.meta_repo
owner, repo = rfc["repo"].split("/", 1)
return owner, repo
def _file_path_for(rfc) -> str:
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
return f"rfcs/{rfc['slug']}.md"
return RFC_FILE_PATH
def _extract_body(rfc, file_contents: str) -> str:
"""For super-draft entries the file on disk is the full
"""For meta-resident entries the file on disk is the full
frontmatter+body envelope; the editable body is entry.body."""
if not _is_super_draft(rfc):
if not _is_meta_resident(rfc):
return file_contents
try:
entry = entry_mod.parse(file_contents)
@@ -760,7 +764,7 @@ def make_router(
return row["original_pr_number"] if row else None
async def _refresh_after_pr_write(rfc) -> None:
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
+13 -136
View File
@@ -695,111 +695,7 @@ class Bot:
)
return sha
# ----- §13 graduation: per-step primitives and rollback inverses -----
async def create_rfc_repo_for_graduation(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
title: str,
) -> dict:
"""§13.3 step 1: create the per-RFC repo.
Empty repo (no auto-init) — `seed_graduated_rfc` writes the first
commit on `main`. Returns the Gitea repo payload."""
repo = await self._gitea.create_org_repo(
org, repo_name, description=f"RFC: {title}"
)
_log(
actor,
"graduate_repo_create",
rfc_slug=slug,
details={"repo": f"{org}/{repo_name}", "title": title},
)
return repo
async def seed_graduated_rfc(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
title: str,
rfc_body: str,
rfc_id: str,
meta_full: str,
meta_path: str,
owners: list[str],
arbiters: list[str],
tags: list[str],
) -> str:
"""§13.3 step 2: seed RFC.md, README.md, .rfc/metadata.yaml on the
new repo's `main`. Three create_file calls; one audit row.
Returns the final commit sha on main.
"""
import yaml as _yaml
ae = actor.email or f"{actor.gitea_login}@users.noreply"
# 2a) RFC.md — the document. The super-draft's body is migrated
# verbatim per §13.3; if the body is empty we seed a minimal
# placeholder so the editor has something to render on first open.
body = rfc_body.strip() + "\n" if rfc_body.strip() else (
f"# {title}\n\n*RFC.md to be filled in — the super-draft graduated with an empty body.*\n"
)
rfc_msg = _stamp_single(f"Seed RFC.md from super-draft {slug}", actor)
rfc_result = await self._gitea.create_file(
org, repo_name, "RFC.md",
content=body, message=rfc_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
# 2b) README.md — header pointing back at the meta-repo entry.
readme = (
f"# {rfc_id}{title}\n\n"
f"This repository carries the canonical text of {rfc_id}.\n"
f"The meta-repo entry is `{meta_path}` in `{meta_full}`.\n\n"
f"The RFC body is in `RFC.md`. Contributions go through the\n"
f"app's §8 RFC view — open a branch, propose changes, land a PR.\n"
)
readme_msg = _stamp_single(f"Seed README.md for {rfc_id}", actor)
await self._gitea.create_file(
org, repo_name, "README.md",
content=readme, message=readme_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
# 2c) .rfc/metadata.yaml — mirror of meta-repo frontmatter for
# future tooling (linting, automation, CI lookups).
meta_yaml = _yaml.safe_dump(
{
"slug": slug, "title": title, "id": rfc_id,
"owners": owners, "arbiters": arbiters, "tags": list(tags),
},
sort_keys=False,
)
meta_msg = _stamp_single(f"Seed .rfc/metadata.yaml for {rfc_id}", actor)
meta_result = await self._gitea.create_file(
org, repo_name, ".rfc/metadata.yaml",
content=meta_yaml, message=meta_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
last_sha = (
meta_result.get("commit", {}).get("sha")
or rfc_result.get("commit", {}).get("sha")
or ""
)
_log(
actor,
"graduate_repo_seed",
rfc_slug=slug,
branch_name="main",
bot_commit_sha=last_sha,
details={"repo": f"{org}/{repo_name}", "rfc_id": rfc_id},
)
return last_sha
# ----- §13 graduation (meta-only): open + merge the flip PR -----
async def open_graduation_pr(
self,
@@ -811,13 +707,14 @@ class Bot:
new_file_contents: str,
prior_sha: str,
rfc_id: str,
repo_full: str,
owners: list[str],
) -> dict:
"""§13.3 step 3: open a PR against the meta repo that strips the
super-draft body and fills graduation frontmatter fields. Branch
name uses the `graduate-<slug>-<6hex>` shape — dash-separated like
the other meta-repo branches per the §19.2 path-routing candidate.
"""§13.3 (meta-only): open a PR against the meta repo that flips the
entry's frontmatter to `state: active` with the integer `id` and
graduation stamps — **keeping the body unchanged** (§1 meta-only
topology; no repo is created and no body is stripped). 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
@@ -844,11 +741,11 @@ class Bot:
pr_body_text = (
f"Graduates super-draft `{slug}` to active.\n\n"
f"- ID: `{rfc_id}`\n"
f"- Repo: `{repo_full}`\n"
f"- Owners: {owners_str}\n\n"
f"The meta-repo entry becomes frontmatter-only; the canonical body\n"
f"moves to `RFC.md` in the new repo. The graduation sequence is\n"
f"transactional per §13.3."
f"This is an in-place state flip per the meta-only topology\n"
f"(SPEC §1, §13.3): the entry `rfcs/{slug}.md` keeps its body and\n"
f"stays in the meta repo. Only the frontmatter changes — `state`,\n"
f"`id`, and the graduation stamps."
)
_subject, pr_body = _stamp("", pr_body_text, actor)
pr = await self._gitea.create_pull(
@@ -862,7 +759,7 @@ class Bot:
branch_name=branch,
pr_number=pr["number"],
bot_commit_sha=commit_sha,
details={"pr_title": pr_title, "rfc_id": rfc_id, "repo": repo_full},
details={"pr_title": pr_title, "rfc_id": rfc_id},
)
return pr
@@ -912,27 +809,7 @@ class Bot:
details={"rfc_id": rfc_id},
)
# ----- §13.3 rollback inverses -----
async def delete_rfc_repo(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
reason: str,
) -> None:
"""Undo of `create_rfc_repo_for_graduation`. Records `graduate_repo_delete`
in the audit log with the rollback reason so the §13.3 stack's
rendered failure surface can be reconstructed from `actions`."""
await self._gitea.delete_repo(org, repo_name)
_log(
actor,
"graduate_repo_delete",
rfc_slug=slug,
details={"repo": f"{org}/{repo_name}", "reason": reason},
)
# ----- §13.3 (meta-only): cleanup of an unmerged flip PR -----
async def close_graduation_pr(
self,
+10 -4
View File
@@ -342,9 +342,13 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
if not slug:
continue
rfc = db.conn().execute(
"SELECT state FROM cached_rfcs WHERE slug = ?", (slug,)
"SELECT state, repo FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if not rfc or rfc["state"] != "super-draft":
# Meta-only topology (§1): edit branches live on the meta repo for
# every meta-resident entry — super-drafts and active RFCs alike
# (active RFCs are graduated in place and keep editing here, §13).
# A legacy per-RFC repo (repo set) is the only thing excluded.
if not rfc or rfc["repo"] or rfc["state"] not in ("super-draft", "active"):
continue
edit_keys_seen.add((slug, name))
db.conn().execute(
@@ -365,7 +369,8 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
# diverges from this single point.
if meta_main_sha:
super_drafts = db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE state = 'super-draft'"
"SELECT slug FROM cached_rfcs "
"WHERE repo IS NULL AND state IN ('super-draft', 'active')"
).fetchall()
for r in super_drafts:
db.conn().execute(
@@ -387,7 +392,8 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
SELECT b.rfc_slug, b.branch_name
FROM cached_branches b
JOIN cached_rfcs r ON r.slug = b.rfc_slug
WHERE r.state = 'super-draft'
WHERE r.repo IS NULL
AND r.state IN ('super-draft', 'active')
AND b.state != 'deleted'
AND b.branch_name != 'main'
"""
+8 -6
View File
@@ -284,9 +284,10 @@ async def _delete_branch_via_bot(
reason: str,
) -> bool:
"""Call `bot.delete_branch` with the system actor. Resolves the
`(org, repo)` pair from the slug: super-draft edit branches and
graduation branches live on the meta repo; active-RFC branches
live on the per-RFC repo named by `cached_rfcs.repo`.
`(org, repo)` pair from the slug: under the meta-only topology (§1)
every meta-resident entry's edit branches and graduation branches
live on the meta repo; a legacy per-RFC repo (a `repo:` that survives
from before the fold-back, §13.6) is named by `cached_rfcs.repo`.
Returns True on a clean delete; False if the rfc row is missing
(we leave the branch row in place — a subsequent reconciler sweep
@@ -297,12 +298,13 @@ async def _delete_branch_via_bot(
if rfc is None:
log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch)
return False
if rfc["state"] == "super-draft":
if not rfc["repo"]:
owner, repo = config.gitea_org, config.meta_repo
elif rfc["state"] == "active" and rfc["repo"] and "/" in rfc["repo"]:
elif "/" in rfc["repo"]:
owner, repo = rfc["repo"].split("/", 1)
else:
log.warning("hygiene: cannot resolve repo for %s state=%s", slug, rfc["state"])
log.warning("hygiene: cannot resolve repo for %s state=%s repo=%r",
slug, rfc["state"], rfc["repo"])
return False
try:
await bot.delete_branch(
+8 -7
View File
@@ -119,19 +119,20 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
r = client.post(f"/api/rfcs/ohm/prs/{body_pr}/merge")
assert r.status_code == 200, r.text
# --- 7. Graduate the super-draft. ---
# --- 7. Graduate the super-draft (in-place flip, §13). ---
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is True
d = client.get("/api/rfcs/ohm").json()
assert d["state"] == "active"
assert d["repo"] == "wiggleverse/rfc-0001-ohm"
# Meta-only topology (§1): no per-RFC repo — the active RFC lives
# in its meta entry, `repo` stays null.
assert d["repo"] is None
# --- 8. Alice opens a PR on the now-active RFC's per-RFC repo. ---
# --- 8. Alice opens a PR on the now-active RFC (meta repo). ---
# v0.16.0 (item #12): ben is the RFC owner now; alice needs a
# per-RFC contributor invitation to cut a branch. In the
# production flow, ben would invite her via /invitations and
@@ -200,8 +201,8 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
)
assert counters["deleted_post_merge"] >= 1, counters
# The branch is gone from FakeGitea + cached row flipped.
assert active_branch not in fake.branches[("wiggleverse", "rfc-0001-ohm")]
# The branch is gone from FakeGitea (meta repo) + cached row flipped.
assert active_branch not in fake.branches[("wiggleverse", "meta")]
cached = db.conn().execute(
"SELECT state FROM cached_branches WHERE rfc_slug = 'ohm' AND branch_name = ?",
(active_branch,),
+171 -198
View File
@@ -1,29 +1,29 @@
"""End-to-end integration tests for the Slice 5 vertical (§13 in full).
"""End-to-end integration tests for the §13 graduation flow under the
meta-only topology (SPEC §1, ROADMAP #36).
Walks the §13.3 transactional sequence end-to-end against the in-process
FakeGitea from test_propose_vertical.py:
Graduation is an in-place state flip on the meta entry — no per-RFC repo
is created, the body is kept, and there is no multi-step transaction or
rollback (§13.3). These tests walk it against the in-process FakeGitea
from test_propose_vertical.py:
* Seed an owned super-draft (skipping the propose+merge + §13.1 claim
round-trips already proven by Slice 1 and exercised in
test_claim_opens_meta_pr below for the §13.1 surface itself).
* Seed an owned super-draft (the §13.1 claim flow is exercised
separately in test_claim_opens_meta_pr).
* GET /api/rfcs/<slug>/graduate/check returns per-field validity for
the dialog.
* GET /api/rfcs/<slug>/blocking-prs returns the §9.8 precondition list.
* POST /api/rfcs/<slug>/graduate?_sync=1 runs the five-step sequence
inline. On success: per-RFC repo exists with RFC.md / README.md /
.rfc/metadata.yaml, meta-entry body is stripped, frontmatter is
graduated, cached_rfcs.state is 'active'.
* §9.8 precondition gate refuses the start when a body-edit PR is open.
* Rollback on a mid-sequence failure unwinds repo creation cleanly.
* §13.4 chat migration: whole-doc threads under (slug, 'main') survive
graduation unchanged — the rfc_slug is the canonical key per §2.3,
so no data movement is needed.
* §9.8 pre-graduation history: the new RFC's /main response surfaces
edit-branch threads under `pre_graduation_history`.
the two-field dialog (integer id + owners; no repo name).
* POST /api/rfcs/<slug>/graduate?_sync=1 opens + merges the flip PR
inline. On success: NO per-RFC repo, the meta entry is `state:
active` with the body KEPT and `repo` null, cached_rfcs.state flips
to 'active'.
* An open body-edit PR no longer blocks graduation (§9.8) — they
coexist.
* An open-PR failure leaves the entry a super-draft (nothing created);
a merge failure cleans up the half-open PR/branch and leaves the
entry a super-draft.
* §13.4: chat threads + edit branches stay put — the slug is the
canonical key per §2.3, so nothing moves at the flip.
The orchestrator's `?_sync=1` seam awaits the sequence inline so the
test can assert post-conditions on the same event loop tick. Production
clients use the spec-described SSE shape via `/graduate/progress`.
The orchestrator's `?_sync=1` seam awaits the flip inline so the test can
assert post-conditions on the same event loop tick.
"""
from __future__ import annotations
@@ -110,7 +110,9 @@ def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str
# ---------------------------------------------------------------------------
def test_graduate_check_validates_three_fields(app_with_fake_gitea):
def test_graduate_check_validates_id_and_owners(app_with_fake_gitea):
"""Two-field dialog under meta-only: integer id + owners. No repo
name to validate (§13.2)."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
@@ -121,57 +123,46 @@ def test_graduate_check_validates_three_fields(app_with_fake_gitea):
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Happy: a fresh RFC-0001 + rfc-0001-ohm repo name.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
# Happy: a fresh RFC-0001.
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
assert r.status_code == 200, r.text
d = r.json()
assert d["id"]["ok"] is True
assert d["repo"]["ok"] is True
assert d["owners"]["ok"] is True
assert d["blocking_prs"]["ok"] is True
assert d["can_submit"] is True
# No repo field in the meta-only check response.
assert "repo" not in d
# ID format error — non-numeric tail.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-abcd", "repo": "rfc-0001-ohm"})
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-abcd"})
d = r.json()
assert d["id"]["ok"] is False
assert d["can_submit"] is False
# Repo name pattern error — leading dot.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": ".bad"})
d = r.json()
assert d["repo"]["ok"] is False
def test_graduate_check_refuses_when_no_owners(app_with_fake_gitea):
"""An unclaimed super-draft fails the owners precondition; can_submit
flips false even with valid id+repo."""
flips false even with a valid id."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
# No owners — simulates an unclaimed super-draft.
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=[])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
d = r.json()
assert d["owners"]["ok"] is False
assert "No owners" in d["owners"]["error"]
assert d["can_submit"] is False
def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea):
"""The full §13.3 sequence: create repo, seed files, open PR, merge
PR, refresh cache. End state: cached_rfcs.state='active', the meta
entry's body is stripped, the per-RFC repo has RFC.md, the audit
log carries graduate_start → graduate_complete bracketing the
per-step rows."""
def test_graduate_happy_path_flips_in_place_keeping_body(app_with_fake_gitea):
"""The meta-only flip: open + merge a frontmatter PR. End state:
cached_rfcs.state='active', the meta entry's body is KEPT, `repo` is
null, NO per-RFC repo exists, and the audit log carries graduate_start
→ graduate_pr_open → graduate_pr_merge → graduate_complete."""
from fastapi.testclient import TestClient
from app import db, entry as entry_mod
@@ -186,60 +177,57 @@ def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0042", "repo_name": "rfc-0042-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0042", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
d = r.json()
assert d["finished"] is True
assert d["succeeded"] is True
assert d["repo"] == "wiggleverse/rfc-0042-ohm"
# No repo in the response, no per-RFC repo on Gitea.
assert "repo" not in d
assert ("wiggleverse", "rfc-0042-ohm") not in fake.repos
assert not any(
k[1].startswith("rfc-0042") for k in fake.repos
), f"a per-RFC repo was created: {fake.repos}"
# 1. Per-RFC repo exists on Gitea.
assert ("wiggleverse", "rfc-0042-ohm") in fake.repos
# 2. Seed files landed on main.
assert ("wiggleverse", "rfc-0042-ohm", "main", "RFC.md") in fake.files
assert ("wiggleverse", "rfc-0042-ohm", "main", "README.md") in fake.files
assert ("wiggleverse", "rfc-0042-ohm", "main", ".rfc/metadata.yaml") in fake.files
rfc_md = fake.files[("wiggleverse", "rfc-0042-ohm", "main", "RFC.md")]["content"]
assert "Open Human Model is a framework" in rfc_md
# 3. Meta entry body is stripped + frontmatter graduated.
# 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)
assert graduated.state == "active"
assert graduated.id == "RFC-0042"
assert graduated.repo == "wiggleverse/rfc-0042-ohm"
assert graduated.repo is None
assert graduated.graduated_by == "ben"
assert graduated.graduated_at # non-empty ISO date
assert graduated.body.strip() == ""
# 5. cached_rfcs.state flipped to active via the inline refresh.
assert "Open Human Model is a framework" in graduated.body
# cached_rfcs flipped to active via the inline refresh; body intact.
cached = db.conn().execute(
"SELECT state, rfc_id, repo, body FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "active"
assert cached["rfc_id"] == "RFC-0042"
assert cached["repo"] == "wiggleverse/rfc-0042-ohm"
# cached body now mirrors RFC.md from the per-RFC repo.
assert cached["repo"] is None
assert "Open Human Model is a framework" in cached["body"]
# Audit log: graduate_start, graduate_repo_create, graduate_repo_seed,
# graduate_pr_open, graduate_pr_merge, graduate_complete, in order.
kinds = [
r["action_kind"]
for r in db.conn().execute(
row["action_kind"]
for row in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
for needed in ("graduate_start", "graduate_repo_create",
"graduate_repo_seed", "graduate_pr_open",
for needed in ("graduate_start", "graduate_pr_open",
"graduate_pr_merge", "graduate_complete"):
assert needed in kinds, f"missing audit row {needed}: {kinds}"
# The retired per-repo steps must NOT appear.
for gone in ("graduate_repo_create", "graduate_repo_seed",
"graduate_repo_delete", "graduate_rollback"):
assert gone not in kinds, f"retired audit row present: {gone}"
def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
"""§9.8: an open meta-repo body-edit PR against rfcs/<slug>.md blocks
graduation before the bot starts the sequence — §13.3's rollback
complexity does not grow."""
def test_graduate_coexists_with_open_body_edit_pr(app_with_fake_gitea):
"""§9.8 (meta-only): an open meta-repo body-edit PR no longer blocks
graduation the body is kept, so they coexist. /check stays
submittable and the flip succeeds."""
from fastapi.testclient import TestClient
from app import db
@@ -249,13 +237,11 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
# v0.16.0 (item #12): ben is the RFC owner; alice needs a per-RFC
# contributor invitation to cut an edit branch on the super-draft.
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
# Cut an edit branch and open a body-edit PR (full Slice 4 path).
# Cut an edit branch and open a body-edit PR.
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
thread_id = view["main_thread_id"]
@@ -279,94 +265,31 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
f"/api/rfcs/ohm/branches/{branch}/open-pr",
json={"title": "Add harm", "description": "Adds harm dimension."},
).json()["pr_number"]
assert pr_number # PR is open
# /blocking-prs surfaces it.
# /check stays submittable despite the open body-edit PR.
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.get("/api/rfcs/ohm/blocking-prs")
items = r.json()["items"]
assert len(items) == 1
assert items[0]["pr_number"] == pr_number
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"}).json()
assert "blocking_prs" not in d
assert d["can_submit"] is True
# /check refuses can_submit.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
d = r.json()
assert d["blocking_prs"]["ok"] is False
assert d["can_submit"] is False
# POST refuses with 409 — the bot never starts the sequence.
# The flip succeeds — coexists with the open body-edit PR.
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
)
assert r.status_code == 409
assert "blocking graduation" in r.text or "block" in r.text
def test_graduate_rollback_on_step_2_seed_failure(app_with_fake_gitea):
"""Step 2 (seed files) fails partway → the orchestrator rolls back
step 1 (delete the repo) and records the rollback in the audit log.
The cached_rfcs row stays at 'super-draft'."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
from app.gitea import Gitea, GiteaError
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Monkey-patch the bot to fail on seed_graduated_rfc. The repo
# has already been created in step 1; the rollback must delete it.
orig_seed = Bot.seed_graduated_rfc
async def boom(self, *args, **kwargs):
raise GiteaError(500, "simulated seed failure for rollback test")
Bot.seed_graduated_rfc = boom
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0003", "repo_name": "rfc-0003-ohm",
"owners": ["ben"]},
)
finally:
Bot.seed_graduated_rfc = orig_seed
assert r.status_code == 200, r.text
d = r.json()
assert d["finished"] is True
assert d["succeeded"] is False
# Repo deleted as the rollback inverse.
assert ("wiggleverse", "rfc-0003-ohm") not in fake.repos
# Meta entry unchanged.
assert r.json()["succeeded"] is True
cached = db.conn().execute(
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "super-draft"
assert cached["rfc_id"] is None
# Audit log carries the rollback row.
kinds = [
r["action_kind"]
for r in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
assert "graduate_start" in kinds
assert "graduate_repo_create" in kinds
assert "graduate_repo_delete" in kinds
assert "graduate_rollback" in kinds
assert "graduate_complete" not in kinds
assert cached["state"] == "active"
def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
"""Step 3 (open PR) fails → the orchestrator rolls back steps 2 and
1 (deleting the repo, which reclaims the seed commits at the same
time). The meta-repo entry is untouched."""
def test_graduate_open_pr_failure_leaves_super_draft(app_with_fake_gitea):
"""An open-PR failure creates nothing — the entry stays a super-draft
with its body intact and no graduation PR on the meta repo."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
@@ -387,19 +310,92 @@ def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0007", "repo_name": "rfc-0007-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0007", "owners": ["ben"]},
)
finally:
Bot.open_graduation_pr = orig_open_pr
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is False
# Repo torn down.
assert ("wiggleverse", "rfc-0007-ohm") not in fake.repos
# Meta entry's body still has the pitch (not stripped).
# Entry untouched: still super-draft, body intact on main.
cached = db.conn().execute(
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "super-draft"
assert cached["rfc_id"] is None
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
assert "Open Human Model is a framework" in meta_text
kinds = [
row["action_kind"]
for row in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
assert "graduate_start" in kinds
assert "graduate_failed" in kinds
assert "graduate_complete" not in kinds
def test_graduate_merge_failure_cleans_up_pr(app_with_fake_gitea):
"""A merge failure leaves the flip PR open on its dash-suffixed
branch; the orchestrator closes the PR and deletes the branch so
failed attempts don't accumulate. The entry stays a super-draft —
the flip PR's commit was on a branch, not on main."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
from app.gitea import GiteaError
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
orig_merge = Bot.merge_graduation_pr
async def boom(self, *args, **kwargs):
raise GiteaError(502, "simulated merge failure")
Bot.merge_graduation_pr = boom
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0009", "owners": ["ben"]},
)
finally:
Bot.merge_graduation_pr = orig_merge
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is False
# Entry stays super-draft on main (the flip never merged).
cached = db.conn().execute(
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "super-draft"
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
assert "state: super-draft" in meta_text
# The dash-suffixed graduation branch was cleaned up.
grad_branches = [
name for (o, repo), branches in fake.branches.items()
if (o, repo) == ("wiggleverse", "meta")
for name in branches
if name.startswith("graduate-ohm-")
]
assert grad_branches == [], f"leftover graduation branch: {grad_branches}"
kinds = [
row["action_kind"]
for row in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
assert "graduate_pr_open" in kinds
assert "graduate_failed" in kinds
assert "graduate_complete" not in kinds
def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
"""A second graduation request for a slug already in-flight is refused."""
@@ -414,17 +410,14 @@ def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Seed a synthetic in-flight state so the registry refuses the second.
st = api_graduation._new_active(
"ohm", rfc_id="RFC-0001", repo_name="rfc-0001-ohm",
repo_full="wiggleverse/rfc-0001-ohm", owners=["ben"], arbiters=["ben"],
"ohm", rfc_id="RFC-0001", owners=["ben"], arbiters=["ben"],
)
st.finished = False
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
)
assert r.status_code == 409
finally:
@@ -432,11 +425,9 @@ def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_gitea):
"""§13.4: chat threads on the super-draft's canonical-body view
(`branch_name='main'`) are interpreted as the new RFC's main-thread
after graduation. The rows don't move — the rfc_slug is canonical
per §2.3 — so the same thread surfaces from both before and after
the graduation."""
"""§13.4: chat threads on the entry's main view (`branch_name='main'`)
stay put across the flip — the rfc_slug is canonical per §2.3 — so the
same thread surfaces from /branches/main before and after graduation."""
from fastapi.testclient import TestClient
from app import db
@@ -448,9 +439,6 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Materialize a whole-doc main thread + a message on it. This
# mirrors what reading the canonical-body view would create
# lazily (§8.12 / api_branches._ensure_branch_chat_thread).
cur = db.conn().execute(
"""
INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, created_by)
@@ -466,32 +454,26 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git
(thread_id,),
)
# Graduate.
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0099", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
# The thread row's identity is unchanged.
row = db.conn().execute(
"SELECT id, branch_name FROM threads WHERE id = ?", (thread_id,),
).fetchone()
assert row["branch_name"] == "main"
# The new RFC's main view surfaces the same thread id as its
# whole-doc main thread (the entry is now active, the branch
# 'main' now points at the per-RFC repo's main, but the
# `(rfc_slug, branch_name)` key remains the canonical anchor).
r = client.get("/api/rfcs/ohm/branches/main")
assert r.status_code == 200, r.text
assert r.json()["main_thread_id"] == thread_id
def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea):
"""§9.8: after graduation, threads on meta-repo edit branches stay
attached to their original branch_name and surface from the new
RFC's /main response under `pre_graduation_history`."""
def test_edit_branch_surfaces_normally_after_graduation(app_with_fake_gitea):
"""§13.4 (meta-only): after graduation an edit branch is a *current*
branch of the now-active RFC — it surfaces in the normal `branches`
list, and there is no separate `pre_graduation_history` set (that
affordance is legacy per-repo only)."""
from fastapi.testclient import TestClient
from app import db
@@ -501,10 +483,8 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
# v0.16.0 (item #12): alice needs per-RFC contributor access.
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
# Alice cuts an edit branch and starts chatting on it.
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
@@ -513,30 +493,25 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
db.conn().execute(
"""
INSERT INTO thread_messages (thread_id, role, author_user_id, text)
VALUES (?, 'user', 2, 'pre-graduation note on an edit branch')
VALUES (?, 'user', 2, 'note on an edit branch')
""",
(thread_id,),
)
# Ben graduates.
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0100", "repo_name": "rfc-0100-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0100", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
# /main on the now-active RFC surfaces the pre-graduation history.
r = client.get("/api/rfcs/ohm/main")
d = r.json()
d = client.get("/api/rfcs/ohm/main").json()
assert d["state"] == "active"
hist = d["pre_graduation_history"]
assert len(hist) >= 1
assert any(h["branch_name"] == branch for h in hist)
target = next(h for h in hist if h["branch_name"] == branch)
assert target["message_count"] >= 1
# The edit branch is a current branch; no pre-graduation hop.
assert d["pre_graduation_history"] == []
assert any(b["name"] == branch for b in d["branches"]), \
f"edit branch not in branches: {[b['name'] for b in d['branches']]}"
def test_claim_opens_meta_pr(app_with_fake_gitea):
@@ -560,12 +535,10 @@ def test_claim_opens_meta_pr(app_with_fake_gitea):
d = r.json()
assert d["branch_name"] == "claim/ohm"
# The PR body's diff carries Alice in owners.
text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"]
ent = entry_mod.parse(text)
assert "alice" in ent.owners
# cached_prs records pr_kind='meta_claim' via refresh_meta_pulls.
row = db.conn().execute(
"SELECT pr_kind FROM cached_prs WHERE pr_number = ?", (d["pr_number"],),
).fetchone()
+9 -10
View File
@@ -339,10 +339,10 @@ def test_hygiene_action_kinds_fire_no_notifications(app_with_fake_gitea):
def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea):
"""§19.2 candidate Slice 8 settles: when graduation rolls back
after step 3 (open_pr), the `graduate-<slug>-<6hex>` branch is
deleted alongside the PR close so failed-graduation branches
don't accumulate on the meta repo across retries."""
"""Meta-only (§13.3): when the flip's merge fails after the PR is
open, the orchestrator closes the PR and deletes its
`graduate-<slug>-<6hex>` branch so failed attempts don't accumulate
on the meta repo across retries."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
@@ -359,24 +359,23 @@ def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea):
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Force a step-4 (merge_pr) failure so step 3 (open_pr) has
# already landed and the rollback exercises the branch cleanup.
# Force a merge_pr failure so the flip PR (open_pr) has already
# landed and the cleanup exercises the branch deletion.
orig_merge = Bot.merge_graduation_pr
async def boom(self, *args, **kwargs):
raise GiteaError(502, "simulated merge failure for rollback test")
raise GiteaError(502, "simulated merge failure for cleanup test")
Bot.merge_graduation_pr = boom
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0099", "owners": ["ben"]},
)
finally:
Bot.merge_graduation_pr = orig_merge
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is False
# The dash-suffixed graduation branch was deleted on rollback.
# The dash-suffixed graduation branch was deleted on cleanup.
meta_branches = fake.branches[("wiggleverse", "meta")]
graduation_branches = [n for n in meta_branches if n.startswith("graduate-ohm-")]
assert graduation_branches == [], (