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