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:
+100
-31
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user