Slice 4: super-draft body editing per §9.5 + §9.6

The §17 routing-collapse rule lands in api_branches.py and
api_prs.py — every branches/<branch>/... and prs/<n>/... route
dispatches on the entry's state to pick the right Gitea repo, and
the body extracted from the entry's frontmatter envelope is what
the editor and the diff see. The bot grows open_metadata_pr;
cache grows refresh_meta_branches. Two §17 routes added:
start-edit-branch and metadata. The §9.4 super-draft view replaces
RFCView.jsx's Slice 2 placeholder; a metadata pane modal opens
from the breadcrumb. Branch naming uses edit-<slug>-<6hex> to
dodge the §19.2 path-routing candidate while preserving §9.5's
structural shape.

Covered by tests/test_super_draft_vertical.py (10 tests). The
full Slices 1-4 suite is 35/35 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 15:43:21 -07:00
parent a2bf89e90b
commit 4565a6cb95
10 changed files with 1558 additions and 344 deletions
+100 -31
View File
@@ -23,7 +23,7 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import auth, cache, chat as chat_layer, db
from . import auth, cache, chat as chat_layer, db, entry as entry_mod
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
@@ -82,19 +82,20 @@ def make_router(
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
owner, repo = _owner_repo(rfc)
path = _file_path_for(rfc)
if not _branch_has_commits_ahead(slug, branch):
raise HTTPException(409, "Branch has no commits ahead of main")
main_body = (await gitea.read_file(owner, repo, RFC_FILE_PATH, ref="main"))
branch_body = (await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=branch))
if not branch_body:
raise HTTPException(404, "Branch RFC.md not found")
main_fetched = await gitea.read_file(owner, repo, path, ref="main")
branch_fetched = await gitea.read_file(owner, repo, path, ref=branch)
if not branch_fetched:
raise HTTPException(404, f"Branch {path} not found")
chat_messages = _branch_chat_excerpt(slug, branch)
title, description = _draft_with_provider(
providers=providers,
default_model=default_model,
rfc_title=rfc["title"],
main_body=(main_body or ("", ""))[0],
branch_body=branch_body[0],
main_body=_extract_body(rfc, (main_fetched or ("", ""))[0]),
branch_body=_extract_body(rfc, branch_fetched[0]),
chat_messages=chat_messages,
)
_ = viewer # silence unused
@@ -158,7 +159,7 @@ def make_router(
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
await cache.refresh_rfc_repo(config, gitea, slug)
await _refresh_after_pr_write(rfc)
return {"pr_number": pr["number"], "slug": slug, "branch": branch}
# -------------------------------------------------------------------
@@ -171,17 +172,19 @@ def make_router(
rfc = _require_active_rfc(slug)
pr_row = _require_pr(slug, pr_number)
owner, repo = _owner_repo(rfc)
path = _file_path_for(rfc)
head_branch = pr_row["head_branch"]
# §11.3: PRs are always public; no visibility check.
main_body, _main_sha = (await gitea.read_file(owner, repo, RFC_FILE_PATH, ref="main")) or ("", "")
main_fetched = await gitea.read_file(owner, repo, path, ref="main")
main_body = _extract_body(rfc, (main_fetched or ("", ""))[0])
merge_sha = pr_row["merge_commit_sha"] if "merge_commit_sha" in pr_row.keys() else None
branch_ref = merge_sha if pr_row["state"] == "merged" and merge_sha else head_branch
branch_fetched = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=branch_ref) if branch_ref else None
branch_fetched = await gitea.read_file(owner, repo, path, ref=branch_ref) if branch_ref else None
if branch_fetched is None:
# Fall back to head_branch if the merge commit is gone.
branch_fetched = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=head_branch) or ("", "")
branch_body, _branch_sha = branch_fetched
branch_fetched = await gitea.read_file(owner, repo, path, ref=head_branch) or ("", "")
branch_body = _extract_body(rfc, branch_fetched[0])
# Threads + messages — the branch chat is the PR's conversation
# surface per §10.4. Both `chat`/`flag` and `review` kinds
@@ -249,7 +252,7 @@ def make_router(
if live is not None:
mergeable = bool(live.get("mergeable"))
if not mergeable:
conflict_files = [RFC_FILE_PATH]
conflict_files = [path]
# Aggregate counts the header strip surfaces per §10.3.
open_review = sum(1 for t in threads if t["thread_kind"] == "review" and t["state"] == "open")
@@ -407,7 +410,7 @@ def make_router(
if e.status == 409:
raise HTTPException(409, "Merge conflict with main — use Start resolution branch")
raise HTTPException(502, f"Gitea: {e.detail}")
await cache.refresh_rfc_repo(config, gitea, slug)
await _refresh_after_pr_write(rfc)
return {"ok": True, "pr_number": pr_number}
# -------------------------------------------------------------------
@@ -436,7 +439,7 @@ def make_router(
)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
await cache.refresh_rfc_repo(config, gitea, slug)
await _refresh_after_pr_write(rfc)
return {"ok": True, "pr_number": pr_number}
# -------------------------------------------------------------------
@@ -540,6 +543,8 @@ def make_router(
owner=owner,
repo=repo,
slug=slug,
file_path=_file_path_for(rfc),
is_super_draft=_is_super_draft(rfc),
original_branch=original_branch,
resolution_branch=resolution_branch,
)
@@ -585,7 +590,7 @@ def make_router(
(slug, resolution_branch, new_thread_id, ch["original"], ch["proposed"], ch["reason"]),
)
await cache.refresh_rfc_repo(config, gitea, slug)
await _refresh_after_pr_write(rfc)
return {
"ok": True,
"resolution_branch": resolution_branch,
@@ -597,25 +602,59 @@ def make_router(
# Helpers (closures over config/gitea/etc.)
# ------------------------------------------------------------------
def _require_active_rfc(slug: str):
def _require_rfc(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"] != "active":
raise HTTPException(409, f"RFC is {row['state']}, not active")
if not row["repo"]:
return row
def _require_active_rfc(slug: str):
"""Used by the §10 PR-flow read and write paths. Per §17's routing-
collapse rule, a super-draft RFC also routes here — its body-edit
PRs are meta-repo PRs with pr_kind='meta_body_edit', but the API
surface is identical."""
row = _require_rfc(slug)
if row["state"] not in ("active", "super-draft"):
raise HTTPException(409, f"RFC is {row['state']}")
if row["state"] == "active" and not row["repo"]:
raise HTTPException(409, "RFC has no repo")
return row
def _is_super_draft(rfc) -> bool:
return rfc["state"] == "super-draft"
def _owner_repo(rfc) -> tuple[str, str]:
if _is_super_draft(rfc):
return config.gitea_org, config.meta_repo
owner, repo = rfc["repo"].split("/", 1)
return owner, repo
def _file_path_for(rfc) -> str:
if _is_super_draft(rfc):
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."""
if not _is_super_draft(rfc):
return file_contents
try:
entry = entry_mod.parse(file_contents)
except Exception:
return file_contents
return entry.body
def _require_pr(slug: str, pr_number: int):
# Dispatch by RFC state: super-draft body-edit PRs live on the
# 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.
row = db.conn().execute(
"""
SELECT * FROM cached_prs
WHERE rfc_slug = ? AND pr_number = ? AND pr_kind = 'rfc_branch'
WHERE rfc_slug = ? AND pr_number = ?
AND pr_kind IN ('rfc_branch', 'meta_body_edit')
""",
(slug, pr_number),
).fetchone()
@@ -626,9 +665,8 @@ def make_router(
def _branch_has_commits_ahead(slug: str, branch: str) -> bool:
"""Cheap heuristic: the cache records main + branch head shas,
which mismatch when the branch has any commit not on main. The
reconciler keeps these honest; an out-of-date cache here can
only false-negative, which the spec is fine with (the merge
attempt would fail at the bot wrapper instead)."""
meta-repo branch refresh (cache.refresh_meta_branches) synthesizes
a per-slug 'main' row for super-drafts so this works uniformly."""
row = db.conn().execute(
"""
SELECT b.head_sha AS branch_sha,
@@ -653,6 +691,14 @@ def make_router(
).fetchone()
return row["original_pr_number"] if row else None
async def _refresh_after_pr_write(rfc) -> None:
if _is_super_draft(rfc):
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
else:
await cache.refresh_rfc_repo(config, gitea, rfc["slug"])
return router
@@ -811,14 +857,18 @@ async def _replay_changes(
owner: str,
repo: str,
slug: str,
file_path: str,
is_super_draft: bool,
original_branch: str,
resolution_branch: str,
) -> tuple[list[dict], list[dict]]:
"""Walk the original branch's accepted AI-kind changes in
creation order and try to apply each to the resolution branch.
"""Walk the original branch's accepted AI-kind changes in creation
order and try to apply each to the resolution branch.
Returns (unambiguous_changes_applied, ambiguous_changes_skipped).
Each list element carries `original`, `proposed`, `reason`.
For super-draft body edits the file is rfcs/<slug>.md and the body
lives inside the frontmatter envelope — extract the body for the
`original`-text match and re-wrap before committing.
"""
rows = db.conn().execute(
"""
@@ -832,24 +882,26 @@ async def _replay_changes(
unambiguous: list[dict] = []
ambiguous: list[dict] = []
for r in rows:
fetched = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=resolution_branch)
fetched = await gitea.read_file(owner, repo, file_path, ref=resolution_branch)
if fetched is None:
ambiguous.append({"original": r["original"], "proposed": r["proposed"], "reason": r["reason"] or ""})
continue
current_body, current_sha = fetched
current_content, current_sha = fetched
current_body = _extract_body_for_replay(is_super_draft, current_content)
original_text = r["original"] or ""
if not original_text or current_body.count(original_text) != 1:
ambiguous.append({"original": r["original"], "proposed": r["proposed"], "reason": r["reason"] or ""})
continue
new_body = current_body.replace(original_text, r["proposed"], 1)
new_content = _wrap_body_for_replay(is_super_draft, current_content, new_body)
try:
await bot.commit_replay_change(
actor,
owner=owner,
repo=repo,
branch=resolution_branch,
file_path=RFC_FILE_PATH,
new_content=new_body,
file_path=file_path,
new_content=new_content,
prior_sha=current_sha,
original_change_id=r["id"],
original=r["original"] or "",
@@ -864,6 +916,23 @@ async def _replay_changes(
return unambiguous, ambiguous
def _extract_body_for_replay(is_super_draft: bool, content: str) -> str:
if not is_super_draft:
return content
try:
return entry_mod.parse(content).body
except Exception:
return content
def _wrap_body_for_replay(is_super_draft: bool, prior_content: str, new_body: str) -> str:
if not is_super_draft:
return new_body
entry = entry_mod.parse(prior_content)
entry.body = new_body if new_body.endswith("\n") else new_body + "\n"
return entry_mod.serialize(entry)
def _resolution_branch_name(original_branch: str) -> str:
"""Per §10.9: a fresh branch name derived from the original.