"""Slice 5 API surface — the §13 graduation flow's endpoints and the in-process orchestrator that runs the §13.3 transactional sequence with rollback. Owns four routes per §17: - GET /api/rfcs//blocking-prs (§13.2 precondition popover) - GET /api/rfcs//graduate/check (§13.2 debounced validator) - POST /api/rfcs//graduate (§13.3 kickoff) - GET /api/rfcs//graduate/progress (§13.3 SSE step stream) Plus the §13.1 claim PR endpoint (POST /api/rfcs//claim), which is graduation's prerequisite for non-admins per §13.1. The orchestrator runs in-process — each in-flight graduation lives in a small `GraduationState` keyed by slug, with an asyncio.Queue feeding the SSE handler. Per the §13.3 transactional contract, every forward step is paired with an undo; rollback runs the undos in reverse order from the last step that completed. §13.4's chat migration is a database semantic no-op (the threads' `(rfc_slug, branch_name='main')` rows are interpreted as super-draft canonical-body before graduation and as new-RFC main afterwards — same shape, different meaning), so the only DB work the sequence does is the audit-log rows the bot's `_log` writes per step. """ from __future__ import annotations import asyncio import json import logging import re from dataclasses import dataclass, field from typing import Any from fastapi import APIRouter, HTTPException, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from . import auth, cache, db, entry as entry_mod from .bot import Actor, Bot from .config import Config from .gitea import Gitea, GiteaError log = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Step machine # --------------------------------------------------------------------------- STEP_KEYS = ( "create_repo", "seed_files", "open_pr", "merge_pr", "refresh_cache", ) STEP_LABELS = { "create_repo": "Create per-RFC repository", "seed_files": "Seed RFC.md, README.md, and .rfc/metadata.yaml", "open_pr": "Open meta-repo graduation PR", "merge_pr": "Merge graduation PR", "refresh_cache": "Refresh catalog and views", } @dataclass class StepState: key: str label: str status: str = "pending" # pending|running|done|failed|not-reached detail: str = "" @dataclass class GraduationState: slug: str rfc_id: str repo_name: str repo_full: str owners: list[str] arbiters: list[str] steps: list[StepState] queue: asyncio.Queue = field(default_factory=asyncio.Queue) finished: bool = False succeeded: bool = False error: str | None = None rollback_started: bool = False rollback_steps: list[StepState] = field(default_factory=list) new_pr_number: int | None = None graduation_branch: str | None = None def to_payload(self) -> dict: return { "slug": self.slug, "rfc_id": self.rfc_id, "repo_full": self.repo_full, "steps": [_step_payload(s) for s in self.steps], "rollback_steps": [_step_payload(s) for s in self.rollback_steps], "finished": self.finished, "succeeded": self.succeeded, "rolled_back": self.rollback_started, "error": self.error, "pr_number": self.new_pr_number, } def _step_payload(s: StepState) -> dict: return {"key": s.key, "label": s.label, "status": s.status, "detail": s.detail} # Process-local registry. Single-process FastAPI per §4.2 means in-memory # is fine; the registry is keyed by slug to refuse concurrent graduations # of the same entry (the §13.2 atomic re-check is a separate defense # against a concurrent attempt of a DIFFERENT slug claiming the same # integer ID or repo name). _active: dict[str, GraduationState] = {} def _get_active(slug: str) -> GraduationState | None: return _active.get(slug) def _new_active(slug: str, *, rfc_id: str, repo_name: str, repo_full: str, owners: list[str], arbiters: list[str]) -> GraduationState: state = GraduationState( slug=slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full, owners=owners, arbiters=arbiters, steps=[StepState(key=k, label=STEP_LABELS[k]) for k in STEP_KEYS], ) _active[slug] = state return state # --------------------------------------------------------------------------- # Validation helpers # --------------------------------------------------------------------------- # §13.2: Gitea repo name pattern. Gitea accepts alphanumerics, dashes, # dots, and underscores; cannot start with a dot. 100-char cap as a sane # upper bound — the spec doesn't pin a max but Gitea's enforcement does. _REPO_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$") _RFC_ID_RE = re.compile(r"^RFC-\d{4,}$") def _is_valid_repo_name(name: str) -> bool: return bool(_REPO_NAME_RE.match(name)) and ".." not in name def _is_valid_rfc_id(rfc_id: str) -> bool: return bool(_RFC_ID_RE.match(rfc_id)) def _suggest_next_rfc_id() -> str: rows = db.conn().execute( "SELECT rfc_id FROM cached_rfcs WHERE rfc_id LIKE 'RFC-%'" ).fetchall() used: set[int] = set() for r in rows: try: used.add(int(r["rfc_id"].split("-", 1)[1])) except (IndexError, ValueError): continue nxt = (max(used) + 1) if used else 1 return f"RFC-{nxt:04d}" def _suggest_repo_name(slug: str, rfc_id: str) -> str: # rfc-NNNN- per §13.2's default. Strip the 'RFC-' prefix and # lowercase the number-pad. num = rfc_id.split("-", 1)[1] if "-" in rfc_id else "0001" return f"rfc-{num}-{slug}" def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool: row = db.conn().execute( "SELECT slug FROM cached_rfcs WHERE rfc_id = ? AND slug != ?", (rfc_id, excluding_slug), ).fetchone() return row is not None # --------------------------------------------------------------------------- # Request bodies # --------------------------------------------------------------------------- class GraduateBody(BaseModel): rfc_id: str = Field(min_length=5, max_length=40) repo_name: str = Field(min_length=1, max_length=100) owners: list[str] = Field(min_length=1) # --------------------------------------------------------------------------- # Router # --------------------------------------------------------------------------- def make_router( config: Config, gitea: Gitea, bot: Bot, ) -> APIRouter: router = APIRouter() # ------------------------------------------------------------------- # §13.2: GET /api/rfcs//blocking-prs # Lists open meta-repo PRs against rfcs/.md per the precondition # popover. Returns PR number, title, author, last-activity timestamp, # and the viewer's available actions (merge, withdraw, open-in-new-tab). # ------------------------------------------------------------------- @router.get("/api/rfcs/{slug}/blocking-prs") async def list_blocking_prs(slug: str, request: Request) -> dict[str, Any]: viewer = auth.current_user(request) rfc = _require_super_draft(slug) # §13's opening paragraph: only body-edit PRs block graduation. # Bare edit branches without an open PR do not block. The query # filters cached_prs to open meta_body_edit kinds for this slug. rows = db.conn().execute( """ SELECT pr_number, title, opened_by, opened_at, head_branch, pr_kind FROM cached_prs WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit' ORDER BY opened_at DESC """, (slug,), ).fetchall() owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") items = [] for r in rows: can_merge = ( viewer is not None and ( viewer.role in ("owner", "admin") or viewer.gitea_login in owners or viewer.gitea_login in arbiters ) ) can_withdraw = ( viewer is not None and ( can_merge or viewer.gitea_login == (r["opened_by"] or "") ) ) items.append({ "pr_number": r["pr_number"], "title": r["title"], "author": r["opened_by"], "last_activity_at": r["opened_at"], "head_branch": r["head_branch"], "actions": { "merge": can_merge, "withdraw": can_withdraw, "open_in_new_tab": True, }, }) return {"items": items} # ------------------------------------------------------------------- # §13.2: GET /api/rfcs//graduate/check?id=&repo= # Inline validation for the Graduate dialog — debounced from the # client; the dialog calls this as the admin types. Returns per-field # collision/validity from the catalog cache plus a server-authoritative # repo-name collision check. # ------------------------------------------------------------------- @router.get("/api/rfcs/{slug}/graduate/check") async def graduate_check( slug: str, request: Request, ) -> dict[str, Any]: viewer = auth.current_user(request) rfc = _require_super_draft(slug) del viewer # no permission gate — the dialog only shows up for # admins/owners, but the check itself is read-only. candidate_id = (request.query_params.get("id") or "").strip() candidate_repo = (request.query_params.get("repo") or "").strip() owners = json.loads(rfc["owners_json"] or "[]") blocking_count = db.conn().execute( """ SELECT COUNT(*) AS n FROM cached_prs WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit' """, (slug,), ).fetchone()["n"] # ID field id_payload: dict[str, Any] = {"value": candidate_id, "ok": True, "error": None} if not candidate_id: id_payload["ok"] = False id_payload["error"] = "Integer ID is required" elif not _is_valid_rfc_id(candidate_id): id_payload["ok"] = False id_payload["error"] = "ID must look like RFC-NNNN (at least four digits)" elif _rfc_id_taken(candidate_id, excluding_slug=slug): id_payload["ok"] = False id_payload["error"] = f"Integer ID {candidate_id} is already taken" # Repo field — validate pattern then probe Gitea for an existing # repo of that name under our org. The repo lookup is a single GET # so it's cheap to call on every keystroke (debounced from the # client per §13.2). repo_payload: dict[str, Any] = {"value": candidate_repo, "ok": True, "error": None} if not candidate_repo: repo_payload["ok"] = False repo_payload["error"] = "Repo name is required" elif not _is_valid_repo_name(candidate_repo): repo_payload["ok"] = False repo_payload["error"] = ( "Repo name must be alphanumerics, dashes, dots, or underscores " "(start with alphanumeric)" ) else: try: existing = await gitea.get_repo(config.gitea_org, candidate_repo) except GiteaError as e: # Network/auth flake — surface as a non-fatal hint; the # atomic server-side check at POST time is the authority. existing = None log.warning("graduate_check: Gitea get_repo error: %s", e) if existing is not None: repo_payload["ok"] = False repo_payload["error"] = ( f"Repo `{config.gitea_org}/{candidate_repo}` already exists" ) # Owners precondition — §13's opening paragraph. owners_payload: dict[str, Any] = { "ok": len(owners) > 0, "count": len(owners), "current": owners, "error": None if len(owners) > 0 else "No owners claimed yet", } # Blocking PR precondition — §9.8 / §13's opening paragraph. prs_payload: dict[str, Any] = { "ok": blocking_count == 0, "count": blocking_count, "error": ( None if blocking_count == 0 else f"{blocking_count} open body-edit PR{'' if blocking_count == 1 else 's'} blocking graduation" ), } in_flight = _get_active(slug) any_invalid = not ( id_payload["ok"] and repo_payload["ok"] and owners_payload["ok"] and prs_payload["ok"] ) return { "slug": slug, "id": id_payload, "repo": repo_payload, "owners": owners_payload, "blocking_prs": prs_payload, "can_submit": (not any_invalid) and (in_flight is None or in_flight.finished), "in_flight": ( None if in_flight is None else {"finished": in_flight.finished, "succeeded": in_flight.succeeded} ), } # ------------------------------------------------------------------- # §13.3: POST /api/rfcs//graduate # Atomic re-validation, then kicks off the sequence as an async task. # The client opens GET /graduate/progress on confirm to watch the SSE. # ------------------------------------------------------------------- @router.post("/api/rfcs/{slug}/graduate") async def graduate(slug: str, body: GraduateBody, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) rfc = _require_super_draft(slug) # §13: only owners/arbiters of the RFC and app admins/owners may # graduate. Until §13.1's claim runs the entry has no owners, so # the set collapses to app admins/owners for unclaimed entries. if not _can_graduate(rfc, viewer): raise HTTPException(403, "Only RFC owners/arbiters or app admins/owners may graduate") # Refuse if an in-flight graduation is still running for this slug. existing = _get_active(slug) if existing is not None and not existing.finished: raise HTTPException(409, "Graduation already in progress for this slug") # §13.2 atomic re-validation. The dialog's debounced check runs # client-side as the admin types; this is the authoritative check # that closes the dialog-open-to-confirm race. rfc_id = body.rfc_id.strip() repo_name = body.repo_name.strip() owners = [o.strip() for o in body.owners if o.strip()] if not owners: raise HTTPException(422, "Add at least one initial owner") if not _is_valid_rfc_id(rfc_id): raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)") if _rfc_id_taken(rfc_id, excluding_slug=slug): raise HTTPException(409, f"Integer ID {rfc_id} is already taken") if not _is_valid_repo_name(repo_name): raise HTTPException(422, "Repo name must be alphanumerics, dashes, dots, or underscores") try: existing_repo = await gitea.get_repo(config.gitea_org, repo_name) except GiteaError as e: raise HTTPException(502, f"Gitea: {e.detail}") if existing_repo is not None: raise HTTPException(409, f"Repo `{config.gitea_org}/{repo_name}` already exists") # §9.8 precondition gate — enforced before the bot starts the # sequence so the §13.3 rollback complexity does not grow. An # open body-edit PR against rfcs/.md would attempt to # re-introduce a body to a frontmatter-only entry after step 3. blocking = db.conn().execute( """ SELECT COUNT(*) AS n FROM cached_prs WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit' """, (slug,), ).fetchone()["n"] if blocking > 0: raise HTTPException( 409, f"{blocking} open body-edit PR{'' if blocking == 1 else 's'} block graduation", ) # Read the meta-repo entry once — we need the file's sha for the # graduation PR's update_file call and the original body so the # bot can seed RFC.md on the new repo with the migrated body. fetched = await gitea.read_file( config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main", ) if fetched is None: raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main") meta_text, meta_sha = fetched try: super_draft_entry = entry_mod.parse(meta_text) except Exception as e: raise HTTPException(500, f"Meta entry malformed: {e}") repo_full = f"{config.gitea_org}/{repo_name}" arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1] # Compose the graduated frontmatter — body stripped, graduation # fields filled. The serializer is run now so the PR-open step # has the contents pre-rendered (single source of truth for the # body migration vs. the meta-entry update). graduated_entry = entry_mod.Entry( slug=slug, title=super_draft_entry.title, state="active", id=rfc_id, repo=repo_full, proposed_by=super_draft_entry.proposed_by, proposed_at=super_draft_entry.proposed_at, graduated_at=entry_mod.today(), graduated_by=viewer.gitea_login, owners=owners, arbiters=arbiters, tags=list(super_draft_entry.tags), body="", ) graduated_contents = entry_mod.serialize(graduated_entry) state = _new_active( slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full, owners=owners, arbiters=arbiters, ) # Audit: graduation started. The terminal `graduate_complete` / # `graduate_rollback` rows below close the linkable sequence. _audit( viewer.user_id, viewer.gitea_login, "graduate_start", rfc_slug=slug, details={ "rfc_id": rfc_id, "repo": repo_full, "owners": owners, "blocking_prs": blocking, }, ) # Test seam: `?_sync=1` awaits the orchestrator inline so # integration tests can assert post-conditions without driving # the SSE. Production clients use the spec-described shape — # POST returns immediately, the client subscribes to the # progress SSE. coro = _orchestrate( config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(), state=state, super_draft_body=super_draft_entry.body, super_draft_title=super_draft_entry.title, super_draft_tags=list(super_draft_entry.tags), graduated_contents=graduated_contents, meta_file_sha=meta_sha, ) if request.query_params.get("_sync") == "1": await coro else: asyncio.create_task(coro) return { "ok": True, "slug": slug, "rfc_id": rfc_id, "repo": repo_full, "stream_url": f"/api/rfcs/{slug}/graduate/progress", "finished": state.finished, "succeeded": state.succeeded, } # ------------------------------------------------------------------- # §13.3: GET /api/rfcs//graduate/progress # SSE stream of the step transitions. One event per step transition # (pending → running → done / failed), plus the trailing rollback # step's events if any earlier step fails. # ------------------------------------------------------------------- @router.get("/api/rfcs/{slug}/graduate/progress") async def graduate_progress(slug: str, request: Request): del request state = _get_active(slug) if state is None: raise HTTPException(404, "No graduation in flight for this slug") async def event_stream(): # Emit the current snapshot first so a late subscriber sees # the steps already completed. yield _sse_event("snapshot", state.to_payload()) if state.finished: yield _sse_event("done", state.to_payload()) return while True: evt = await state.queue.get() if evt is None: yield _sse_event("done", state.to_payload()) return yield _sse_event(evt.get("event", "update"), evt.get("payload")) headers = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"} return StreamingResponse(event_stream(), media_type="text/event-stream", headers=headers) # ------------------------------------------------------------------- # §13.1: POST /api/rfcs//claim # Opens a meta-repo PR adding the actor's gitea_login to the entry's # owners list. Anyone signed in may claim — the merge is gated to # owners/admins per §13.1 (which collapses to admins for unclaimed # entries since `owners` is empty). # ------------------------------------------------------------------- @router.post("/api/rfcs/{slug}/claim") async def claim_ownership(slug: str, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) rfc = _require_super_draft(slug) # Refuse if the actor is already in owners — no-op claim. existing_owners = json.loads(rfc["owners_json"] or "[]") if viewer.gitea_login in existing_owners: return {"ok": True, "noop": True} # Refuse if a claim PR for this actor is already open. The branch # name `claim/` collides per actor implicitly since Gitea # refuses duplicate branch creation; we surface a clean 409 here # so the client doesn't see a 502. already = db.conn().execute( """ SELECT pr_number FROM cached_prs WHERE rfc_slug = ? AND pr_kind = 'meta_claim' AND state = 'open' """, (slug,), ).fetchone() if already: raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}") # Compose the new entry contents — owners list with the claimant # appended. fetched = await gitea.read_file( config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main", ) if fetched is None: raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main") meta_text, meta_sha = fetched try: ent = entry_mod.parse(meta_text) except Exception as e: raise HTTPException(500, f"Meta entry malformed: {e}") if viewer.gitea_login in ent.owners: return {"ok": True, "noop": True} ent.owners = ent.owners + [viewer.gitea_login] new_contents = entry_mod.serialize(ent) try: pr = await bot.open_claim_pr( viewer.as_actor(), org=config.gitea_org, meta_repo=config.meta_repo, slug=slug, new_file_contents=new_contents, prior_sha=meta_sha, ) except GiteaError as e: raise HTTPException(502, f"Gitea: {e.detail}") await cache.refresh_meta_branches(config, gitea) await cache.refresh_meta_pulls(config, gitea) return {"pr_number": pr["number"], "slug": slug, "branch_name": pr["head"]["ref"]} # ------------------------------------------------------------------- # Helpers # ------------------------------------------------------------------- def _require_super_draft(slug: str): row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone() if row is None: raise HTTPException(404, "RFC not found") if row["state"] != "super-draft": raise HTTPException(409, f"RFC is {row['state']}, not super-draft") return row return router # --------------------------------------------------------------------------- # Orchestrator # --------------------------------------------------------------------------- async def _orchestrate( *, config: Config, gitea: Gitea, bot: Bot, actor: Actor, state: GraduationState, super_draft_body: str, super_draft_title: str, super_draft_tags: list[str], graduated_contents: str, meta_file_sha: str, ) -> None: """Run §13.3 step by step. Each step: - marks itself `running` and pushes an event - calls the bot method (which writes to Gitea + audit log) - marks itself `done` (or `failed`) and pushes another event On failure at step N, every later step is marked `not-reached` and `_rollback` runs undoes in reverse from N-1 to 1. """ try: # ----- Step 1: create per-RFC repo ----- await _start(state, "create_repo", f"Creating `{state.repo_full}`…") try: await bot.create_rfc_repo_for_graduation( actor, org=config.gitea_org, repo_name=state.repo_name, slug=state.slug, title=super_draft_title, ) except GiteaError as e: await _fail(state, "create_repo", f"Gitea: {e.detail}") await _rollback(config=config, gitea=gitea, bot=bot, actor=actor, state=state, failed_at="create_repo") return await _done(state, "create_repo", state.repo_full) # ----- Step 2: seed RFC.md, README.md, .rfc/metadata.yaml ----- await _start(state, "seed_files", "Writing initial commit on main…") try: await bot.seed_graduated_rfc( actor, org=config.gitea_org, repo_name=state.repo_name, slug=state.slug, title=super_draft_title, rfc_body=super_draft_body, rfc_id=state.rfc_id, meta_full=config.meta_repo_full, meta_path=f"rfcs/{state.slug}.md", owners=state.owners, arbiters=state.arbiters, tags=super_draft_tags, ) except GiteaError as e: await _fail(state, "seed_files", f"Gitea: {e.detail}") await _rollback(config=config, gitea=gitea, bot=bot, actor=actor, state=state, failed_at="seed_files") return await _done(state, "seed_files", "RFC.md, README.md, .rfc/metadata.yaml") # ----- Step 3: open graduation PR ----- await _start(state, "open_pr", "Opening graduation PR…") try: pr = await bot.open_graduation_pr( actor, org=config.gitea_org, meta_repo=config.meta_repo, slug=state.slug, new_file_contents=graduated_contents, prior_sha=meta_file_sha, rfc_id=state.rfc_id, repo_full=state.repo_full, owners=state.owners, ) except GiteaError as e: await _fail(state, "open_pr", f"Gitea: {e.detail}") await _rollback(config=config, gitea=gitea, bot=bot, actor=actor, state=state, failed_at="open_pr") return state.new_pr_number = pr["number"] state.graduation_branch = pr["head"]["ref"] await _done(state, "open_pr", f"PR #{state.new_pr_number}") # ----- Step 4: merge the graduation PR ----- await _start(state, "merge_pr", f"Merging PR #{state.new_pr_number}…") try: await bot.merge_graduation_pr( actor, org=config.gitea_org, meta_repo=config.meta_repo, pr_number=state.new_pr_number, head_branch=state.graduation_branch or "", slug=state.slug, rfc_id=state.rfc_id, ) except GiteaError as e: await _fail(state, "merge_pr", f"Gitea: {e.detail}") await _rollback(config=config, gitea=gitea, bot=bot, actor=actor, state=state, failed_at="merge_pr") return await _done(state, "merge_pr", f"PR #{state.new_pr_number} merged") # ----- Step 5: refresh the cache so the catalog flips immediately. # Per §13.3 step 5 the webhook flow is the steady-state path, but # we refresh inline so the dialog can transition to "graduation # complete" with the catalog row already showing `active`. A # cache-refresh failure does not unwind Git state — the # reconciler will catch up per §4.1. await _start(state, "refresh_cache", "Refreshing catalog and views…") try: await cache.refresh_meta_repo(config, gitea) await cache.refresh_meta_branches(config, gitea) await cache.refresh_meta_pulls(config, gitea) await cache.refresh_rfc_repo(config, gitea, state.slug) except Exception as e: log.warning("graduate refresh_cache failed for %s: %s", state.slug, e) await _done(state, "refresh_cache", f"Cache will catch up via reconciler ({e})") else: await _done(state, "refresh_cache", "Catalog and main view updated") # Terminal success row in the audit log. _audit( None, actor.gitea_login, "graduate_complete", rfc_slug=state.slug, details={ "rfc_id": state.rfc_id, "repo": state.repo_full, "owners": state.owners, "pr_number": state.new_pr_number, }, ) state.succeeded = True state.finished = True await state.queue.put({"event": "completed", "payload": state.to_payload()}) except Exception as e: log.exception("graduate: unexpected error for %s", state.slug) # Best-effort: mark the in-flight step failed, then roll back. running = next((s for s in state.steps if s.status == "running"), None) if running is not None: await _fail(state, running.key, f"unexpected: {e}") await _rollback( config=config, gitea=gitea, bot=bot, actor=actor, state=state, failed_at=running.key if running else "unknown", ) finally: # Push the sentinel so any open SSE handler returns. await state.queue.put(None) async def _rollback( *, config: Config, gitea: Gitea, bot: Bot, actor: Actor, state: GraduationState, failed_at: str, ) -> None: """Run undoes in reverse order from the last completed step. Each undo emits its own rollback-step event so the dialog can render the cleanup as a visible step appended to the stack per §13.3.""" state.rollback_started = True # Mark every step after the failed one as not-reached so the rendered # stack is honest about what didn't run. seen_failure = False for s in state.steps: if s.status == "failed": seen_failure = True continue if seen_failure and s.status == "pending": s.status = "not-reached" # Walk completed steps in reverse and run their inverses. for s in reversed(state.steps): if s.status != "done": continue undo = _UNDO_BY_STEP.get(s.key) if undo is None: continue rb = StepState(key=f"undo:{s.key}", label=f"Undo: {s.label}", status="running", detail="") state.rollback_steps.append(rb) await state.queue.put({"event": "rollback_step", "payload": state.to_payload()}) try: detail = await undo( config=config, gitea=gitea, bot=bot, actor=actor, state=state, ) except Exception as e: rb.status = "failed" rb.detail = f"{e}" await state.queue.put({"event": "rollback_step", "payload": state.to_payload()}) continue rb.status = "done" rb.detail = detail or "" await state.queue.put({"event": "rollback_step", "payload": state.to_payload()}) _audit( None, actor.gitea_login, "graduate_rollback", rfc_slug=state.slug, details={ "failed_at": failed_at, "error": state.error, "rfc_id": state.rfc_id, "repo": state.repo_full, "undone": [s.key for s in state.rollback_steps if s.status == "done"], }, ) state.finished = True state.succeeded = False await state.queue.put({"event": "rolled_back", "payload": state.to_payload()}) async def _undo_create_repo(*, config, gitea, bot, actor, state) -> str: await bot.delete_rfc_repo( actor, org=config.gitea_org, repo_name=state.repo_name, slug=state.slug, reason="graduation rollback", ) return f"Deleted `{state.repo_full}`" async def _undo_seed_files(*, config, gitea, bot, actor, state) -> str: # The seed commits live inside the per-RFC repo created in step 1; # deleting the repo (step 1's undo) reclaims them at the same time. # We surface a separate rollback step here so the rendered stack # mirrors the forward steps, but the work is folded into _undo_create_repo. return "Folded into repo deletion" async def _undo_open_pr(*, config, gitea, bot, actor, state) -> str: if state.new_pr_number is None: return "No PR opened" await bot.close_graduation_pr( actor, org=config.gitea_org, meta_repo=config.meta_repo, pr_number=state.new_pr_number, head_branch=state.graduation_branch or "", slug=state.slug, reason="graduation rollback", ) return f"Closed PR #{state.new_pr_number}" # merge_pr's undo is intentionally absent — once the meta-repo merge has # landed, graduation is irreversible per §13.5. If we ever reach a merged # state and a later step fails (which can't happen — refresh_cache failures # fold into success), there is no clean undo path; the user transitions # via §3's `withdraw` instead. _UNDO_BY_STEP = { "create_repo": _undo_create_repo, "seed_files": _undo_seed_files, "open_pr": _undo_open_pr, } # --------------------------------------------------------------------------- # Permission + audit helpers # --------------------------------------------------------------------------- def _can_graduate(rfc, viewer) -> bool: if viewer is None: return False if viewer.role in ("owner", "admin"): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") return viewer.gitea_login in owners or viewer.gitea_login in arbiters def _audit( actor_user_id: int | None, on_behalf_of: str, action_kind: str, *, rfc_slug: str | None = None, branch_name: str | None = None, pr_number: int | None = None, details: dict | None = None, ) -> None: """Direct audit-log write for graduation lifecycle events that don't correspond to a single Gitea write. The per-step Gitea writes log themselves via the bot's `_log`; this is for the bracketing `graduate_start` / `graduate_complete` / `graduate_rollback` rows.""" db.conn().execute( """ INSERT INTO actions (actor_user_id, on_behalf_of, action_kind, rfc_slug, branch_name, pr_number, bot_commit_sha, details) VALUES (?, ?, ?, ?, ?, ?, NULL, ?) """, ( actor_user_id, on_behalf_of, action_kind, rfc_slug, branch_name, pr_number, json.dumps(details) if details else None, ), ) # --------------------------------------------------------------------------- # Step state transitions # --------------------------------------------------------------------------- async def _start(state: GraduationState, key: str, detail: str) -> None: step = next(s for s in state.steps if s.key == key) step.status = "running" step.detail = detail await state.queue.put({"event": "step", "payload": state.to_payload()}) async def _done(state: GraduationState, key: str, detail: str) -> None: step = next(s for s in state.steps if s.key == key) step.status = "done" step.detail = detail await state.queue.put({"event": "step", "payload": state.to_payload()}) async def _fail(state: GraduationState, key: str, detail: str) -> None: step = next(s for s in state.steps if s.key == key) step.status = "failed" step.detail = detail state.error = detail await state.queue.put({"event": "step", "payload": state.to_payload()}) def _sse_event(name: str, payload: Any) -> str: return f"event: {name}\ndata: {json.dumps(payload)}\n\n"