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:
+59
-37
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'
|
||||
"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 == [], (
|
||||
|
||||
Reference in New Issue
Block a user