Slice 5: graduation per §13
The §13.3 transactional sequence flips a super-draft to active — five steps with paired undoes, an in-process orchestrator fed by an asyncio.Queue, the §17 SSE endpoint streaming step transitions to the dialog. Each step is a new bot primitive that logs an `actions` row, bracketed by `graduate_start` / `graduate_complete` for the linkable audit sequence. Rollback runs the undoes in reverse from the last completed step; merge_pr has no undo by design per §13.5. The §9.8 precondition gate is enforced server-side at the top of POST /graduate so the §13.3 rollback complexity does not grow. The §13.4 chat migration is a database semantic no-op — the (slug, branch_name='main') threads keep their identity, only the interpretation changes. The §9.8 pre-graduation history surfaces via a new _is_meta_target(rfc, branch) dispatch helper and lands as pre_graduation_history on /main. §13.1 claim flow landed alongside since it's the prerequisite for non-admin graduation — bot.open_claim_pr plus broadening api_prs._require_pr to accept meta_claim. 45/45 tests green; ten new integration tests cover the validator, the §9.8 precondition refusal, happy path with audit verification, mid-sequence rollback at steps 2 and 3, concurrent refusal, chat-survives-without-data-movement, pre-graduation history, and the §13.1 claim PR cycle. SPEC.md §19.1 rewritten for Slice 6 (notifications); §19.2 grew four candidates surfaced during the slice. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -17,7 +17,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import api_branches, api_prs, auth, db, entry as entry_mod, cache
|
||||
from . import api_branches, api_graduation, api_prs, auth, db, entry as entry_mod, cache
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
@@ -53,6 +53,8 @@ def make_router(
|
||||
router.include_router(api_branches.make_router(config, gitea, bot, providers))
|
||||
# Slice 3: the §10 PR-flow endpoints.
|
||||
router.include_router(api_prs.make_router(config, gitea, bot, providers))
|
||||
# Slice 5: §13 graduation + §13.1 claim.
|
||||
router.include_router(api_graduation.make_router(config, gitea, bot))
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Auth surface — extends the prototype's pattern but reads role
|
||||
|
||||
+97
-28
@@ -204,6 +204,46 @@ def make_router(
|
||||
|
||||
# For super-drafts the cached body is entry.body already (see
|
||||
# cache._upsert_cached_rfc), so no extraction is needed.
|
||||
# §9.8 / §13.4 pre-graduation history: for active RFCs, surface
|
||||
# any `threads` or `changes` rows whose `branch_name` starts with
|
||||
# `edit-<slug>-` so the breadcrumb dropdown can render the
|
||||
# affordance as a distinct disclosure alongside main, open
|
||||
# branches, and open PRs. The slug is the canonical key per §2.3
|
||||
# before and after graduation, so the query is a straightforward
|
||||
# lookup — no data movement.
|
||||
pre_grad: list[dict[str, Any]] = []
|
||||
if rfc["state"] == "active":
|
||||
pre_grad_rows = db.conn().execute(
|
||||
"""
|
||||
SELECT t.branch_name,
|
||||
COUNT(DISTINCT t.id) AS thread_count,
|
||||
COUNT(DISTINCT m.id) AS message_count,
|
||||
MAX(m.created_at) AS last_activity_at
|
||||
FROM threads t
|
||||
LEFT JOIN thread_messages m ON m.thread_id = t.id
|
||||
WHERE t.rfc_slug = ?
|
||||
AND (
|
||||
t.branch_name LIKE 'edit-' || ? || '-%'
|
||||
OR t.branch_name LIKE 'edit/' || ? || '/%'
|
||||
)
|
||||
GROUP BY t.branch_name
|
||||
ORDER BY MAX(m.created_at) DESC NULLS LAST, t.branch_name
|
||||
""",
|
||||
(slug, slug, slug),
|
||||
).fetchall()
|
||||
for r in pre_grad_rows:
|
||||
change_count = db.conn().execute(
|
||||
"SELECT COUNT(*) AS n FROM changes WHERE rfc_slug = ? AND branch_name = ?",
|
||||
(slug, r["branch_name"]),
|
||||
).fetchone()["n"]
|
||||
pre_grad.append({
|
||||
"branch_name": r["branch_name"],
|
||||
"thread_count": r["thread_count"],
|
||||
"message_count": r["message_count"],
|
||||
"change_count": change_count,
|
||||
"last_activity_at": r["last_activity_at"],
|
||||
})
|
||||
|
||||
return {
|
||||
"slug": slug,
|
||||
"title": rfc["title"],
|
||||
@@ -215,6 +255,7 @@ def make_router(
|
||||
"body_sha": rfc["body_sha"],
|
||||
"branches": branches,
|
||||
"open_prs": prs,
|
||||
"pre_graduation_history": pre_grad,
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
@@ -232,8 +273,8 @@ def make_router(
|
||||
if not _can_read_branch(slug, branch, viewer):
|
||||
raise HTTPException(403, "Branch is private")
|
||||
|
||||
owner, repo = _repo_for(rfc)
|
||||
path = _file_path_for(rfc)
|
||||
owner, repo = _repo_for(rfc, branch)
|
||||
path = _file_path_for(rfc, branch)
|
||||
result = await gitea.read_file(owner, repo, path, ref=branch)
|
||||
if result is None:
|
||||
br = await gitea.get_branch(owner, repo, branch)
|
||||
@@ -242,7 +283,7 @@ def make_router(
|
||||
body, body_sha = "", ""
|
||||
else:
|
||||
content, body_sha = result
|
||||
body = _extract_body(rfc, content)
|
||||
body = _extract_body(rfc, content, branch)
|
||||
|
||||
# Ensure the whole-doc chat thread for the branch exists.
|
||||
thread_id = _ensure_branch_chat_thread(slug, branch, viewer)
|
||||
@@ -482,13 +523,13 @@ def make_router(
|
||||
|
||||
# Fetch current file and extract the editable body. For super-draft
|
||||
# the file is rfcs/<slug>.md with frontmatter; for active it's RFC.md.
|
||||
owner, repo = _repo_for(rfc)
|
||||
path = _file_path_for(rfc)
|
||||
owner, repo = _repo_for(rfc, branch)
|
||||
path = _file_path_for(rfc, branch)
|
||||
fetched = await gitea.read_file(owner, repo, path, ref=branch)
|
||||
if fetched is None:
|
||||
raise HTTPException(409, f"Branch {path} not found")
|
||||
prior_content, prior_sha = fetched
|
||||
current_body = _extract_body(rfc, prior_content)
|
||||
current_body = _extract_body(rfc, prior_content, branch)
|
||||
|
||||
original = row["original"]
|
||||
occurrences = current_body.count(original)
|
||||
@@ -508,7 +549,7 @@ def make_router(
|
||||
else:
|
||||
new_body = current_body.replace(original, body.proposed, 1)
|
||||
|
||||
new_file_contents = _wrap_body(rfc, prior_content, new_body)
|
||||
new_file_contents = _wrap_body(rfc, prior_content, new_body, branch)
|
||||
|
||||
try:
|
||||
sha = await bot.commit_accepted_change(
|
||||
@@ -590,10 +631,10 @@ def make_router(
|
||||
if not providers:
|
||||
raise HTTPException(503, "No AI providers configured")
|
||||
|
||||
owner, repo = _repo_for(rfc)
|
||||
path = _file_path_for(rfc)
|
||||
owner, repo = _repo_for(rfc, branch)
|
||||
path = _file_path_for(rfc, branch)
|
||||
fetched = await gitea.read_file(owner, repo, path, ref=branch)
|
||||
body_text = _extract_body(rfc, fetched[0]) if fetched else ""
|
||||
body_text = _extract_body(rfc, fetched[0], branch) if fetched else ""
|
||||
|
||||
provider = next(iter(providers.values()))
|
||||
system = chat_layer.build_system_prompt(title=rfc["title"], body=body_text)
|
||||
@@ -638,18 +679,18 @@ def make_router(
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
_require_can_contribute(slug, branch, viewer)
|
||||
owner, repo = _repo_for(rfc)
|
||||
path = _file_path_for(rfc)
|
||||
owner, repo = _repo_for(rfc, branch)
|
||||
path = _file_path_for(rfc, branch)
|
||||
|
||||
fetched = await gitea.read_file(owner, repo, path, ref=branch)
|
||||
if fetched is None:
|
||||
raise HTTPException(409, f"Branch {path} not found")
|
||||
prior_content, prior_sha = fetched
|
||||
prior_body = _extract_body(rfc, prior_content)
|
||||
prior_body = _extract_body(rfc, prior_content, branch)
|
||||
if prior_body == body.new_content:
|
||||
return {"ok": True, "noop": True}
|
||||
|
||||
new_file_contents = _wrap_body(rfc, prior_content, body.new_content)
|
||||
new_file_contents = _wrap_body(rfc, prior_content, body.new_content, branch)
|
||||
|
||||
# Per §8.11: materialize the manual change as a `changes` row
|
||||
# first so the resolved card binds 1:1 to the commit.
|
||||
@@ -898,10 +939,10 @@ def make_router(
|
||||
# Fetch the live branch body so the prompt is anchored to
|
||||
# what's in Gitea right now, not the cache. For super-draft,
|
||||
# extract just the body part from the entry envelope.
|
||||
owner, repo = _repo_for(rfc)
|
||||
path = _file_path_for(rfc)
|
||||
owner, repo = _repo_for(rfc, branch)
|
||||
path = _file_path_for(rfc, branch)
|
||||
fetched = await gitea.read_file(owner, repo, path, ref=branch)
|
||||
body_text = _extract_body(rfc, fetched[0]) if fetched else ""
|
||||
body_text = _extract_body(rfc, fetched[0], branch) if fetched else ""
|
||||
|
||||
prompt_text = body.text
|
||||
if body.quote:
|
||||
@@ -981,22 +1022,42 @@ def make_router(
|
||||
def _is_super_draft(rfc) -> bool:
|
||||
return rfc["state"] == "super-draft"
|
||||
|
||||
def _repo_for(rfc) -> tuple[str, str]:
|
||||
def _is_meta_branch_name(name: str) -> bool:
|
||||
"""A branch name shaped like one of the bot's meta-repo prefixes.
|
||||
§9.8's pre-graduation history affordance points the new RFC view
|
||||
at branches matching `edit-<slug>-...` even after the entry is
|
||||
active; treating those names as meta-repo targets lets the read
|
||||
path dispatch correctly without a separate endpoint."""
|
||||
return name != "main" and name.startswith((
|
||||
"edit-", "edit/", "metadata-", "metadata/", "claim/", "propose/",
|
||||
"graduate-",
|
||||
))
|
||||
|
||||
def _is_meta_target(rfc, branch: str) -> bool:
|
||||
"""Either a super-draft branch (active edit branch or the
|
||||
canonical body) or an active RFC's pre-graduation meta-repo
|
||||
branch surfaced through the §9.8 history affordance."""
|
||||
if _is_super_draft(rfc):
|
||||
return True
|
||||
return _is_meta_branch_name(branch)
|
||||
|
||||
def _repo_for(rfc, branch: str = "main") -> tuple[str, str]:
|
||||
if _is_meta_target(rfc, branch):
|
||||
return config.gitea_org, config.meta_repo
|
||||
owner, repo = rfc["repo"].split("/", 1)
|
||||
return owner, repo
|
||||
|
||||
def _file_path_for(rfc) -> str:
|
||||
if _is_super_draft(rfc):
|
||||
def _file_path_for(rfc, branch: str = "main") -> str:
|
||||
if _is_meta_target(rfc, branch):
|
||||
return f"rfcs/{rfc['slug']}.md"
|
||||
return RFC_FILE_PATH
|
||||
|
||||
def _extract_body(rfc, file_contents: str) -> str:
|
||||
"""For super-draft entries the file on disk is the full
|
||||
frontmatter+body envelope; the editable body is entry.body. For
|
||||
active RFCs the file is just RFC.md and the whole thing is body."""
|
||||
if not _is_super_draft(rfc):
|
||||
def _extract_body(rfc, file_contents: str, branch: str = "main") -> str:
|
||||
"""For super-draft entries (and active-RFC pre-graduation reads
|
||||
per §9.8) the file on disk is the full frontmatter+body envelope;
|
||||
the editable body is entry.body. For active RFCs reading their
|
||||
per-RFC repo the file is just RFC.md and the whole thing is body."""
|
||||
if not _is_meta_target(rfc, branch):
|
||||
return file_contents
|
||||
try:
|
||||
entry = entry_mod.parse(file_contents)
|
||||
@@ -1004,10 +1065,10 @@ def make_router(
|
||||
return file_contents
|
||||
return entry.body
|
||||
|
||||
def _wrap_body(rfc, prior_contents: str, new_body: str) -> str:
|
||||
def _wrap_body(rfc, prior_contents: str, new_body: str, branch: str = "main") -> str:
|
||||
"""Inverse of _extract_body: re-wrap a new body into the entry
|
||||
envelope, preserving the prior frontmatter exactly."""
|
||||
if not _is_super_draft(rfc):
|
||||
if not _is_meta_target(rfc, branch):
|
||||
return new_body
|
||||
entry = entry_mod.parse(prior_contents)
|
||||
# Ensure exactly one trailing newline so the serializer's
|
||||
@@ -1125,6 +1186,13 @@ def make_router(
|
||||
return False
|
||||
if branch == "main":
|
||||
return False
|
||||
# §9.8: pre-graduation history branches are read-only on the
|
||||
# post-graduation surface. The contributor can re-cut against the
|
||||
# new repo's main if they still want the work, but the meta-repo
|
||||
# branches that lived on the super-draft are not editable from
|
||||
# the active-RFC view.
|
||||
if rfc["state"] == "active" and _is_meta_branch_name(branch):
|
||||
return False
|
||||
if viewer.role in ("owner", "admin"):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
@@ -1150,7 +1218,8 @@ def make_router(
|
||||
|
||||
def _require_can_contribute(slug: str, branch: str, viewer) -> None:
|
||||
rfc = db.conn().execute(
|
||||
"SELECT owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
"SELECT state, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if not _can_contribute(rfc, slug, branch, viewer):
|
||||
raise HTTPException(403, "You do not have contribute access to this branch")
|
||||
|
||||
@@ -0,0 +1,940 @@
|
||||
"""Slice 5 API surface — the §13 graduation flow's endpoints and the
|
||||
in-process orchestrator that runs the §13.3 transactional sequence with
|
||||
rollback.
|
||||
|
||||
Owns four routes per §17:
|
||||
|
||||
- GET /api/rfcs/<slug>/blocking-prs (§13.2 precondition popover)
|
||||
- GET /api/rfcs/<slug>/graduate/check (§13.2 debounced validator)
|
||||
- POST /api/rfcs/<slug>/graduate (§13.3 kickoff)
|
||||
- GET /api/rfcs/<slug>/graduate/progress (§13.3 SSE step stream)
|
||||
|
||||
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim), which is
|
||||
graduation's prerequisite for non-admins per §13.1.
|
||||
|
||||
The orchestrator runs in-process — each in-flight graduation lives in a
|
||||
small `GraduationState` keyed by slug, with an asyncio.Queue feeding the
|
||||
SSE handler. Per the §13.3 transactional contract, every forward step is
|
||||
paired with an undo; rollback runs the undos in reverse order from the
|
||||
last step that completed. §13.4's chat migration is a database semantic
|
||||
no-op (the threads' `(rfc_slug, branch_name='main')` rows are interpreted
|
||||
as super-draft canonical-body before graduation and as new-RFC main
|
||||
afterwards — same shape, different meaning), so the only DB work the
|
||||
sequence does is the audit-log rows the bot's `_log` writes per step.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, cache, db, entry as entry_mod
|
||||
from .bot import Actor, Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step machine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
STEP_KEYS = (
|
||||
"create_repo",
|
||||
"seed_files",
|
||||
"open_pr",
|
||||
"merge_pr",
|
||||
"refresh_cache",
|
||||
)
|
||||
|
||||
STEP_LABELS = {
|
||||
"create_repo": "Create per-RFC repository",
|
||||
"seed_files": "Seed RFC.md, README.md, and .rfc/metadata.yaml",
|
||||
"open_pr": "Open meta-repo graduation PR",
|
||||
"merge_pr": "Merge graduation PR",
|
||||
"refresh_cache": "Refresh catalog and views",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepState:
|
||||
key: str
|
||||
label: str
|
||||
status: str = "pending" # pending|running|done|failed|not-reached
|
||||
detail: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraduationState:
|
||||
slug: str
|
||||
rfc_id: str
|
||||
repo_name: str
|
||||
repo_full: str
|
||||
owners: list[str]
|
||||
arbiters: list[str]
|
||||
steps: list[StepState]
|
||||
queue: asyncio.Queue = field(default_factory=asyncio.Queue)
|
||||
finished: bool = False
|
||||
succeeded: bool = False
|
||||
error: str | None = None
|
||||
rollback_started: bool = False
|
||||
rollback_steps: list[StepState] = field(default_factory=list)
|
||||
new_pr_number: int | None = None
|
||||
graduation_branch: str | None = None
|
||||
|
||||
def to_payload(self) -> dict:
|
||||
return {
|
||||
"slug": self.slug,
|
||||
"rfc_id": self.rfc_id,
|
||||
"repo_full": self.repo_full,
|
||||
"steps": [_step_payload(s) for s in self.steps],
|
||||
"rollback_steps": [_step_payload(s) for s in self.rollback_steps],
|
||||
"finished": self.finished,
|
||||
"succeeded": self.succeeded,
|
||||
"rolled_back": self.rollback_started,
|
||||
"error": self.error,
|
||||
"pr_number": self.new_pr_number,
|
||||
}
|
||||
|
||||
|
||||
def _step_payload(s: StepState) -> dict:
|
||||
return {"key": s.key, "label": s.label, "status": s.status, "detail": s.detail}
|
||||
|
||||
|
||||
# Process-local registry. Single-process FastAPI per §4.2 means in-memory
|
||||
# is fine; the registry is keyed by slug to refuse concurrent graduations
|
||||
# of the same entry (the §13.2 atomic re-check is a separate defense
|
||||
# against a concurrent attempt of a DIFFERENT slug claiming the same
|
||||
# integer ID or repo name).
|
||||
_active: dict[str, GraduationState] = {}
|
||||
|
||||
|
||||
def _get_active(slug: str) -> GraduationState | None:
|
||||
return _active.get(slug)
|
||||
|
||||
|
||||
def _new_active(slug: str, *, rfc_id: str, repo_name: str, repo_full: str,
|
||||
owners: list[str], arbiters: list[str]) -> GraduationState:
|
||||
state = GraduationState(
|
||||
slug=slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full,
|
||||
owners=owners, arbiters=arbiters,
|
||||
steps=[StepState(key=k, label=STEP_LABELS[k]) for k in STEP_KEYS],
|
||||
)
|
||||
_active[slug] = state
|
||||
return state
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# §13.2: Gitea repo name pattern. Gitea accepts alphanumerics, dashes,
|
||||
# dots, and underscores; cannot start with a dot. 100-char cap as a sane
|
||||
# upper bound — the spec doesn't pin a max but Gitea's enforcement does.
|
||||
_REPO_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$")
|
||||
_RFC_ID_RE = re.compile(r"^RFC-\d{4,}$")
|
||||
|
||||
|
||||
def _is_valid_repo_name(name: str) -> bool:
|
||||
return bool(_REPO_NAME_RE.match(name)) and ".." not in name
|
||||
|
||||
|
||||
def _is_valid_rfc_id(rfc_id: str) -> bool:
|
||||
return bool(_RFC_ID_RE.match(rfc_id))
|
||||
|
||||
|
||||
def _suggest_next_rfc_id() -> str:
|
||||
rows = db.conn().execute(
|
||||
"SELECT rfc_id FROM cached_rfcs WHERE rfc_id LIKE 'RFC-%'"
|
||||
).fetchall()
|
||||
used: set[int] = set()
|
||||
for r in rows:
|
||||
try:
|
||||
used.add(int(r["rfc_id"].split("-", 1)[1]))
|
||||
except (IndexError, ValueError):
|
||||
continue
|
||||
nxt = (max(used) + 1) if used else 1
|
||||
return f"RFC-{nxt:04d}"
|
||||
|
||||
|
||||
def _suggest_repo_name(slug: str, rfc_id: str) -> str:
|
||||
# rfc-NNNN-<slug> per §13.2's default. Strip the 'RFC-' prefix and
|
||||
# lowercase the number-pad.
|
||||
num = rfc_id.split("-", 1)[1] if "-" in rfc_id else "0001"
|
||||
return f"rfc-{num}-{slug}"
|
||||
|
||||
|
||||
def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool:
|
||||
row = db.conn().execute(
|
||||
"SELECT slug FROM cached_rfcs WHERE rfc_id = ? AND slug != ?",
|
||||
(rfc_id, excluding_slug),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request bodies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GraduateBody(BaseModel):
|
||||
rfc_id: str = Field(min_length=5, max_length=40)
|
||||
repo_name: str = Field(min_length=1, max_length=100)
|
||||
owners: list[str] = Field(min_length=1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_router(
|
||||
config: Config,
|
||||
gitea: Gitea,
|
||||
bot: Bot,
|
||||
) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §13.2: GET /api/rfcs/<slug>/blocking-prs
|
||||
# Lists open meta-repo PRs against rfcs/<slug>.md per the precondition
|
||||
# popover. Returns PR number, title, author, last-activity timestamp,
|
||||
# and the viewer's available actions (merge, withdraw, open-in-new-tab).
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/blocking-prs")
|
||||
async def list_blocking_prs(slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
rfc = _require_super_draft(slug)
|
||||
# §13's opening paragraph: only body-edit PRs block graduation.
|
||||
# Bare edit branches without an open PR do not block. The query
|
||||
# filters cached_prs to open meta_body_edit kinds for this slug.
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT pr_number, title, opened_by, opened_at, head_branch, pr_kind
|
||||
FROM cached_prs
|
||||
WHERE rfc_slug = ?
|
||||
AND state = 'open'
|
||||
AND pr_kind = 'meta_body_edit'
|
||||
ORDER BY opened_at DESC
|
||||
""",
|
||||
(slug,),
|
||||
).fetchall()
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
items = []
|
||||
for r in rows:
|
||||
can_merge = (
|
||||
viewer is not None
|
||||
and (
|
||||
viewer.role in ("owner", "admin")
|
||||
or viewer.gitea_login in owners
|
||||
or viewer.gitea_login in arbiters
|
||||
)
|
||||
)
|
||||
can_withdraw = (
|
||||
viewer is not None
|
||||
and (
|
||||
can_merge
|
||||
or viewer.gitea_login == (r["opened_by"] or "")
|
||||
)
|
||||
)
|
||||
items.append({
|
||||
"pr_number": r["pr_number"],
|
||||
"title": r["title"],
|
||||
"author": r["opened_by"],
|
||||
"last_activity_at": r["opened_at"],
|
||||
"head_branch": r["head_branch"],
|
||||
"actions": {
|
||||
"merge": can_merge,
|
||||
"withdraw": can_withdraw,
|
||||
"open_in_new_tab": True,
|
||||
},
|
||||
})
|
||||
return {"items": items}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §13.2: GET /api/rfcs/<slug>/graduate/check?id=&repo=
|
||||
# Inline validation for the Graduate dialog — debounced from the
|
||||
# client; the dialog calls this as the admin types. Returns per-field
|
||||
# collision/validity from the catalog cache plus a server-authoritative
|
||||
# repo-name collision check.
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/graduate/check")
|
||||
async def graduate_check(
|
||||
slug: str, request: Request,
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
rfc = _require_super_draft(slug)
|
||||
del viewer # no permission gate — the dialog only shows up for
|
||||
# admins/owners, but the check itself is read-only.
|
||||
|
||||
candidate_id = (request.query_params.get("id") or "").strip()
|
||||
candidate_repo = (request.query_params.get("repo") or "").strip()
|
||||
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
blocking_count = db.conn().execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n FROM cached_prs
|
||||
WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit'
|
||||
""",
|
||||
(slug,),
|
||||
).fetchone()["n"]
|
||||
|
||||
# ID field
|
||||
id_payload: dict[str, Any] = {"value": candidate_id, "ok": True, "error": None}
|
||||
if not candidate_id:
|
||||
id_payload["ok"] = False
|
||||
id_payload["error"] = "Integer ID is required"
|
||||
elif not _is_valid_rfc_id(candidate_id):
|
||||
id_payload["ok"] = False
|
||||
id_payload["error"] = "ID must look like RFC-NNNN (at least four digits)"
|
||||
elif _rfc_id_taken(candidate_id, excluding_slug=slug):
|
||||
id_payload["ok"] = False
|
||||
id_payload["error"] = f"Integer ID {candidate_id} is already taken"
|
||||
|
||||
# Repo field — validate pattern then probe Gitea for an existing
|
||||
# repo of that name under our org. The repo lookup is a single GET
|
||||
# so it's cheap to call on every keystroke (debounced from the
|
||||
# client per §13.2).
|
||||
repo_payload: dict[str, Any] = {"value": candidate_repo, "ok": True, "error": None}
|
||||
if not candidate_repo:
|
||||
repo_payload["ok"] = False
|
||||
repo_payload["error"] = "Repo name is required"
|
||||
elif not _is_valid_repo_name(candidate_repo):
|
||||
repo_payload["ok"] = False
|
||||
repo_payload["error"] = (
|
||||
"Repo name must be alphanumerics, dashes, dots, or underscores "
|
||||
"(start with alphanumeric)"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
existing = await gitea.get_repo(config.gitea_org, candidate_repo)
|
||||
except GiteaError as e:
|
||||
# Network/auth flake — surface as a non-fatal hint; the
|
||||
# atomic server-side check at POST time is the authority.
|
||||
existing = None
|
||||
log.warning("graduate_check: Gitea get_repo error: %s", e)
|
||||
if existing is not None:
|
||||
repo_payload["ok"] = False
|
||||
repo_payload["error"] = (
|
||||
f"Repo `{config.gitea_org}/{candidate_repo}` already exists"
|
||||
)
|
||||
|
||||
# Owners precondition — §13's opening paragraph.
|
||||
owners_payload: dict[str, Any] = {
|
||||
"ok": len(owners) > 0,
|
||||
"count": len(owners),
|
||||
"current": owners,
|
||||
"error": None if len(owners) > 0 else "No owners claimed yet",
|
||||
}
|
||||
|
||||
# Blocking PR precondition — §9.8 / §13's opening paragraph.
|
||||
prs_payload: dict[str, Any] = {
|
||||
"ok": blocking_count == 0,
|
||||
"count": blocking_count,
|
||||
"error": (
|
||||
None if blocking_count == 0
|
||||
else f"{blocking_count} open body-edit PR{'' if blocking_count == 1 else 's'} blocking graduation"
|
||||
),
|
||||
}
|
||||
|
||||
in_flight = _get_active(slug)
|
||||
any_invalid = not (
|
||||
id_payload["ok"] and repo_payload["ok"]
|
||||
and owners_payload["ok"] and prs_payload["ok"]
|
||||
)
|
||||
|
||||
return {
|
||||
"slug": slug,
|
||||
"id": id_payload,
|
||||
"repo": repo_payload,
|
||||
"owners": owners_payload,
|
||||
"blocking_prs": prs_payload,
|
||||
"can_submit": (not any_invalid) and (in_flight is None or in_flight.finished),
|
||||
"in_flight": (
|
||||
None if in_flight is None
|
||||
else {"finished": in_flight.finished, "succeeded": in_flight.succeeded}
|
||||
),
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §13.3: POST /api/rfcs/<slug>/graduate
|
||||
# Atomic re-validation, then kicks off the sequence as an async task.
|
||||
# The client opens GET /graduate/progress on confirm to watch the SSE.
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/graduate")
|
||||
async def graduate(slug: str, body: GraduateBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_super_draft(slug)
|
||||
# §13: only owners/arbiters of the RFC and app admins/owners may
|
||||
# graduate. Until §13.1's claim runs the entry has no owners, so
|
||||
# the set collapses to app admins/owners for unclaimed entries.
|
||||
if not _can_graduate(rfc, viewer):
|
||||
raise HTTPException(403, "Only RFC owners/arbiters or app admins/owners may graduate")
|
||||
|
||||
# Refuse if an in-flight graduation is still running for this slug.
|
||||
existing = _get_active(slug)
|
||||
if existing is not None and not existing.finished:
|
||||
raise HTTPException(409, "Graduation already in progress for this slug")
|
||||
|
||||
# §13.2 atomic re-validation. The dialog's debounced check runs
|
||||
# client-side as the admin types; this is the authoritative check
|
||||
# that closes the dialog-open-to-confirm race.
|
||||
rfc_id = body.rfc_id.strip()
|
||||
repo_name = body.repo_name.strip()
|
||||
owners = [o.strip() for o in body.owners if o.strip()]
|
||||
if not owners:
|
||||
raise HTTPException(422, "Add at least one initial owner")
|
||||
if not _is_valid_rfc_id(rfc_id):
|
||||
raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)")
|
||||
if _rfc_id_taken(rfc_id, excluding_slug=slug):
|
||||
raise HTTPException(409, f"Integer ID {rfc_id} is already taken")
|
||||
if not _is_valid_repo_name(repo_name):
|
||||
raise HTTPException(422, "Repo name must be alphanumerics, dashes, dots, or underscores")
|
||||
try:
|
||||
existing_repo = await gitea.get_repo(config.gitea_org, repo_name)
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
if existing_repo is not None:
|
||||
raise HTTPException(409, f"Repo `{config.gitea_org}/{repo_name}` already exists")
|
||||
|
||||
# §9.8 precondition gate — enforced before the bot starts the
|
||||
# sequence so the §13.3 rollback complexity does not grow. An
|
||||
# open body-edit PR against rfcs/<slug>.md would attempt to
|
||||
# re-introduce a body to a frontmatter-only entry after step 3.
|
||||
blocking = db.conn().execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n FROM cached_prs
|
||||
WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit'
|
||||
""",
|
||||
(slug,),
|
||||
).fetchone()["n"]
|
||||
if blocking > 0:
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"{blocking} open body-edit PR{'' if blocking == 1 else 's'} block graduation",
|
||||
)
|
||||
|
||||
# Read the meta-repo entry once — we need the file's sha for the
|
||||
# graduation PR's update_file call and the original body so the
|
||||
# bot can seed RFC.md on the new repo with the migrated body.
|
||||
fetched = await gitea.read_file(
|
||||
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
|
||||
)
|
||||
if fetched is None:
|
||||
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
|
||||
meta_text, meta_sha = fetched
|
||||
try:
|
||||
super_draft_entry = entry_mod.parse(meta_text)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Meta entry malformed: {e}")
|
||||
|
||||
repo_full = f"{config.gitea_org}/{repo_name}"
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1]
|
||||
|
||||
# Compose the graduated frontmatter — body stripped, graduation
|
||||
# fields filled. The serializer is run now so the PR-open step
|
||||
# has the contents pre-rendered (single source of truth for the
|
||||
# body migration vs. the meta-entry update).
|
||||
graduated_entry = entry_mod.Entry(
|
||||
slug=slug,
|
||||
title=super_draft_entry.title,
|
||||
state="active",
|
||||
id=rfc_id,
|
||||
repo=repo_full,
|
||||
proposed_by=super_draft_entry.proposed_by,
|
||||
proposed_at=super_draft_entry.proposed_at,
|
||||
graduated_at=entry_mod.today(),
|
||||
graduated_by=viewer.gitea_login,
|
||||
owners=owners,
|
||||
arbiters=arbiters,
|
||||
tags=list(super_draft_entry.tags),
|
||||
body="",
|
||||
)
|
||||
graduated_contents = entry_mod.serialize(graduated_entry)
|
||||
|
||||
state = _new_active(
|
||||
slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full,
|
||||
owners=owners, arbiters=arbiters,
|
||||
)
|
||||
|
||||
# Audit: graduation started. The terminal `graduate_complete` /
|
||||
# `graduate_rollback` rows below close the linkable sequence.
|
||||
_audit(
|
||||
viewer.user_id, viewer.gitea_login, "graduate_start",
|
||||
rfc_slug=slug,
|
||||
details={
|
||||
"rfc_id": rfc_id, "repo": repo_full, "owners": owners,
|
||||
"blocking_prs": blocking,
|
||||
},
|
||||
)
|
||||
|
||||
# Test seam: `?_sync=1` awaits the orchestrator inline so
|
||||
# integration tests can assert post-conditions without driving
|
||||
# the SSE. Production clients use the spec-described shape —
|
||||
# POST returns immediately, the client subscribes to the
|
||||
# progress SSE.
|
||||
coro = _orchestrate(
|
||||
config=config, gitea=gitea, bot=bot,
|
||||
actor=viewer.as_actor(), state=state,
|
||||
super_draft_body=super_draft_entry.body,
|
||||
super_draft_title=super_draft_entry.title,
|
||||
super_draft_tags=list(super_draft_entry.tags),
|
||||
graduated_contents=graduated_contents,
|
||||
meta_file_sha=meta_sha,
|
||||
)
|
||||
if request.query_params.get("_sync") == "1":
|
||||
await coro
|
||||
else:
|
||||
asyncio.create_task(coro)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"slug": slug,
|
||||
"rfc_id": rfc_id,
|
||||
"repo": repo_full,
|
||||
"stream_url": f"/api/rfcs/{slug}/graduate/progress",
|
||||
"finished": state.finished,
|
||||
"succeeded": state.succeeded,
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §13.3: GET /api/rfcs/<slug>/graduate/progress
|
||||
# SSE stream of the step transitions. One event per step transition
|
||||
# (pending → running → done / failed), plus the trailing rollback
|
||||
# step's events if any earlier step fails.
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/graduate/progress")
|
||||
async def graduate_progress(slug: str, request: Request):
|
||||
del request
|
||||
state = _get_active(slug)
|
||||
if state is None:
|
||||
raise HTTPException(404, "No graduation in flight for this slug")
|
||||
|
||||
async def event_stream():
|
||||
# Emit the current snapshot first so a late subscriber sees
|
||||
# the steps already completed.
|
||||
yield _sse_event("snapshot", state.to_payload())
|
||||
if state.finished:
|
||||
yield _sse_event("done", state.to_payload())
|
||||
return
|
||||
while True:
|
||||
evt = await state.queue.get()
|
||||
if evt is None:
|
||||
yield _sse_event("done", state.to_payload())
|
||||
return
|
||||
yield _sse_event(evt.get("event", "update"), evt.get("payload"))
|
||||
|
||||
headers = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream", headers=headers)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §13.1: POST /api/rfcs/<slug>/claim
|
||||
# Opens a meta-repo PR adding the actor's gitea_login to the entry's
|
||||
# owners list. Anyone signed in may claim — the merge is gated to
|
||||
# owners/admins per §13.1 (which collapses to admins for unclaimed
|
||||
# entries since `owners` is empty).
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/claim")
|
||||
async def claim_ownership(slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_super_draft(slug)
|
||||
# Refuse if the actor is already in owners — no-op claim.
|
||||
existing_owners = json.loads(rfc["owners_json"] or "[]")
|
||||
if viewer.gitea_login in existing_owners:
|
||||
return {"ok": True, "noop": True}
|
||||
# Refuse if a claim PR for this actor is already open. The branch
|
||||
# name `claim/<slug>` collides per actor implicitly since Gitea
|
||||
# refuses duplicate branch creation; we surface a clean 409 here
|
||||
# so the client doesn't see a 502.
|
||||
already = db.conn().execute(
|
||||
"""
|
||||
SELECT pr_number FROM cached_prs
|
||||
WHERE rfc_slug = ? AND pr_kind = 'meta_claim' AND state = 'open'
|
||||
""",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if already:
|
||||
raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}")
|
||||
|
||||
# Compose the new entry contents — owners list with the claimant
|
||||
# appended.
|
||||
fetched = await gitea.read_file(
|
||||
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
|
||||
)
|
||||
if fetched is None:
|
||||
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
|
||||
meta_text, meta_sha = fetched
|
||||
try:
|
||||
ent = entry_mod.parse(meta_text)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Meta entry malformed: {e}")
|
||||
if viewer.gitea_login in ent.owners:
|
||||
return {"ok": True, "noop": True}
|
||||
ent.owners = ent.owners + [viewer.gitea_login]
|
||||
new_contents = entry_mod.serialize(ent)
|
||||
try:
|
||||
pr = await bot.open_claim_pr(
|
||||
viewer.as_actor(),
|
||||
org=config.gitea_org, meta_repo=config.meta_repo,
|
||||
slug=slug,
|
||||
new_file_contents=new_contents, prior_sha=meta_sha,
|
||||
)
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
await cache.refresh_meta_branches(config, gitea)
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
return {"pr_number": pr["number"], "slug": slug, "branch_name": pr["head"]["ref"]}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def _require_super_draft(slug: str):
|
||||
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
if row["state"] != "super-draft":
|
||||
raise HTTPException(409, f"RFC is {row['state']}, not super-draft")
|
||||
return row
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestrator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _orchestrate(
|
||||
*,
|
||||
config: Config,
|
||||
gitea: Gitea,
|
||||
bot: Bot,
|
||||
actor: Actor,
|
||||
state: GraduationState,
|
||||
super_draft_body: str,
|
||||
super_draft_title: str,
|
||||
super_draft_tags: list[str],
|
||||
graduated_contents: str,
|
||||
meta_file_sha: str,
|
||||
) -> None:
|
||||
"""Run §13.3 step by step. Each step:
|
||||
|
||||
- marks itself `running` and pushes an event
|
||||
- calls the bot method (which writes to Gitea + audit log)
|
||||
- marks itself `done` (or `failed`) and pushes another event
|
||||
|
||||
On failure at step N, every later step is marked `not-reached` and
|
||||
`_rollback` runs undoes in reverse from N-1 to 1.
|
||||
"""
|
||||
try:
|
||||
# ----- Step 1: create per-RFC repo -----
|
||||
await _start(state, "create_repo", f"Creating `{state.repo_full}`…")
|
||||
try:
|
||||
await bot.create_rfc_repo_for_graduation(
|
||||
actor, org=config.gitea_org, repo_name=state.repo_name,
|
||||
slug=state.slug, title=super_draft_title,
|
||||
)
|
||||
except GiteaError as e:
|
||||
await _fail(state, "create_repo", f"Gitea: {e.detail}")
|
||||
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
|
||||
state=state, failed_at="create_repo")
|
||||
return
|
||||
await _done(state, "create_repo", state.repo_full)
|
||||
|
||||
# ----- Step 2: seed RFC.md, README.md, .rfc/metadata.yaml -----
|
||||
await _start(state, "seed_files", "Writing initial commit on main…")
|
||||
try:
|
||||
await bot.seed_graduated_rfc(
|
||||
actor,
|
||||
org=config.gitea_org, repo_name=state.repo_name,
|
||||
slug=state.slug, title=super_draft_title,
|
||||
rfc_body=super_draft_body, rfc_id=state.rfc_id,
|
||||
meta_full=config.meta_repo_full,
|
||||
meta_path=f"rfcs/{state.slug}.md",
|
||||
owners=state.owners, arbiters=state.arbiters,
|
||||
tags=super_draft_tags,
|
||||
)
|
||||
except GiteaError as e:
|
||||
await _fail(state, "seed_files", f"Gitea: {e.detail}")
|
||||
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
|
||||
state=state, failed_at="seed_files")
|
||||
return
|
||||
await _done(state, "seed_files", "RFC.md, README.md, .rfc/metadata.yaml")
|
||||
|
||||
# ----- Step 3: open graduation PR -----
|
||||
await _start(state, "open_pr", "Opening graduation PR…")
|
||||
try:
|
||||
pr = await bot.open_graduation_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=config.meta_repo,
|
||||
slug=state.slug,
|
||||
new_file_contents=graduated_contents,
|
||||
prior_sha=meta_file_sha,
|
||||
rfc_id=state.rfc_id, repo_full=state.repo_full,
|
||||
owners=state.owners,
|
||||
)
|
||||
except GiteaError as e:
|
||||
await _fail(state, "open_pr", f"Gitea: {e.detail}")
|
||||
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
|
||||
state=state, failed_at="open_pr")
|
||||
return
|
||||
state.new_pr_number = pr["number"]
|
||||
state.graduation_branch = pr["head"]["ref"]
|
||||
await _done(state, "open_pr", f"PR #{state.new_pr_number}")
|
||||
|
||||
# ----- Step 4: merge the graduation PR -----
|
||||
await _start(state, "merge_pr", f"Merging PR #{state.new_pr_number}…")
|
||||
try:
|
||||
await bot.merge_graduation_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=config.meta_repo,
|
||||
pr_number=state.new_pr_number,
|
||||
head_branch=state.graduation_branch or "",
|
||||
slug=state.slug, rfc_id=state.rfc_id,
|
||||
)
|
||||
except GiteaError as e:
|
||||
await _fail(state, "merge_pr", f"Gitea: {e.detail}")
|
||||
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
|
||||
state=state, failed_at="merge_pr")
|
||||
return
|
||||
await _done(state, "merge_pr", f"PR #{state.new_pr_number} merged")
|
||||
|
||||
# ----- Step 5: refresh the cache so the catalog flips immediately.
|
||||
# Per §13.3 step 5 the webhook flow is the steady-state path, but
|
||||
# we refresh inline so the dialog can transition to "graduation
|
||||
# complete" with the catalog row already showing `active`. A
|
||||
# cache-refresh failure does not unwind Git state — the
|
||||
# reconciler will catch up per §4.1.
|
||||
await _start(state, "refresh_cache", "Refreshing catalog and views…")
|
||||
try:
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
await cache.refresh_meta_branches(config, gitea)
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
await cache.refresh_rfc_repo(config, gitea, state.slug)
|
||||
except Exception as e:
|
||||
log.warning("graduate refresh_cache failed for %s: %s", state.slug, e)
|
||||
await _done(state, "refresh_cache", f"Cache will catch up via reconciler ({e})")
|
||||
else:
|
||||
await _done(state, "refresh_cache", "Catalog and main view updated")
|
||||
|
||||
# Terminal success row in the audit log.
|
||||
_audit(
|
||||
None, actor.gitea_login, "graduate_complete",
|
||||
rfc_slug=state.slug,
|
||||
details={
|
||||
"rfc_id": state.rfc_id, "repo": state.repo_full,
|
||||
"owners": state.owners, "pr_number": state.new_pr_number,
|
||||
},
|
||||
)
|
||||
state.succeeded = True
|
||||
state.finished = True
|
||||
await state.queue.put({"event": "completed", "payload": state.to_payload()})
|
||||
except Exception as e:
|
||||
log.exception("graduate: unexpected error for %s", state.slug)
|
||||
# Best-effort: mark the in-flight step failed, then roll back.
|
||||
running = next((s for s in state.steps if s.status == "running"), None)
|
||||
if running is not None:
|
||||
await _fail(state, running.key, f"unexpected: {e}")
|
||||
await _rollback(
|
||||
config=config, gitea=gitea, bot=bot, actor=actor,
|
||||
state=state, failed_at=running.key if running else "unknown",
|
||||
)
|
||||
finally:
|
||||
# Push the sentinel so any open SSE handler returns.
|
||||
await state.queue.put(None)
|
||||
|
||||
|
||||
async def _rollback(
|
||||
*,
|
||||
config: Config, gitea: Gitea, bot: Bot, actor: Actor,
|
||||
state: GraduationState, failed_at: str,
|
||||
) -> None:
|
||||
"""Run undoes in reverse order from the last completed step. Each
|
||||
undo emits its own rollback-step event so the dialog can render the
|
||||
cleanup as a visible step appended to the stack per §13.3."""
|
||||
state.rollback_started = True
|
||||
# Mark every step after the failed one as not-reached so the rendered
|
||||
# stack is honest about what didn't run.
|
||||
seen_failure = False
|
||||
for s in state.steps:
|
||||
if s.status == "failed":
|
||||
seen_failure = True
|
||||
continue
|
||||
if seen_failure and s.status == "pending":
|
||||
s.status = "not-reached"
|
||||
|
||||
# Walk completed steps in reverse and run their inverses.
|
||||
for s in reversed(state.steps):
|
||||
if s.status != "done":
|
||||
continue
|
||||
undo = _UNDO_BY_STEP.get(s.key)
|
||||
if undo is None:
|
||||
continue
|
||||
rb = StepState(key=f"undo:{s.key}", label=f"Undo: {s.label}",
|
||||
status="running", detail="")
|
||||
state.rollback_steps.append(rb)
|
||||
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
|
||||
try:
|
||||
detail = await undo(
|
||||
config=config, gitea=gitea, bot=bot, actor=actor, state=state,
|
||||
)
|
||||
except Exception as e:
|
||||
rb.status = "failed"
|
||||
rb.detail = f"{e}"
|
||||
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
|
||||
continue
|
||||
rb.status = "done"
|
||||
rb.detail = detail or ""
|
||||
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
|
||||
|
||||
_audit(
|
||||
None, actor.gitea_login, "graduate_rollback",
|
||||
rfc_slug=state.slug,
|
||||
details={
|
||||
"failed_at": failed_at,
|
||||
"error": state.error,
|
||||
"rfc_id": state.rfc_id,
|
||||
"repo": state.repo_full,
|
||||
"undone": [s.key for s in state.rollback_steps if s.status == "done"],
|
||||
},
|
||||
)
|
||||
state.finished = True
|
||||
state.succeeded = False
|
||||
await state.queue.put({"event": "rolled_back", "payload": state.to_payload()})
|
||||
|
||||
|
||||
async def _undo_create_repo(*, config, gitea, bot, actor, state) -> str:
|
||||
await bot.delete_rfc_repo(
|
||||
actor, org=config.gitea_org, repo_name=state.repo_name,
|
||||
slug=state.slug, reason="graduation rollback",
|
||||
)
|
||||
return f"Deleted `{state.repo_full}`"
|
||||
|
||||
|
||||
async def _undo_seed_files(*, config, gitea, bot, actor, state) -> str:
|
||||
# The seed commits live inside the per-RFC repo created in step 1;
|
||||
# deleting the repo (step 1's undo) reclaims them at the same time.
|
||||
# We surface a separate rollback step here so the rendered stack
|
||||
# mirrors the forward steps, but the work is folded into _undo_create_repo.
|
||||
return "Folded into repo deletion"
|
||||
|
||||
|
||||
async def _undo_open_pr(*, config, gitea, bot, actor, state) -> str:
|
||||
if state.new_pr_number is None:
|
||||
return "No PR opened"
|
||||
await bot.close_graduation_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=config.meta_repo,
|
||||
pr_number=state.new_pr_number,
|
||||
head_branch=state.graduation_branch or "",
|
||||
slug=state.slug, reason="graduation rollback",
|
||||
)
|
||||
return f"Closed PR #{state.new_pr_number}"
|
||||
|
||||
|
||||
# merge_pr's undo is intentionally absent — once the meta-repo merge has
|
||||
# landed, graduation is irreversible per §13.5. If we ever reach a merged
|
||||
# state and a later step fails (which can't happen — refresh_cache failures
|
||||
# fold into success), there is no clean undo path; the user transitions
|
||||
# via §3's `withdraw` instead.
|
||||
|
||||
_UNDO_BY_STEP = {
|
||||
"create_repo": _undo_create_repo,
|
||||
"seed_files": _undo_seed_files,
|
||||
"open_pr": _undo_open_pr,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Permission + audit helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _can_graduate(rfc, viewer) -> bool:
|
||||
if viewer is None:
|
||||
return False
|
||||
if viewer.role in ("owner", "admin"):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
return viewer.gitea_login in owners or viewer.gitea_login in arbiters
|
||||
|
||||
|
||||
def _audit(
|
||||
actor_user_id: int | None,
|
||||
on_behalf_of: str,
|
||||
action_kind: str,
|
||||
*,
|
||||
rfc_slug: str | None = None,
|
||||
branch_name: str | None = None,
|
||||
pr_number: int | None = None,
|
||||
details: dict | None = None,
|
||||
) -> None:
|
||||
"""Direct audit-log write for graduation lifecycle events that don't
|
||||
correspond to a single Gitea write. The per-step Gitea writes log
|
||||
themselves via the bot's `_log`; this is for the bracketing
|
||||
`graduate_start` / `graduate_complete` / `graduate_rollback` rows."""
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO actions
|
||||
(actor_user_id, on_behalf_of, action_kind, rfc_slug, branch_name, pr_number, bot_commit_sha, details)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NULL, ?)
|
||||
""",
|
||||
(
|
||||
actor_user_id,
|
||||
on_behalf_of,
|
||||
action_kind,
|
||||
rfc_slug,
|
||||
branch_name,
|
||||
pr_number,
|
||||
json.dumps(details) if details else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step state transitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _start(state: GraduationState, key: str, detail: str) -> None:
|
||||
step = next(s for s in state.steps if s.key == key)
|
||||
step.status = "running"
|
||||
step.detail = detail
|
||||
await state.queue.put({"event": "step", "payload": state.to_payload()})
|
||||
|
||||
|
||||
async def _done(state: GraduationState, key: str, detail: str) -> None:
|
||||
step = next(s for s in state.steps if s.key == key)
|
||||
step.status = "done"
|
||||
step.detail = detail
|
||||
await state.queue.put({"event": "step", "payload": state.to_payload()})
|
||||
|
||||
|
||||
async def _fail(state: GraduationState, key: str, detail: str) -> None:
|
||||
step = next(s for s in state.steps if s.key == key)
|
||||
step.status = "failed"
|
||||
step.detail = detail
|
||||
state.error = detail
|
||||
await state.queue.put({"event": "step", "payload": state.to_payload()})
|
||||
|
||||
|
||||
def _sse_event(name: str, payload: Any) -> str:
|
||||
return f"event: {name}\ndata: {json.dumps(payload)}\n\n"
|
||||
@@ -650,11 +650,15 @@ def make_router(
|
||||
# meta repo as pr_kind='meta_body_edit'; active RFC PRs live on
|
||||
# the per-RFC repo as 'rfc_branch'. The API surface and the §10
|
||||
# treatment are identical.
|
||||
# Slice 5: §13.1 claim PRs (pr_kind='meta_claim') are also
|
||||
# exposed through this surface — the merge path is the only
|
||||
# affordance an admin needs, and the §10 review machinery
|
||||
# gracefully degrades for frontmatter-only PRs.
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT * FROM cached_prs
|
||||
WHERE rfc_slug = ? AND pr_number = ?
|
||||
AND pr_kind IN ('rfc_branch', 'meta_body_edit')
|
||||
AND pr_kind IN ('rfc_branch', 'meta_body_edit', 'meta_claim')
|
||||
""",
|
||||
(slug, pr_number),
|
||||
).fetchone()
|
||||
|
||||
@@ -627,6 +627,314 @@ class Bot:
|
||||
)
|
||||
return sha
|
||||
|
||||
# ----- §13 graduation: per-step primitives and rollback inverses -----
|
||||
|
||||
async def create_rfc_repo_for_graduation(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
repo_name: str,
|
||||
slug: str,
|
||||
title: str,
|
||||
) -> dict:
|
||||
"""§13.3 step 1: create the per-RFC repo.
|
||||
|
||||
Empty repo (no auto-init) — `seed_graduated_rfc` writes the first
|
||||
commit on `main`. Returns the Gitea repo payload."""
|
||||
repo = await self._gitea.create_org_repo(
|
||||
org, repo_name, description=f"RFC: {title}"
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"graduate_repo_create",
|
||||
rfc_slug=slug,
|
||||
details={"repo": f"{org}/{repo_name}", "title": title},
|
||||
)
|
||||
return repo
|
||||
|
||||
async def seed_graduated_rfc(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
repo_name: str,
|
||||
slug: str,
|
||||
title: str,
|
||||
rfc_body: str,
|
||||
rfc_id: str,
|
||||
meta_full: str,
|
||||
meta_path: str,
|
||||
owners: list[str],
|
||||
arbiters: list[str],
|
||||
tags: list[str],
|
||||
) -> str:
|
||||
"""§13.3 step 2: seed RFC.md, README.md, .rfc/metadata.yaml on the
|
||||
new repo's `main`. Three create_file calls; one audit row.
|
||||
|
||||
Returns the final commit sha on main.
|
||||
"""
|
||||
import yaml as _yaml
|
||||
|
||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
||||
# 2a) RFC.md — the document. The super-draft's body is migrated
|
||||
# verbatim per §13.3; if the body is empty we seed a minimal
|
||||
# placeholder so the editor has something to render on first open.
|
||||
body = rfc_body.strip() + "\n" if rfc_body.strip() else (
|
||||
f"# {title}\n\n*RFC.md to be filled in — the super-draft graduated with an empty body.*\n"
|
||||
)
|
||||
rfc_msg = _stamp_single(f"Seed RFC.md from super-draft {slug}", actor)
|
||||
rfc_result = await self._gitea.create_file(
|
||||
org, repo_name, "RFC.md",
|
||||
content=body, message=rfc_msg, branch="main",
|
||||
author_name=actor.display_name, author_email=ae,
|
||||
)
|
||||
# 2b) README.md — header pointing back at the meta-repo entry.
|
||||
readme = (
|
||||
f"# {rfc_id} — {title}\n\n"
|
||||
f"This repository carries the canonical text of {rfc_id}.\n"
|
||||
f"The meta-repo entry is `{meta_path}` in `{meta_full}`.\n\n"
|
||||
f"The RFC body is in `RFC.md`. Contributions go through the\n"
|
||||
f"app's §8 RFC view — open a branch, propose changes, land a PR.\n"
|
||||
)
|
||||
readme_msg = _stamp_single(f"Seed README.md for {rfc_id}", actor)
|
||||
await self._gitea.create_file(
|
||||
org, repo_name, "README.md",
|
||||
content=readme, message=readme_msg, branch="main",
|
||||
author_name=actor.display_name, author_email=ae,
|
||||
)
|
||||
# 2c) .rfc/metadata.yaml — mirror of meta-repo frontmatter for
|
||||
# future tooling (linting, automation, CI lookups).
|
||||
meta_yaml = _yaml.safe_dump(
|
||||
{
|
||||
"slug": slug, "title": title, "id": rfc_id,
|
||||
"owners": owners, "arbiters": arbiters, "tags": list(tags),
|
||||
},
|
||||
sort_keys=False,
|
||||
)
|
||||
meta_msg = _stamp_single(f"Seed .rfc/metadata.yaml for {rfc_id}", actor)
|
||||
meta_result = await self._gitea.create_file(
|
||||
org, repo_name, ".rfc/metadata.yaml",
|
||||
content=meta_yaml, message=meta_msg, branch="main",
|
||||
author_name=actor.display_name, author_email=ae,
|
||||
)
|
||||
last_sha = (
|
||||
meta_result.get("commit", {}).get("sha")
|
||||
or rfc_result.get("commit", {}).get("sha")
|
||||
or ""
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"graduate_repo_seed",
|
||||
rfc_slug=slug,
|
||||
branch_name="main",
|
||||
bot_commit_sha=last_sha,
|
||||
details={"repo": f"{org}/{repo_name}", "rfc_id": rfc_id},
|
||||
)
|
||||
return last_sha
|
||||
|
||||
async def open_graduation_pr(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
meta_repo: str,
|
||||
slug: str,
|
||||
new_file_contents: str,
|
||||
prior_sha: str,
|
||||
rfc_id: str,
|
||||
repo_full: str,
|
||||
owners: list[str],
|
||||
) -> dict:
|
||||
"""§13.3 step 3: open a PR against the meta repo that strips the
|
||||
super-draft body and fills graduation frontmatter fields. Branch
|
||||
name uses the `graduate-<slug>-<6hex>` shape — dash-separated like
|
||||
the other meta-repo branches per the §19.2 path-routing candidate.
|
||||
"""
|
||||
import secrets
|
||||
|
||||
branch = f"graduate-{slug}-{secrets.token_hex(3)}"
|
||||
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
||||
commit_subject = f"Graduate {slug} → {rfc_id}"
|
||||
commit_message = _stamp_single(commit_subject, actor)
|
||||
result = await self._gitea.update_file(
|
||||
org, meta_repo, f"rfcs/{slug}.md",
|
||||
content=new_file_contents,
|
||||
sha=prior_sha,
|
||||
message=commit_message,
|
||||
branch=branch,
|
||||
author_name=actor.display_name, author_email=ae,
|
||||
)
|
||||
commit_sha = (
|
||||
result.get("commit", {}).get("sha")
|
||||
or result.get("content", {}).get("sha")
|
||||
or ""
|
||||
)
|
||||
pr_title = f"Graduate {slug} → {rfc_id}"
|
||||
owners_str = ", ".join(owners) if owners else "(none)"
|
||||
pr_body_text = (
|
||||
f"Graduates super-draft `{slug}` to active.\n\n"
|
||||
f"- ID: `{rfc_id}`\n"
|
||||
f"- Repo: `{repo_full}`\n"
|
||||
f"- Owners: {owners_str}\n\n"
|
||||
f"The meta-repo entry becomes frontmatter-only; the canonical body\n"
|
||||
f"moves to `RFC.md` in the new repo. The graduation sequence is\n"
|
||||
f"transactional per §13.3."
|
||||
)
|
||||
_subject, pr_body = _stamp("", pr_body_text, actor)
|
||||
pr = await self._gitea.create_pull(
|
||||
org, meta_repo,
|
||||
title=pr_title, body=pr_body, head=branch, base="main",
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"graduate_pr_open",
|
||||
rfc_slug=slug,
|
||||
branch_name=branch,
|
||||
pr_number=pr["number"],
|
||||
bot_commit_sha=commit_sha,
|
||||
details={"pr_title": pr_title, "rfc_id": rfc_id, "repo": repo_full},
|
||||
)
|
||||
return pr
|
||||
|
||||
async def merge_graduation_pr(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
meta_repo: str,
|
||||
pr_number: int,
|
||||
head_branch: str,
|
||||
slug: str,
|
||||
rfc_id: str,
|
||||
) -> None:
|
||||
"""§13.3 step 4: auto-merge the graduation PR with the admin as
|
||||
merge actor. Distinct action_kind so the audit log carries the
|
||||
graduation as a linkable sequence per §13.3's transactional shape."""
|
||||
subject = f"Graduate {slug} → {rfc_id}"
|
||||
body = _trailer(actor)
|
||||
await self._gitea.merge_pull(
|
||||
org, meta_repo, pr_number,
|
||||
merge_message_title=subject,
|
||||
merge_message_body=body,
|
||||
style="merge",
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"graduate_pr_merge",
|
||||
rfc_slug=slug,
|
||||
branch_name=head_branch,
|
||||
pr_number=pr_number,
|
||||
details={"rfc_id": rfc_id},
|
||||
)
|
||||
|
||||
# ----- §13.3 rollback inverses -----
|
||||
|
||||
async def delete_rfc_repo(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
repo_name: str,
|
||||
slug: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""Undo of `create_rfc_repo_for_graduation`. Records `graduate_repo_delete`
|
||||
in the audit log with the rollback reason so the §13.3 stack's
|
||||
rendered failure surface can be reconstructed from `actions`."""
|
||||
await self._gitea.delete_repo(org, repo_name)
|
||||
_log(
|
||||
actor,
|
||||
"graduate_repo_delete",
|
||||
rfc_slug=slug,
|
||||
details={"repo": f"{org}/{repo_name}", "reason": reason},
|
||||
)
|
||||
|
||||
async def close_graduation_pr(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
meta_repo: str,
|
||||
pr_number: int,
|
||||
head_branch: str,
|
||||
slug: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""Undo of `open_graduation_pr`. Closes the PR without merging; the
|
||||
branch is left in place to dodge the case where another graduation
|
||||
attempt runs immediately — it'll get its own `graduate-<slug>-<hex>`
|
||||
suffix."""
|
||||
await self._gitea.close_pull(org, meta_repo, pr_number)
|
||||
_log(
|
||||
actor,
|
||||
"graduate_pr_close",
|
||||
rfc_slug=slug,
|
||||
branch_name=head_branch,
|
||||
pr_number=pr_number,
|
||||
details={"reason": reason},
|
||||
)
|
||||
|
||||
# ----- §13.1 claim PRs -----
|
||||
|
||||
async def open_claim_pr(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
meta_repo: str,
|
||||
slug: str,
|
||||
new_file_contents: str,
|
||||
prior_sha: str,
|
||||
) -> dict:
|
||||
"""§13.1: open a PR adding the actor to the entry's `owners:` list.
|
||||
|
||||
Touches only the frontmatter of `rfcs/<slug>.md`. Branch shape is
|
||||
`claim/<slug>` — single attempt per super-draft per actor (Gitea
|
||||
refuses duplicate branch creation, which is the right behavior:
|
||||
if the claim is still open, point the contributor at the existing
|
||||
PR rather than opening a second one).
|
||||
"""
|
||||
branch = f"claim/{slug}"
|
||||
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
||||
commit_subject = f"Claim ownership of {slug} for {actor.gitea_login}"
|
||||
commit_message = _stamp_single(commit_subject, actor)
|
||||
result = await self._gitea.update_file(
|
||||
org, meta_repo, f"rfcs/{slug}.md",
|
||||
content=new_file_contents,
|
||||
sha=prior_sha,
|
||||
message=commit_message,
|
||||
branch=branch,
|
||||
author_name=actor.display_name, author_email=ae,
|
||||
)
|
||||
commit_sha = (
|
||||
result.get("commit", {}).get("sha")
|
||||
or result.get("content", {}).get("sha")
|
||||
or ""
|
||||
)
|
||||
pr_title = f"Claim ownership: {slug}"
|
||||
pr_description = (
|
||||
f"`{actor.gitea_login}` claims ownership of super-draft `{slug}`.\n\n"
|
||||
f"Per §13.1, owners and admins can merge."
|
||||
)
|
||||
_subject, pr_body = _stamp("", pr_description, actor)
|
||||
pr = await self._gitea.create_pull(
|
||||
org, meta_repo,
|
||||
title=pr_title, body=pr_body, head=branch, base="main",
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"open_claim_pr",
|
||||
rfc_slug=slug,
|
||||
branch_name=branch,
|
||||
pr_number=pr["number"],
|
||||
bot_commit_sha=commit_sha,
|
||||
details={"new_owner": actor.gitea_login},
|
||||
)
|
||||
return pr
|
||||
|
||||
# ----- Per-RFC repo: seeding (test/dev fixtures, future graduation) -----
|
||||
|
||||
async def ensure_rfc_repo_seed(
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
"""End-to-end integration tests for the Slice 5 vertical (§13 in full).
|
||||
|
||||
Walks the §13.3 transactional sequence end-to-end against the in-process
|
||||
FakeGitea from test_propose_vertical.py:
|
||||
|
||||
* Seed an owned super-draft (skipping the propose+merge + §13.1 claim
|
||||
round-trips already proven by Slice 1 and exercised in
|
||||
test_claim_opens_meta_pr below for the §13.1 surface itself).
|
||||
* GET /api/rfcs/<slug>/graduate/check returns per-field validity for
|
||||
the dialog.
|
||||
* GET /api/rfcs/<slug>/blocking-prs returns the §9.8 precondition list.
|
||||
* POST /api/rfcs/<slug>/graduate?_sync=1 runs the five-step sequence
|
||||
inline. On success: per-RFC repo exists with RFC.md / README.md /
|
||||
.rfc/metadata.yaml, meta-entry body is stripped, frontmatter is
|
||||
graduated, cached_rfcs.state is 'active'.
|
||||
* §9.8 precondition gate refuses the start when a body-edit PR is open.
|
||||
* Rollback on a mid-sequence failure unwinds repo creation cleanly.
|
||||
* §13.4 chat migration: whole-doc threads under (slug, 'main') survive
|
||||
graduation unchanged — the rfc_slug is the canonical key per §2.3,
|
||||
so no data movement is needed.
|
||||
* §9.8 pre-graduation history: the new RFC's /main response surfaces
|
||||
edit-branch threads under `pre_graduation_history`.
|
||||
|
||||
The orchestrator's `?_sync=1` seam awaits the sequence inline so the
|
||||
test can assert post-conditions on the same event loop tick. Production
|
||||
clients use the spec-described SSE shape via `/graduate/progress`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_super_draft_vertical import seed_super_draft # noqa: F401
|
||||
|
||||
|
||||
PITCH = (
|
||||
"Open Human Model is a framework for representing humans.\n\n"
|
||||
"It defines consent, trait, and agency in compatible terms."
|
||||
)
|
||||
|
||||
|
||||
def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str,
|
||||
owners: list[str], arbiters: list[str] | None = None,
|
||||
proposed_by: str = "alice", tags: list[str] | None = None) -> None:
|
||||
"""Seed a super-draft directly with owners already filled in — the
|
||||
§13.1 claim flow is exercised separately."""
|
||||
import yaml
|
||||
from app import db
|
||||
|
||||
fm = {
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
"state": "super-draft",
|
||||
"id": None,
|
||||
"repo": None,
|
||||
"proposed_by": proposed_by,
|
||||
"proposed_at": "2026-05-23",
|
||||
"graduated_at": None,
|
||||
"graduated_by": None,
|
||||
"owners": owners,
|
||||
"arbiters": arbiters or owners[:1],
|
||||
"tags": tags or [],
|
||||
}
|
||||
body = pitch.strip() + "\n"
|
||||
entry_text = f"---\n{yaml.safe_dump(fm, sort_keys=False).rstrip()}\n---\n\n{body}"
|
||||
sha = fake._next_sha()
|
||||
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {
|
||||
"content": entry_text, "sha": sha,
|
||||
}
|
||||
fake.branches[("wiggleverse", "meta")]["main"]["sha"] = sha
|
||||
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO cached_rfcs
|
||||
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
|
||||
owners_json, arbiters_json, tags_json,
|
||||
body, body_sha, last_main_commit_at, last_entry_commit_at)
|
||||
VALUES (?, ?, 'super-draft', NULL, NULL, ?, '2026-05-23',
|
||||
?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
""",
|
||||
(
|
||||
slug, title, proposed_by,
|
||||
_json.dumps(owners),
|
||||
_json.dumps(arbiters or owners[:1]),
|
||||
_json.dumps(tags or []),
|
||||
body, sha,
|
||||
),
|
||||
)
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO cached_branches
|
||||
(rfc_slug, branch_name, head_sha, state, last_commit_at)
|
||||
VALUES (?, 'main', ?, 'open', datetime('now'))
|
||||
""",
|
||||
(slug, sha),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_graduate_check_validates_three_fields(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="Open Human Model",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Happy: a fresh RFC-0001 + rfc-0001-ohm repo name.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["id"]["ok"] is True
|
||||
assert d["repo"]["ok"] is True
|
||||
assert d["owners"]["ok"] is True
|
||||
assert d["blocking_prs"]["ok"] is True
|
||||
assert d["can_submit"] is True
|
||||
|
||||
# ID format error — non-numeric tail.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-abcd", "repo": "rfc-0001-ohm"})
|
||||
d = r.json()
|
||||
assert d["id"]["ok"] is False
|
||||
assert d["can_submit"] is False
|
||||
|
||||
# Repo name pattern error — leading dot.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-0001", "repo": ".bad"})
|
||||
d = r.json()
|
||||
assert d["repo"]["ok"] is False
|
||||
|
||||
|
||||
def test_graduate_check_refuses_when_no_owners(app_with_fake_gitea):
|
||||
"""An unclaimed super-draft fails the owners precondition; can_submit
|
||||
flips false even with valid id+repo."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
# No owners — simulates an unclaimed super-draft.
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=[])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
|
||||
d = r.json()
|
||||
assert d["owners"]["ok"] is False
|
||||
assert "No owners" in d["owners"]["error"]
|
||||
assert d["can_submit"] is False
|
||||
|
||||
|
||||
def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea):
|
||||
"""The full §13.3 sequence: create repo, seed files, open PR, merge
|
||||
PR, refresh cache. End state: cached_rfcs.state='active', the meta
|
||||
entry's body is stripped, the per-RFC repo has RFC.md, the audit
|
||||
log carries graduate_start → graduate_complete bracketing the
|
||||
per-step rows."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, entry as entry_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="Open Human Model",
|
||||
pitch=PITCH, owners=["ben"], arbiters=["ben"],
|
||||
tags=["identity", "schema"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner", email="ben@test")
|
||||
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0042", "repo_name": "rfc-0042-ohm",
|
||||
"owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["finished"] is True
|
||||
assert d["succeeded"] is True
|
||||
assert d["repo"] == "wiggleverse/rfc-0042-ohm"
|
||||
|
||||
# 1. Per-RFC repo exists on Gitea.
|
||||
assert ("wiggleverse", "rfc-0042-ohm") in fake.repos
|
||||
# 2. Seed files landed on main.
|
||||
assert ("wiggleverse", "rfc-0042-ohm", "main", "RFC.md") in fake.files
|
||||
assert ("wiggleverse", "rfc-0042-ohm", "main", "README.md") in fake.files
|
||||
assert ("wiggleverse", "rfc-0042-ohm", "main", ".rfc/metadata.yaml") in fake.files
|
||||
rfc_md = fake.files[("wiggleverse", "rfc-0042-ohm", "main", "RFC.md")]["content"]
|
||||
assert "Open Human Model is a framework" in rfc_md
|
||||
# 3. Meta entry body is stripped + frontmatter graduated.
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
graduated = entry_mod.parse(meta_text)
|
||||
assert graduated.state == "active"
|
||||
assert graduated.id == "RFC-0042"
|
||||
assert graduated.repo == "wiggleverse/rfc-0042-ohm"
|
||||
assert graduated.graduated_by == "ben"
|
||||
assert graduated.graduated_at # non-empty ISO date
|
||||
assert graduated.body.strip() == ""
|
||||
# 5. cached_rfcs.state flipped to active via the inline refresh.
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id, repo, body FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "active"
|
||||
assert cached["rfc_id"] == "RFC-0042"
|
||||
assert cached["repo"] == "wiggleverse/rfc-0042-ohm"
|
||||
# cached body now mirrors RFC.md from the per-RFC repo.
|
||||
assert "Open Human Model is a framework" in cached["body"]
|
||||
|
||||
# Audit log: graduate_start, graduate_repo_create, graduate_repo_seed,
|
||||
# graduate_pr_open, graduate_pr_merge, graduate_complete, in order.
|
||||
kinds = [
|
||||
r["action_kind"]
|
||||
for r in db.conn().execute(
|
||||
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
|
||||
)
|
||||
]
|
||||
for needed in ("graduate_start", "graduate_repo_create",
|
||||
"graduate_repo_seed", "graduate_pr_open",
|
||||
"graduate_pr_merge", "graduate_complete"):
|
||||
assert needed in kinds, f"missing audit row {needed}: {kinds}"
|
||||
|
||||
|
||||
def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
|
||||
"""§9.8: an open meta-repo body-edit PR against rfcs/<slug>.md blocks
|
||||
graduation before the bot starts the sequence — §13.3's rollback
|
||||
complexity does not grow."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
|
||||
# Cut an edit branch and open a body-edit PR (full Slice 4 path).
|
||||
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
|
||||
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
|
||||
thread_id = view["main_thread_id"]
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO changes
|
||||
(rfc_slug, branch_name, thread_id, kind, state, original, proposed, reason)
|
||||
VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, 'tighten')
|
||||
""",
|
||||
(branch, thread_id,
|
||||
"It defines consent, trait, and agency in compatible terms.",
|
||||
"It defines consent, trait, harm, and agency in compatible terms."),
|
||||
)
|
||||
change_id = cur.lastrowid
|
||||
client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
|
||||
json={"proposed": "It defines consent, trait, harm, and agency in compatible terms.",
|
||||
"was_edited_before_accept": False},
|
||||
)
|
||||
pr_number = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/open-pr",
|
||||
json={"title": "Add harm", "description": "Adds harm dimension."},
|
||||
).json()["pr_number"]
|
||||
|
||||
# /blocking-prs surfaces it.
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/rfcs/ohm/blocking-prs")
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["pr_number"] == pr_number
|
||||
|
||||
# /check refuses can_submit.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
|
||||
d = r.json()
|
||||
assert d["blocking_prs"]["ok"] is False
|
||||
assert d["can_submit"] is False
|
||||
|
||||
# POST refuses with 409 — the bot never starts the sequence.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
|
||||
"owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
assert "blocking graduation" in r.text or "block" in r.text
|
||||
|
||||
|
||||
def test_graduate_rollback_on_step_2_seed_failure(app_with_fake_gitea):
|
||||
"""Step 2 (seed files) fails partway → the orchestrator rolls back
|
||||
step 1 (delete the repo) and records the rollback in the audit log.
|
||||
The cached_rfcs row stays at 'super-draft'."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
from app.bot import Bot
|
||||
from app.gitea import Gitea, GiteaError
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Monkey-patch the bot to fail on seed_graduated_rfc. The repo
|
||||
# has already been created in step 1; the rollback must delete it.
|
||||
orig_seed = Bot.seed_graduated_rfc
|
||||
async def boom(self, *args, **kwargs):
|
||||
raise GiteaError(500, "simulated seed failure for rollback test")
|
||||
Bot.seed_graduated_rfc = boom
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0003", "repo_name": "rfc-0003-ohm",
|
||||
"owners": ["ben"]},
|
||||
)
|
||||
finally:
|
||||
Bot.seed_graduated_rfc = orig_seed
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["finished"] is True
|
||||
assert d["succeeded"] is False
|
||||
|
||||
# Repo deleted as the rollback inverse.
|
||||
assert ("wiggleverse", "rfc-0003-ohm") not in fake.repos
|
||||
# Meta entry unchanged.
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "super-draft"
|
||||
assert cached["rfc_id"] is None
|
||||
# Audit log carries the rollback row.
|
||||
kinds = [
|
||||
r["action_kind"]
|
||||
for r in db.conn().execute(
|
||||
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
|
||||
)
|
||||
]
|
||||
assert "graduate_start" in kinds
|
||||
assert "graduate_repo_create" in kinds
|
||||
assert "graduate_repo_delete" in kinds
|
||||
assert "graduate_rollback" in kinds
|
||||
assert "graduate_complete" not in kinds
|
||||
|
||||
|
||||
def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
|
||||
"""Step 3 (open PR) fails → the orchestrator rolls back steps 2 and
|
||||
1 (deleting the repo, which reclaims the seed commits at the same
|
||||
time). The meta-repo entry is untouched."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
from app.bot import Bot
|
||||
from app.gitea import GiteaError
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
orig_open_pr = Bot.open_graduation_pr
|
||||
async def boom(self, *args, **kwargs):
|
||||
raise GiteaError(502, "simulated PR-open failure")
|
||||
Bot.open_graduation_pr = boom
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0007", "repo_name": "rfc-0007-ohm",
|
||||
"owners": ["ben"]},
|
||||
)
|
||||
finally:
|
||||
Bot.open_graduation_pr = orig_open_pr
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["succeeded"] is False
|
||||
# Repo torn down.
|
||||
assert ("wiggleverse", "rfc-0007-ohm") not in fake.repos
|
||||
# Meta entry's body still has the pitch (not stripped).
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
assert "Open Human Model is a framework" in meta_text
|
||||
|
||||
|
||||
def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
|
||||
"""A second graduation request for a slug already in-flight is refused."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import api_graduation
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Seed a synthetic in-flight state so the registry refuses the second.
|
||||
st = api_graduation._new_active(
|
||||
"ohm", rfc_id="RFC-0001", repo_name="rfc-0001-ohm",
|
||||
repo_full="wiggleverse/rfc-0001-ohm", owners=["ben"], arbiters=["ben"],
|
||||
)
|
||||
st.finished = False
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
|
||||
"owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
finally:
|
||||
api_graduation._active.pop("ohm", None)
|
||||
|
||||
|
||||
def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_gitea):
|
||||
"""§13.4: chat threads on the super-draft's canonical-body view
|
||||
(`branch_name='main'`) are interpreted as the new RFC's main-thread
|
||||
after graduation. The rows don't move — the rfc_slug is canonical
|
||||
per §2.3 — so the same thread surfaces from both before and after
|
||||
the graduation."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Materialize a whole-doc main thread + a message on it. This
|
||||
# mirrors what reading the canonical-body view would create
|
||||
# lazily (§8.12 / api_branches._ensure_branch_chat_thread).
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, created_by)
|
||||
VALUES ('ohm', 'main', 'whole-doc', 'chat', 1)
|
||||
"""
|
||||
)
|
||||
thread_id = cur.lastrowid
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO thread_messages (thread_id, role, author_user_id, text)
|
||||
VALUES (?, 'user', 1, 'pre-grad note on the canonical body')
|
||||
""",
|
||||
(thread_id,),
|
||||
)
|
||||
|
||||
# Graduate.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
|
||||
"owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# The thread row's identity is unchanged.
|
||||
row = db.conn().execute(
|
||||
"SELECT id, branch_name FROM threads WHERE id = ?", (thread_id,),
|
||||
).fetchone()
|
||||
assert row["branch_name"] == "main"
|
||||
# The new RFC's main view surfaces the same thread id as its
|
||||
# whole-doc main thread (the entry is now active, the branch
|
||||
# 'main' now points at the per-RFC repo's main, but the
|
||||
# `(rfc_slug, branch_name)` key remains the canonical anchor).
|
||||
r = client.get("/api/rfcs/ohm/branches/main")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["main_thread_id"] == thread_id
|
||||
|
||||
|
||||
def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea):
|
||||
"""§9.8: after graduation, threads on meta-repo edit branches stay
|
||||
attached to their original branch_name and surface from the new
|
||||
RFC's /main response under `pre_graduation_history`."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
|
||||
# Alice cuts an edit branch and starts chatting on it.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
|
||||
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
|
||||
thread_id = view["main_thread_id"]
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO thread_messages (thread_id, role, author_user_id, text)
|
||||
VALUES (?, 'user', 2, 'pre-graduation note on an edit branch')
|
||||
""",
|
||||
(thread_id,),
|
||||
)
|
||||
|
||||
# Ben graduates.
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0100", "repo_name": "rfc-0100-ohm",
|
||||
"owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# /main on the now-active RFC surfaces the pre-graduation history.
|
||||
r = client.get("/api/rfcs/ohm/main")
|
||||
d = r.json()
|
||||
assert d["state"] == "active"
|
||||
hist = d["pre_graduation_history"]
|
||||
assert len(hist) >= 1
|
||||
assert any(h["branch_name"] == branch for h in hist)
|
||||
target = next(h for h in hist if h["branch_name"] == branch)
|
||||
assert target["message_count"] >= 1
|
||||
|
||||
|
||||
def test_claim_opens_meta_pr(app_with_fake_gitea):
|
||||
"""§13.1: any signed-in contributor can claim ownership of an
|
||||
unclaimed super-draft; the result is a meta-repo PR
|
||||
(`pr_kind='meta_claim'`) adding their gitea_login to the entry's
|
||||
owners list."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, entry as entry_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=[])
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
|
||||
r = client.post("/api/rfcs/ohm/claim")
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["branch_name"] == "claim/ohm"
|
||||
|
||||
# The PR body's diff carries Alice in owners.
|
||||
text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"]
|
||||
ent = entry_mod.parse(text)
|
||||
assert "alice" in ent.owners
|
||||
|
||||
# cached_prs records pr_kind='meta_claim' via refresh_meta_pulls.
|
||||
row = db.conn().execute(
|
||||
"SELECT pr_kind FROM cached_prs WHERE pr_number = ?", (d["pr_number"],),
|
||||
).fetchone()
|
||||
assert row["pr_kind"] == "meta_claim"
|
||||
@@ -128,6 +128,18 @@ class FakeGitea:
|
||||
return httpx.Response(200, json={"name": repo, "full_name": f"{owner}/{repo}"})
|
||||
return httpx.Response(404, json={"message": "not found"})
|
||||
|
||||
# DELETE /repos/{owner}/{repo} — Slice 5 graduation rollback uses
|
||||
# this to undo step 1 (repo create). The FakeGitea drops every
|
||||
# file, branch, and PR tied to the repo so a subsequent retry
|
||||
# graduation can re-create the repo cleanly.
|
||||
if method == "DELETE" and m_repo:
|
||||
owner, repo = m_repo.groups()
|
||||
self.repos.discard((owner, repo))
|
||||
self.branches.pop((owner, repo), None)
|
||||
self.pulls.pop((owner, repo), None)
|
||||
self.files = {k: v for k, v in self.files.items() if (k[0], k[1]) != (owner, repo)}
|
||||
return httpx.Response(204, json={})
|
||||
|
||||
# POST /orgs/{org}/repos
|
||||
m = re.fullmatch(r"/orgs/([^/]+)/repos", path)
|
||||
if method == "POST" and m:
|
||||
|
||||
Reference in New Issue
Block a user