Slice 5: graduation per §13

The §13.3 transactional sequence flips a super-draft to active —
five steps with paired undoes, an in-process orchestrator fed by
an asyncio.Queue, the §17 SSE endpoint streaming step transitions
to the dialog. Each step is a new bot primitive that logs an
`actions` row, bracketed by `graduate_start` / `graduate_complete`
for the linkable audit sequence. Rollback runs the undoes in
reverse from the last completed step; merge_pr has no undo by
design per §13.5.

The §9.8 precondition gate is enforced server-side at the top of
POST /graduate so the §13.3 rollback complexity does not grow.
The §13.4 chat migration is a database semantic no-op — the
(slug, branch_name='main') threads keep their identity, only the
interpretation changes. The §9.8 pre-graduation history surfaces
via a new _is_meta_target(rfc, branch) dispatch helper and lands
as pre_graduation_history on /main.

§13.1 claim flow landed alongside since it's the prerequisite for
non-admin graduation — bot.open_claim_pr plus broadening
api_prs._require_pr to accept meta_claim.

45/45 tests green; ten new integration tests cover the validator,
the §9.8 precondition refusal, happy path with audit verification,
mid-sequence rollback at steps 2 and 3, concurrent refusal,
chat-survives-without-data-movement, pre-graduation history, and
the §13.1 claim PR cycle.

SPEC.md §19.1 rewritten for Slice 6 (notifications); §19.2 grew
four candidates surfaced during the slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 21:52:29 -07:00
parent 4565a6cb95
commit 1b0968a9a2
14 changed files with 2872 additions and 172 deletions
+3 -1
View File
@@ -17,7 +17,7 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import api_branches, api_prs, auth, db, entry as entry_mod, cache
from . import api_branches, api_graduation, api_prs, auth, db, entry as entry_mod, cache
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
@@ -53,6 +53,8 @@ def make_router(
router.include_router(api_branches.make_router(config, gitea, bot, providers))
# Slice 3: the §10 PR-flow endpoints.
router.include_router(api_prs.make_router(config, gitea, bot, providers))
# Slice 5: §13 graduation + §13.1 claim.
router.include_router(api_graduation.make_router(config, gitea, bot))
# ---------------------------------------------------------------
# Auth surface — extends the prototype's pattern but reads role
+97 -28
View File
@@ -204,6 +204,46 @@ def make_router(
# 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.
pre_grad: list[dict[str, Any]] = []
if rfc["state"] == "active":
pre_grad_rows = db.conn().execute(
"""
SELECT t.branch_name,
COUNT(DISTINCT t.id) AS thread_count,
COUNT(DISTINCT m.id) AS message_count,
MAX(m.created_at) AS last_activity_at
FROM threads t
LEFT JOIN thread_messages m ON m.thread_id = t.id
WHERE t.rfc_slug = ?
AND (
t.branch_name LIKE 'edit-' || ? || '-%'
OR t.branch_name LIKE 'edit/' || ? || '/%'
)
GROUP BY t.branch_name
ORDER BY MAX(m.created_at) DESC NULLS LAST, t.branch_name
""",
(slug, slug, slug),
).fetchall()
for r in pre_grad_rows:
change_count = db.conn().execute(
"SELECT COUNT(*) AS n FROM changes WHERE rfc_slug = ? AND branch_name = ?",
(slug, r["branch_name"]),
).fetchone()["n"]
pre_grad.append({
"branch_name": r["branch_name"],
"thread_count": r["thread_count"],
"message_count": r["message_count"],
"change_count": change_count,
"last_activity_at": r["last_activity_at"],
})
return {
"slug": slug,
"title": rfc["title"],
@@ -215,6 +255,7 @@ def make_router(
"body_sha": rfc["body_sha"],
"branches": branches,
"open_prs": prs,
"pre_graduation_history": pre_grad,
}
# -------------------------------------------------------------------
@@ -232,8 +273,8 @@ def make_router(
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
owner, repo = _repo_for(rfc)
path = _file_path_for(rfc)
owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc, branch)
result = await gitea.read_file(owner, repo, path, ref=branch)
if result is None:
br = await gitea.get_branch(owner, repo, branch)
@@ -242,7 +283,7 @@ def make_router(
body, body_sha = "", ""
else:
content, body_sha = result
body = _extract_body(rfc, content)
body = _extract_body(rfc, content, branch)
# Ensure the whole-doc chat thread for the branch exists.
thread_id = _ensure_branch_chat_thread(slug, branch, viewer)
@@ -482,13 +523,13 @@ def make_router(
# Fetch current file and extract the editable body. For super-draft
# the file is rfcs/<slug>.md with frontmatter; for active it's RFC.md.
owner, repo = _repo_for(rfc)
path = _file_path_for(rfc)
owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc, branch)
fetched = await gitea.read_file(owner, repo, path, ref=branch)
if fetched is None:
raise HTTPException(409, f"Branch {path} not found")
prior_content, prior_sha = fetched
current_body = _extract_body(rfc, prior_content)
current_body = _extract_body(rfc, prior_content, branch)
original = row["original"]
occurrences = current_body.count(original)
@@ -508,7 +549,7 @@ def make_router(
else:
new_body = current_body.replace(original, body.proposed, 1)
new_file_contents = _wrap_body(rfc, prior_content, new_body)
new_file_contents = _wrap_body(rfc, prior_content, new_body, branch)
try:
sha = await bot.commit_accepted_change(
@@ -590,10 +631,10 @@ def make_router(
if not providers:
raise HTTPException(503, "No AI providers configured")
owner, repo = _repo_for(rfc)
path = _file_path_for(rfc)
owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc, branch)
fetched = await gitea.read_file(owner, repo, path, ref=branch)
body_text = _extract_body(rfc, fetched[0]) if fetched else ""
body_text = _extract_body(rfc, fetched[0], branch) if fetched else ""
provider = next(iter(providers.values()))
system = chat_layer.build_system_prompt(title=rfc["title"], body=body_text)
@@ -638,18 +679,18 @@ def make_router(
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
_require_can_contribute(slug, branch, viewer)
owner, repo = _repo_for(rfc)
path = _file_path_for(rfc)
owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc, branch)
fetched = await gitea.read_file(owner, repo, path, ref=branch)
if fetched is None:
raise HTTPException(409, f"Branch {path} not found")
prior_content, prior_sha = fetched
prior_body = _extract_body(rfc, prior_content)
prior_body = _extract_body(rfc, prior_content, branch)
if prior_body == body.new_content:
return {"ok": True, "noop": True}
new_file_contents = _wrap_body(rfc, prior_content, body.new_content)
new_file_contents = _wrap_body(rfc, prior_content, body.new_content, branch)
# Per §8.11: materialize the manual change as a `changes` row
# first so the resolved card binds 1:1 to the commit.
@@ -898,10 +939,10 @@ def make_router(
# Fetch the live branch body so the prompt is anchored to
# what's in Gitea right now, not the cache. For super-draft,
# extract just the body part from the entry envelope.
owner, repo = _repo_for(rfc)
path = _file_path_for(rfc)
owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc, branch)
fetched = await gitea.read_file(owner, repo, path, ref=branch)
body_text = _extract_body(rfc, fetched[0]) if fetched else ""
body_text = _extract_body(rfc, fetched[0], branch) if fetched else ""
prompt_text = body.text
if body.quote:
@@ -981,22 +1022,42 @@ def make_router(
def _is_super_draft(rfc) -> bool:
return rfc["state"] == "super-draft"
def _repo_for(rfc) -> tuple[str, str]:
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."""
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):
return True
return _is_meta_branch_name(branch)
def _repo_for(rfc, branch: str = "main") -> tuple[str, str]:
if _is_meta_target(rfc, branch):
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):
def _file_path_for(rfc, branch: str = "main") -> str:
if _is_meta_target(rfc, branch):
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
frontmatter+body envelope; the editable body is entry.body. For
active RFCs the file is just RFC.md and the whole thing is body."""
if not _is_super_draft(rfc):
def _extract_body(rfc, file_contents: str, branch: str = "main") -> str:
"""For super-draft entries (and active-RFC pre-graduation reads
per §9.8) the file on disk is the full frontmatter+body envelope;
the editable body is entry.body. For active RFCs reading their
per-RFC repo the file is just RFC.md and the whole thing is body."""
if not _is_meta_target(rfc, branch):
return file_contents
try:
entry = entry_mod.parse(file_contents)
@@ -1004,10 +1065,10 @@ def make_router(
return file_contents
return entry.body
def _wrap_body(rfc, prior_contents: str, new_body: str) -> str:
def _wrap_body(rfc, prior_contents: str, new_body: str, branch: str = "main") -> str:
"""Inverse of _extract_body: re-wrap a new body into the entry
envelope, preserving the prior frontmatter exactly."""
if not _is_super_draft(rfc):
if not _is_meta_target(rfc, branch):
return new_body
entry = entry_mod.parse(prior_contents)
# Ensure exactly one trailing newline so the serializer's
@@ -1125,6 +1186,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):
return False
if viewer.role in ("owner", "admin"):
return True
owners = json.loads(rfc["owners_json"] or "[]")
@@ -1150,7 +1218,8 @@ def make_router(
def _require_can_contribute(slug: str, branch: str, viewer) -> None:
rfc = db.conn().execute(
"SELECT owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?", (slug,)
"SELECT state, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?",
(slug,),
).fetchone()
if not _can_contribute(rfc, slug, branch, viewer):
raise HTTPException(403, "You do not have contribute access to this branch")
+940
View File
@@ -0,0 +1,940 @@
"""Slice 5 API surface — the §13 graduation flow's endpoints and the
in-process orchestrator that runs the §13.3 transactional sequence with
rollback.
Owns four routes per §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)
- GET /api/rfcs/<slug>/graduate/progress (§13.3 SSE step stream)
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim), which is
graduation's prerequisite for non-admins per §13.1.
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.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from dataclasses import dataclass, field
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from . import auth, cache, db, entry as entry_mod
from .bot import Actor, Bot
from .config import Config
from .gitea import Gitea, GiteaError
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Step machine
# ---------------------------------------------------------------------------
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",
"merge_pr": "Merge graduation PR",
"refresh_cache": "Refresh catalog and views",
}
@dataclass
class StepState:
key: str
label: str
status: str = "pending" # pending|running|done|failed|not-reached
detail: str = ""
@dataclass
class GraduationState:
slug: str
rfc_id: str
repo_name: str
repo_full: str
owners: list[str]
arbiters: list[str]
steps: list[StepState]
queue: asyncio.Queue = field(default_factory=asyncio.Queue)
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
def to_payload(self) -> dict:
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,
}
def _step_payload(s: StepState) -> dict:
return {"key": s.key, "label": s.label, "status": s.status, "detail": s.detail}
# Process-local registry. Single-process FastAPI per §4.2 means in-memory
# 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).
_active: dict[str, GraduationState] = {}
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,
owners: list[str], arbiters: list[str]) -> GraduationState:
state = GraduationState(
slug=slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full,
owners=owners, arbiters=arbiters,
steps=[StepState(key=k, label=STEP_LABELS[k]) for k in STEP_KEYS],
)
_active[slug] = state
return state
# ---------------------------------------------------------------------------
# Validation helpers
# ---------------------------------------------------------------------------
# §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))
def _suggest_next_rfc_id() -> str:
rows = db.conn().execute(
"SELECT rfc_id FROM cached_rfcs WHERE rfc_id LIKE 'RFC-%'"
).fetchall()
used: set[int] = 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"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 != ?",
(rfc_id, excluding_slug),
).fetchone()
return row is not None
# ---------------------------------------------------------------------------
# Request bodies
# ---------------------------------------------------------------------------
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)
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
def make_router(
config: Config,
gitea: Gitea,
bot: Bot,
) -> APIRouter:
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).
# -------------------------------------------------------------------
@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
FROM cached_prs
WHERE rfc_slug = ?
AND state = 'open'
AND pr_kind = 'meta_body_edit'
ORDER BY opened_at DESC
""",
(slug,),
).fetchall()
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")
items = []
for r in rows:
can_merge = (
viewer is not None
and (
viewer.role in ("owner", "admin")
or viewer.gitea_login in owners
or viewer.gitea_login in arbiters
)
)
can_withdraw = (
viewer is not None
and (
can_merge
or viewer.gitea_login == (r["opened_by"] or "")
)
)
items.append({
"pr_number": r["pr_number"],
"title": r["title"],
"author": r["opened_by"],
"last_activity_at": r["opened_at"],
"head_branch": r["head_branch"],
"actions": {
"merge": can_merge,
"withdraw": can_withdraw,
"open_in_new_tab": True,
},
})
return {"items": items}
# -------------------------------------------------------------------
# §13.2: GET /api/rfcs/<slug>/graduate/check?id=&repo=
# 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.
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/graduate/check")
async def graduate_check(
slug: str, request: Request,
) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_super_draft(slug)
del viewer # no permission gate — the dialog only shows up for
# 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}
if not candidate_id:
id_payload["ok"] = False
id_payload["error"] = "Integer ID is required"
elif not _is_valid_rfc_id(candidate_id):
id_payload["ok"] = False
id_payload["error"] = "ID must look like RFC-NNNN (at least four digits)"
elif _rfc_id_taken(candidate_id, excluding_slug=slug):
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,
"count": len(owners),
"current": owners,
"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"]
)
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
else {"finished": in_flight.finished, "succeeded": in_flight.succeeded}
),
}
# -------------------------------------------------------------------
# §13.3: POST /api/rfcs/<slug>/graduate
# Atomic re-validation, then kicks off the sequence as an async task.
# The client opens GET /graduate/progress on confirm to watch the SSE.
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/graduate")
async def graduate(slug: str, body: GraduateBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug)
# §13: only owners/arbiters of the RFC and app admins/owners may
# graduate. Until §13.1's claim runs the entry has no owners, so
# the set collapses to app admins/owners for unclaimed entries.
if not _can_graduate(rfc, viewer):
raise HTTPException(403, "Only RFC owners/arbiters or app admins/owners may graduate")
# Refuse if an in-flight graduation is still running for this slug.
existing = _get_active(slug)
if existing is not None and not existing.finished:
raise HTTPException(409, "Graduation already in progress for this slug")
# §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.
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")
if not _is_valid_rfc_id(rfc_id):
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.
fetched = await gitea.read_file(
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
)
if fetched is None:
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
meta_text, meta_sha = fetched
try:
super_draft_entry = entry_mod.parse(meta_text)
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).
graduated_entry = entry_mod.Entry(
slug=slug,
title=super_draft_entry.title,
state="active",
id=rfc_id,
repo=repo_full,
proposed_by=super_draft_entry.proposed_by,
proposed_at=super_draft_entry.proposed_at,
graduated_at=entry_mod.today(),
graduated_by=viewer.gitea_login,
owners=owners,
arbiters=arbiters,
tags=list(super_draft_entry.tags),
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,
)
# Audit: graduation started. The terminal `graduate_complete` /
# `graduate_rollback` 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,
},
)
# 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.
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,
)
if request.query_params.get("_sync") == "1":
await coro
else:
asyncio.create_task(coro)
return {
"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.
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/graduate/progress")
async def graduate_progress(slug: str, request: Request):
del 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())
return
while True:
evt = await state.queue.get()
if evt is None:
yield _sse_event("done", state.to_payload())
return
yield _sse_event(evt.get("event", "update"), evt.get("payload"))
headers = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
return StreamingResponse(event_stream(), media_type="text/event-stream", headers=headers)
# -------------------------------------------------------------------
# §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).
# -------------------------------------------------------------------
@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
WHERE rfc_slug = ? AND pr_kind = 'meta_claim' AND state = 'open'
""",
(slug,),
).fetchone()
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",
)
if fetched is None:
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
meta_text, meta_sha = fetched
try:
ent = entry_mod.parse(meta_text)
except Exception as e:
raise HTTPException(500, f"Meta entry malformed: {e}")
if viewer.gitea_login in ent.owners:
return {"ok": True, "noop": True}
ent.owners = ent.owners + [viewer.gitea_login]
new_contents = entry_mod.serialize(ent)
try:
pr = await bot.open_claim_pr(
viewer.as_actor(),
org=config.gitea_org, meta_repo=config.meta_repo,
slug=slug,
new_file_contents=new_contents, prior_sha=meta_sha,
)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
await cache.refresh_meta_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
return {"pr_number": pr["number"], "slug": slug, "branch_name": pr["head"]["ref"]}
# -------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------
def _require_super_draft(slug: str):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
if row["state"] != "super-draft":
raise HTTPException(409, f"RFC is {row['state']}, not super-draft")
return row
return router
# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------
async def _orchestrate(
*,
config: Config,
gitea: Gitea,
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:
- 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
On failure at step N, every later step is marked `not-reached` and
`_rollback` runs undoes in reverse from N-1 to 1.
"""
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 -----
await _start(state, "open_pr", "Opening graduation PR…")
try:
pr = await bot.open_graduation_pr(
actor,
org=config.gitea_org, meta_repo=config.meta_repo,
slug=state.slug,
new_file_contents=graduated_contents,
prior_sha=meta_file_sha,
rfc_id=state.rfc_id, repo_full=state.repo_full,
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")
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 -----
await _start(state, "merge_pr", f"Merging PR #{state.new_pr_number}")
try:
await bot.merge_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, rfc_id=state.rfc_id,
)
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")
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…")
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")
# 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,
"owners": state.owners, "pr_number": state.new_pr_number,
},
)
state.succeeded = True
state.finished = True
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",
)
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,
) -> 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:
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",
)
return f"Closed PR #{state.new_pr_number}"
# 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,
}
# ---------------------------------------------------------------------------
# Permission + audit helpers
# ---------------------------------------------------------------------------
def _can_graduate(rfc, viewer) -> bool:
if viewer is None:
return False
if viewer.role in ("owner", "admin"):
return True
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")
return viewer.gitea_login in owners or viewer.gitea_login in arbiters
def _audit(
actor_user_id: int | None,
on_behalf_of: str,
action_kind: str,
*,
rfc_slug: str | None = None,
branch_name: str | None = None,
pr_number: int | None = None,
details: dict | None = None,
) -> None:
"""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."""
db.conn().execute(
"""
INSERT INTO actions
(actor_user_id, on_behalf_of, action_kind, rfc_slug, branch_name, pr_number, bot_commit_sha, details)
VALUES (?, ?, ?, ?, ?, ?, NULL, ?)
""",
(
actor_user_id,
on_behalf_of,
action_kind,
rfc_slug,
branch_name,
pr_number,
json.dumps(details) if details else None,
),
)
# ---------------------------------------------------------------------------
# Step state transitions
# ---------------------------------------------------------------------------
async def _start(state: GraduationState, key: str, detail: str) -> None:
step = next(s for s in state.steps if s.key == key)
step.status = "running"
step.detail = detail
await state.queue.put({"event": "step", "payload": state.to_payload()})
async def _done(state: GraduationState, key: str, detail: str) -> None:
step = next(s for s in state.steps if s.key == key)
step.status = "done"
step.detail = detail
await state.queue.put({"event": "step", "payload": state.to_payload()})
async def _fail(state: GraduationState, key: str, detail: str) -> None:
step = next(s for s in state.steps if s.key == key)
step.status = "failed"
step.detail = detail
state.error = detail
await state.queue.put({"event": "step", "payload": state.to_payload()})
def _sse_event(name: str, payload: Any) -> str:
return f"event: {name}\ndata: {json.dumps(payload)}\n\n"
+5 -1
View File
@@ -650,11 +650,15 @@ def make_router(
# meta repo as pr_kind='meta_body_edit'; active RFC PRs live on
# the per-RFC repo as 'rfc_branch'. The API surface and the §10
# treatment are identical.
# Slice 5: §13.1 claim PRs (pr_kind='meta_claim') are also
# exposed through this surface — the merge path is the only
# affordance an admin needs, and the §10 review machinery
# gracefully degrades for frontmatter-only PRs.
row = db.conn().execute(
"""
SELECT * FROM cached_prs
WHERE rfc_slug = ? AND pr_number = ?
AND pr_kind IN ('rfc_branch', 'meta_body_edit')
AND pr_kind IN ('rfc_branch', 'meta_body_edit', 'meta_claim')
""",
(slug, pr_number),
).fetchone()
+308
View File
@@ -627,6 +627,314 @@ 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
async def open_graduation_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
slug: str,
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.
"""
import secrets
branch = f"graduate-{slug}-{secrets.token_hex(3)}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
ae = actor.email or f"{actor.gitea_login}@users.noreply"
commit_subject = f"Graduate {slug}{rfc_id}"
commit_message = _stamp_single(commit_subject, actor)
result = await self._gitea.update_file(
org, meta_repo, f"rfcs/{slug}.md",
content=new_file_contents,
sha=prior_sha,
message=commit_message,
branch=branch,
author_name=actor.display_name, author_email=ae,
)
commit_sha = (
result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha")
or ""
)
pr_title = f"Graduate {slug}{rfc_id}"
owners_str = ", ".join(owners) if owners else "(none)"
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."
)
_subject, pr_body = _stamp("", pr_body_text, actor)
pr = await self._gitea.create_pull(
org, meta_repo,
title=pr_title, body=pr_body, head=branch, base="main",
)
_log(
actor,
"graduate_pr_open",
rfc_slug=slug,
branch_name=branch,
pr_number=pr["number"],
bot_commit_sha=commit_sha,
details={"pr_title": pr_title, "rfc_id": rfc_id, "repo": repo_full},
)
return pr
async def merge_graduation_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
pr_number: int,
head_branch: str,
slug: str,
rfc_id: str,
) -> None:
"""§13.3 step 4: auto-merge the graduation PR with the admin as
merge actor. Distinct action_kind so the audit log carries the
graduation as a linkable sequence per §13.3's transactional shape."""
subject = f"Graduate {slug}{rfc_id}"
body = _trailer(actor)
await self._gitea.merge_pull(
org, meta_repo, pr_number,
merge_message_title=subject,
merge_message_body=body,
style="merge",
)
_log(
actor,
"graduate_pr_merge",
rfc_slug=slug,
branch_name=head_branch,
pr_number=pr_number,
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},
)
async def close_graduation_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
pr_number: int,
head_branch: str,
slug: str,
reason: str,
) -> None:
"""Undo of `open_graduation_pr`. Closes the PR without merging; the
branch is left in place to dodge the case where another graduation
attempt runs immediately it'll get its own `graduate-<slug>-<hex>`
suffix."""
await self._gitea.close_pull(org, meta_repo, pr_number)
_log(
actor,
"graduate_pr_close",
rfc_slug=slug,
branch_name=head_branch,
pr_number=pr_number,
details={"reason": reason},
)
# ----- §13.1 claim PRs -----
async def open_claim_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
slug: str,
new_file_contents: str,
prior_sha: str,
) -> dict:
"""§13.1: open a PR adding the actor to the entry's `owners:` list.
Touches only the frontmatter of `rfcs/<slug>.md`. Branch shape is
`claim/<slug>` single attempt per super-draft per actor (Gitea
refuses duplicate branch creation, which is the right behavior:
if the claim is still open, point the contributor at the existing
PR rather than opening a second one).
"""
branch = f"claim/{slug}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
ae = actor.email or f"{actor.gitea_login}@users.noreply"
commit_subject = f"Claim ownership of {slug} for {actor.gitea_login}"
commit_message = _stamp_single(commit_subject, actor)
result = await self._gitea.update_file(
org, meta_repo, f"rfcs/{slug}.md",
content=new_file_contents,
sha=prior_sha,
message=commit_message,
branch=branch,
author_name=actor.display_name, author_email=ae,
)
commit_sha = (
result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha")
or ""
)
pr_title = f"Claim ownership: {slug}"
pr_description = (
f"`{actor.gitea_login}` claims ownership of super-draft `{slug}`.\n\n"
f"Per §13.1, owners and admins can merge."
)
_subject, pr_body = _stamp("", pr_description, actor)
pr = await self._gitea.create_pull(
org, meta_repo,
title=pr_title, body=pr_body, head=branch, base="main",
)
_log(
actor,
"open_claim_pr",
rfc_slug=slug,
branch_name=branch,
pr_number=pr["number"],
bot_commit_sha=commit_sha,
details={"new_owner": actor.gitea_login},
)
return pr
# ----- Per-RFC repo: seeding (test/dev fixtures, future graduation) -----
async def ensure_rfc_repo_seed(
+566
View File
@@ -0,0 +1,566 @@
"""End-to-end integration tests for the Slice 5 vertical (§13 in full).
Walks the §13.3 transactional sequence end-to-end 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).
* 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 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`.
"""
from __future__ import annotations
import json as _json
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_super_draft_vertical import seed_super_draft # noqa: F401
PITCH = (
"Open Human Model is a framework for representing humans.\n\n"
"It defines consent, trait, and agency in compatible terms."
)
def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str,
owners: list[str], arbiters: list[str] | None = None,
proposed_by: str = "alice", tags: list[str] | None = None) -> None:
"""Seed a super-draft directly with owners already filled in — the
§13.1 claim flow is exercised separately."""
import yaml
from app import db
fm = {
"slug": slug,
"title": title,
"state": "super-draft",
"id": None,
"repo": None,
"proposed_by": proposed_by,
"proposed_at": "2026-05-23",
"graduated_at": None,
"graduated_by": None,
"owners": owners,
"arbiters": arbiters or owners[:1],
"tags": tags or [],
}
body = pitch.strip() + "\n"
entry_text = f"---\n{yaml.safe_dump(fm, sort_keys=False).rstrip()}\n---\n\n{body}"
sha = fake._next_sha()
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {
"content": entry_text, "sha": sha,
}
fake.branches[("wiggleverse", "meta")]["main"]["sha"] = sha
db.conn().execute(
"""
INSERT OR REPLACE INTO cached_rfcs
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
owners_json, arbiters_json, tags_json,
body, body_sha, last_main_commit_at, last_entry_commit_at)
VALUES (?, ?, 'super-draft', NULL, NULL, ?, '2026-05-23',
?, ?, ?, ?, ?, datetime('now'), datetime('now'))
""",
(
slug, title, proposed_by,
_json.dumps(owners),
_json.dumps(arbiters or owners[:1]),
_json.dumps(tags or []),
body, sha,
),
)
db.conn().execute(
"""
INSERT OR REPLACE INTO cached_branches
(rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, 'main', ?, 'open', datetime('now'))
""",
(slug, sha),
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_graduate_check_validates_three_fields(app_with_fake_gitea):
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")
seed_owned_super_draft(fake, slug="ohm", title="Open Human Model",
pitch=PITCH, owners=["ben"])
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"})
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
# ID format error — non-numeric tail.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-abcd", "repo": "rfc-0001-ohm"})
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."""
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"})
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."""
from fastapi.testclient import TestClient
from app import db, entry as entry_mod
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="Open Human Model",
pitch=PITCH, owners=["ben"], arbiters=["ben"],
tags=["identity", "schema"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0042", "repo_name": "rfc-0042-ohm",
"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"
# 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_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.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.
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 "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(
"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",
"graduate_pr_merge", "graduate_complete"):
assert needed in kinds, f"missing audit row {needed}: {kinds}"
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."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
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).
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"]
cur = db.conn().execute(
"""
INSERT INTO changes
(rfc_slug, branch_name, thread_id, kind, state, original, proposed, reason)
VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, 'tighten')
""",
(branch, thread_id,
"It defines consent, trait, and agency in compatible terms.",
"It defines consent, trait, harm, and agency in compatible terms."),
)
change_id = cur.lastrowid
client.post(
f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
json={"proposed": "It defines consent, trait, harm, and agency in compatible terms.",
"was_edited_before_accept": False},
)
pr_number = client.post(
f"/api/rfcs/ohm/branches/{branch}/open-pr",
json={"title": "Add harm", "description": "Adds harm dimension."},
).json()["pr_number"]
# /blocking-prs surfaces it.
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
# /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.
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
"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.
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
# 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
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."""
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_open_pr = Bot.open_graduation_pr
async def boom(self, *args, **kwargs):
raise GiteaError(502, "simulated PR-open failure")
Bot.open_graduation_pr = boom
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0007", "repo_name": "rfc-0007-ohm",
"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).
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
assert "Open Human Model is a framework" in meta_text
def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
"""A second graduation request for a slug already in-flight is refused."""
from fastapi.testclient import TestClient
from app import api_graduation
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")
# 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"],
)
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"]},
)
assert r.status_code == 409
finally:
api_graduation._active.pop("ohm", None)
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."""
from fastapi.testclient import TestClient
from app import db
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")
# 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)
VALUES ('ohm', 'main', 'whole-doc', 'chat', 1)
"""
)
thread_id = cur.lastrowid
db.conn().execute(
"""
INSERT INTO thread_messages (thread_id, role, author_user_id, text)
VALUES (?, 'user', 1, 'pre-grad note on the canonical body')
""",
(thread_id,),
)
# Graduate.
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
"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`."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
# 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"]
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
thread_id = view["main_thread_id"]
db.conn().execute(
"""
INSERT INTO thread_messages (thread_id, role, author_user_id, text)
VALUES (?, 'user', 2, 'pre-graduation 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"]},
)
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()
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
def test_claim_opens_meta_pr(app_with_fake_gitea):
"""§13.1: any signed-in contributor can claim ownership of an
unclaimed super-draft; the result is a meta-repo PR
(`pr_kind='meta_claim'`) adding their gitea_login to the entry's
owners list."""
from fastapi.testclient import TestClient
from app import db, entry as entry_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=[])
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post("/api/rfcs/ohm/claim")
assert r.status_code == 200, r.text
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()
assert row["pr_kind"] == "meta_claim"
+12
View File
@@ -128,6 +128,18 @@ class FakeGitea:
return httpx.Response(200, json={"name": repo, "full_name": f"{owner}/{repo}"})
return httpx.Response(404, json={"message": "not found"})
# DELETE /repos/{owner}/{repo} — Slice 5 graduation rollback uses
# this to undo step 1 (repo create). The FakeGitea drops every
# file, branch, and PR tied to the repo so a subsequent retry
# graduation can re-create the repo cleanly.
if method == "DELETE" and m_repo:
owner, repo = m_repo.groups()
self.repos.discard((owner, repo))
self.branches.pop((owner, repo), None)
self.pulls.pop((owner, repo), None)
self.files = {k: v for k, v in self.files.items() if (k[0], k[1]) != (owner, repo)}
return httpx.Response(204, json={})
# POST /orgs/{org}/repos
m = re.fullmatch(r"/orgs/([^/]+)/repos", path)
if method == "POST" and m: