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:
+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")
|
||||
|
||||
Reference in New Issue
Block a user