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
+352 -142
View File
@@ -1,14 +1,21 @@
"""Slice 2 API surface — the §8 active-RFC view's endpoints.
"""Slice 2 + Slice 4 API surface — the §8 active-RFC view and the §9.4
super-draft view share the same endpoint shape per §17's routing-collapse
rule. When `<slug>` resolves to an entry in state `super-draft`,
`<branch>` names a branch on the meta repo rather than on a per-RFC-repo
branch (§5 super-draft scoping note, §9.5). The dispatch happens here at
the API layer; the bot wrapper, the cache, and the chat layer all stay
state-agnostic — they take owner/repo/path arguments.
Owns every `branches/<branch>/...` and `threads/<thread_id>/...` route
from §17. Read paths fetch branch bodies live from Gitea (§4 #3
exempts branch bodies from the cache); write paths funnel through
`bot.py` so the §1 chokepoint and the §6.5 trailer hold.
from §17. Read paths fetch branch bodies live from Gitea (§4 #3 exempts
branch bodies from the cache); write paths funnel through `bot.py` so
the §1 chokepoint and the §6.5 trailer hold. The two §17 routes Slice 4
adds — `start-edit-branch` and `metadata` — live here too because they
are super-draft variants of the same machinery.
Visibility and contribute decisions are enforced inline here against
the §6 four-role model plus the §11 per-branch visibility/contribute
rules; the app's permission model is canonical, and Gitea sees only
the bot.
Visibility and contribute decisions are enforced inline here against the
§6 four-role model plus the §11 per-branch visibility/contribute rules;
the app's permission model is canonical, and Gitea sees only the bot.
"""
from __future__ import annotations
@@ -22,7 +29,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
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
@@ -42,6 +49,16 @@ class PromoteToBranchBody(BaseModel):
branch_name: str | None = Field(default=None, max_length=120)
class StartEditBranchBody(BaseModel):
branch_name: str | None = Field(default=None, max_length=120)
class MetadataEditBody(BaseModel):
title: str | None = Field(default=None, max_length=200)
tags: list[str] | None = None
pr_description: str | None = Field(default=None, max_length=4000)
class AcceptChangeBody(BaseModel):
proposed: str = Field(min_length=0)
was_edited_before_accept: bool = False
@@ -113,38 +130,64 @@ def make_router(
# -------------------------------------------------------------------
# §17: GET /api/rfcs/<slug>/main
# Body + branches + open PRs for the breadcrumb dropdown.
# For active RFCs: body, branches, open PRs on the per-RFC repo.
# For super-drafts: canonical body (entry.body on meta-repo main),
# open edit branches, open meta-repo body-edit and metadata PRs.
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/main")
async def get_rfc_main(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_active_rfc(slug)
rfc = _require_rfc(slug)
if rfc["state"] not in ("active", "super-draft"):
raise HTTPException(409, f"RFC is {rfc['state']}")
# Branches the viewer can read per §11.1.
branch_rows = db.conn().execute(
"""
SELECT branch_name, head_sha, state, last_commit_at, pinned
FROM cached_branches
WHERE rfc_slug = ? AND state != 'deleted'
ORDER BY last_commit_at DESC NULLS LAST
""",
(slug,),
).fetchall()
# Branches the viewer can read per §11.1. For active RFCs the
# per-RFC repo's main is included so the §8.1 breadcrumb dropdown
# can render it; for super-drafts the synthetic 'main' row that
# `refresh_meta_branches` writes is internal scaffolding for the
# §10.1 has-commits-ahead check — the §9.4 dropdown's first
# position is rendered separately as 'canonical body'.
if _is_super_draft(rfc):
branch_rows = db.conn().execute(
"""
SELECT branch_name, head_sha, state, last_commit_at, pinned
FROM cached_branches
WHERE rfc_slug = ? AND state != 'deleted' AND branch_name != 'main'
ORDER BY last_commit_at DESC NULLS LAST
""",
(slug,),
).fetchall()
else:
branch_rows = db.conn().execute(
"""
SELECT branch_name, head_sha, state, last_commit_at, pinned
FROM cached_branches
WHERE rfc_slug = ? AND state != 'deleted'
ORDER BY last_commit_at DESC NULLS LAST
""",
(slug,),
).fetchall()
branches = [
_branch_summary(slug, br, viewer)
for br in branch_rows
if _can_read_branch(slug, br["branch_name"], viewer)
]
# Open PRs surfaced inline. For active: rfc_branch PRs on the
# per-RFC repo. For super-draft: meta_body_edit and meta_metadata
# PRs on the meta repo. Same shape either way — the §9.4 dropdown
# treats both as "open work against this entry."
pr_kinds = ("meta_body_edit", "meta_metadata") if _is_super_draft(rfc) else ("rfc_branch",)
placeholders = ",".join("?" * len(pr_kinds))
pr_rows = db.conn().execute(
"""
SELECT pr_number, title, state, head_branch, opened_by, opened_at
f"""
SELECT pr_number, title, state, head_branch, opened_by, opened_at, pr_kind
FROM cached_prs
WHERE rfc_slug = ? AND pr_kind = 'rfc_branch' AND state = 'open'
WHERE rfc_slug = ? AND state = 'open' AND pr_kind IN ({placeholders})
ORDER BY opened_at DESC
""",
(slug,),
(slug, *pr_kinds),
).fetchall()
prs = [
{
@@ -154,15 +197,20 @@ def make_router(
"head_branch": r["head_branch"],
"opened_by": r["opened_by"],
"opened_at": r["opened_at"],
"pr_kind": r["pr_kind"],
}
for r in pr_rows
]
# For super-drafts the cached body is entry.body already (see
# cache._upsert_cached_rfc), so no extraction is needed.
return {
"slug": slug,
"title": rfc["title"],
"state": rfc["state"],
"id": rfc["rfc_id"],
"repo": rfc["repo"],
"tags": json.loads(rfc["tags_json"] or "[]"),
"body": rfc["body"] or "",
"body_sha": rfc["body_sha"],
"branches": branches,
@@ -172,29 +220,29 @@ def make_router(
# -------------------------------------------------------------------
# §17: GET /api/rfcs/<slug>/branches/<branch>
# Per §4: branch bodies are NOT cached — fetch live from Gitea.
# Per §9.5 / §17: when slug resolves to super-draft, <branch> names
# a meta-repo branch and the underlying file is rfcs/<slug>.md with
# the body wrapped in frontmatter.
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/branches/{branch}")
async def get_branch_view(slug: str, branch: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_active_rfc(slug)
rfc = _require_rfc_with_repo(slug)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
owner, repo = _owner_repo(rfc)
# Ensure branch exists in cache so freshness measures match
# reality; the read path is read-only so a missing row is a
# cue to refresh, not an error.
result = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=branch)
owner, repo = _repo_for(rfc)
path = _file_path_for(rfc)
result = await gitea.read_file(owner, repo, path, ref=branch)
if result is None:
# The branch might exist but be empty; check the branch
# itself before deciding whether this is 404 or 200-with-empty.
br = await gitea.get_branch(owner, repo, branch)
if br is None:
raise HTTPException(404, "Branch not found")
body, body_sha = "", ""
else:
body, body_sha = result
content, body_sha = result
body = _extract_body(rfc, content)
# Ensure the whole-doc chat thread for the branch exists.
thread_id = _ensure_branch_chat_thread(slug, branch, viewer)
@@ -249,14 +297,15 @@ def make_router(
# -------------------------------------------------------------------
# §17: POST /api/rfcs/<slug>/branches/main/promote-to-branch
# The §8.14 "Start Contributing on main" gesture.
# The §8.14 "Start Contributing on main" gesture for active RFCs.
# Super-drafts use start-edit-branch below, per §9.5.
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/branches/main/promote-to-branch")
async def promote_to_branch(slug: str, body: PromoteToBranchBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
owner, repo = _owner_repo(rfc)
owner, repo = _repo_for(rfc)
new_branch = (body.branch_name or "").strip()
if not new_branch:
new_branch = _auto_branch_name(viewer.gitea_login)
@@ -298,6 +347,120 @@ def make_router(
return {"branch_name": new_branch, "slug": slug}
# -------------------------------------------------------------------
# §17 / §9.5: POST /api/rfcs/<slug>/start-edit-branch
# The "Start Contributing" gesture on a super-draft — cuts a fresh
# meta-repo branch the contributor will land body edits on.
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/start-edit-branch")
async def start_edit_branch(slug: str, body: StartEditBranchBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug)
owner, repo = _repo_for(rfc)
new_branch = (body.branch_name or "").strip()
if not new_branch:
new_branch = _auto_edit_branch_name(slug)
else:
_validate_branch_name(new_branch)
try:
await bot.cut_branch_from_main(
viewer.as_actor(),
owner=owner,
repo=repo,
new_branch=new_branch,
slug=slug,
)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
# §9.6: re-anchor any pending main-scoped (super-draft canonical
# body) changes onto the new edit branch, mirroring §8.14's
# treatment for active RFCs.
db.conn().execute(
"""
UPDATE changes
SET branch_name = ?
WHERE rfc_slug = ? AND branch_name = 'main' AND state = 'pending'
""",
(new_branch, slug),
)
_ensure_branch_vis(slug, new_branch, creator_user_id=viewer.user_id)
await cache.refresh_meta_branches(config, gitea)
return {"branch_name": new_branch, "slug": slug}
# -------------------------------------------------------------------
# §17 / §9.5: POST /api/rfcs/<slug>/metadata
# Title or tag edits on a super-draft — opens a tiny meta-repo PR
# touching only the frontmatter of rfcs/<slug>.md. Slug renames are
# not supported in v1 per §9.5 and the §19.2 candidate.
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/metadata")
async def edit_metadata(slug: str, body: MetadataEditBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug)
# Permission: super-draft owners/arbiters per §6.3, plus app-wide
# admins/owners per §6.1. Until claim, that collapses to admin/owner.
if not _can_edit_metadata(rfc, viewer):
raise HTTPException(403, "Only RFC owners/arbiters or app admins/owners may edit metadata")
new_title = (body.title or "").strip() or None
new_tags = body.tags
if new_title is None and new_tags is None:
raise HTTPException(422, "Provide title and/or tags")
owner, repo = _repo_for(rfc)
path = _file_path_for(rfc)
fetched = await gitea.read_file(owner, repo, path, ref="main")
if fetched is None:
raise HTTPException(409, f"{path} not found on meta-main")
prior_content, prior_sha = fetched
try:
entry = entry_mod.parse(prior_content)
except Exception as e:
raise HTTPException(500, f"meta-repo entry is malformed: {e}")
changes_desc: list[str] = []
if new_title is not None and new_title != entry.title:
changes_desc.append(f"title: {entry.title!r}{new_title!r}")
entry.title = new_title
if new_tags is not None and list(new_tags) != list(entry.tags):
cleaned = [t.strip() for t in new_tags if t and t.strip()]
changes_desc.append(f"tags: {entry.tags!r}{cleaned!r}")
entry.tags = cleaned
if not changes_desc:
return {"ok": True, "noop": True}
new_content = entry_mod.serialize(entry)
pr_title = f"Metadata: {entry.title}"
pr_description = (
body.pr_description
or "Metadata edit on the super-draft entry:\n\n- " + "\n- ".join(changes_desc)
)
try:
pr = await bot.open_metadata_pr(
viewer.as_actor(),
org=owner,
meta_repo=repo,
slug=slug,
new_file_contents=new_content,
prior_sha=prior_sha,
pr_title=pr_title,
pr_description=pr_description,
)
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"]}
# -------------------------------------------------------------------
# §17 / §8.9: accept / decline / reask a change
# -------------------------------------------------------------------
@@ -311,49 +474,50 @@ def make_router(
request: Request,
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_rfc_with_repo(slug)
_require_can_contribute(slug, branch, viewer)
row = _require_pending_change(slug, branch, change_id)
if row["kind"] != "ai":
raise HTTPException(409, "Manual changes are accepted via manual-flush")
# Fetch current branch body and locate the change's `original`.
owner, repo = _owner_repo(rfc)
fetched = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=branch)
# 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)
fetched = await gitea.read_file(owner, repo, path, ref=branch)
if fetched is None:
raise HTTPException(409, "Branch RFC.md not found")
current_body, prior_sha = fetched
raise HTTPException(409, f"Branch {path} not found")
prior_content, prior_sha = fetched
current_body = _extract_body(rfc, prior_content)
original = row["original"]
# §8.9: the fallback for ambiguous ranges — the `original` text
# appearing in more than one place — is to refuse the apply.
occurrences = current_body.count(original)
if occurrences == 0:
if not body.force_apply_stale:
# Per §8.11: mark stale and refuse. The contributor can
# re-ask, or force-apply if they judge it still applicable.
# Per §8.11: mark stale and refuse.
db.conn().execute(
"UPDATE changes SET stale_since = COALESCE(stale_since, datetime('now')) WHERE id = ?",
(change_id,),
)
raise HTTPException(409, "Change is stale — original text no longer in document")
# force-apply path: append the proposed text at the end of
# the document as a coarse fallback. The contributor's
# explicit consent (force_apply_stale) is the gate.
# force-apply path: append the proposed text at the end as a
# coarse fallback. The contributor's explicit consent is the gate.
new_body = current_body.rstrip() + "\n\n" + body.proposed.strip() + "\n"
elif occurrences > 1:
raise HTTPException(409, "Change cannot be auto-applied: original text appears multiple times")
else:
new_body = current_body.replace(original, body.proposed, 1)
new_file_contents = _wrap_body(rfc, prior_content, new_body)
try:
sha = await bot.commit_accepted_change(
viewer.as_actor(),
owner=owner,
repo=repo,
branch=branch,
file_path=RFC_FILE_PATH,
new_content=new_body,
file_path=path,
new_content=new_file_contents,
prior_sha=prior_sha,
change_id=change_id,
original=original,
@@ -386,24 +550,22 @@ def make_router(
),
)
# Per §8.11: a successful manual or AI commit changes the
# document; mark any pending AI proposals whose anchor no
# longer locates as stale.
# Per §8.11: mark any pending AI proposals whose anchor no longer
# locates as stale. The stale check operates against the editable
# body, not the full file.
chat_layer.mark_stale_overlapping(rfc_slug=slug, branch_name=branch, new_body=new_body)
await cache.refresh_rfc_repo(config, gitea, slug)
await _refresh_cache_for(rfc)
return {"ok": True, "commit_sha": sha, "change_id": change_id}
@router.post("/api/rfcs/{slug}/branches/{branch}/changes/{change_id}/decline")
async def decline_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_active_rfc(slug)
_require_rfc_with_repo(slug)
_require_can_contribute(slug, branch, viewer)
row = _require_pending_change(slug, branch, change_id)
if row["kind"] != "ai":
raise HTTPException(409, "Manual changes are declined via manual-flush revert")
# Per §8.9: decline is not a commit. The card persists with
# state='declined' as evidence.
db.conn().execute(
"""
UPDATE changes
@@ -416,18 +578,8 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch}/changes/{change_id}/reask")
async def reask_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]:
"""Per §8.11: re-prompt the AI against the current text to
regenerate a proposal anchored to the new phrasing. The old
row stays for audit; the new row lands when the streaming
turn completes.
For Slice 2 the reask is a synchronous, non-streaming call
that returns the new change ids. The richer "streams the
regeneration into the originating thread" version lands when
the per-thread SSE chat surface needs it across more flows.
"""
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_rfc_with_repo(slug)
_require_can_contribute(slug, branch, viewer)
row = _require_change(slug, branch, change_id)
if row["kind"] != "ai":
@@ -438,9 +590,10 @@ def make_router(
if not providers:
raise HTTPException(503, "No AI providers configured")
owner, repo = _owner_repo(rfc)
fetched = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=branch)
body_text = fetched[0] if fetched else ""
owner, repo = _repo_for(rfc)
path = _file_path_for(rfc)
fetched = await gitea.read_file(owner, repo, path, ref=branch)
body_text = _extract_body(rfc, fetched[0]) if fetched else ""
provider = next(iter(providers.values()))
system = chat_layer.build_system_prompt(title=rfc["title"], body=body_text)
@@ -483,20 +636,23 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch}/manual-flush")
async def manual_flush(slug: str, branch: str, body: ManualFlushBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_rfc_with_repo(slug)
_require_can_contribute(slug, branch, viewer)
owner, repo = _owner_repo(rfc)
owner, repo = _repo_for(rfc)
path = _file_path_for(rfc)
fetched = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=branch)
fetched = await gitea.read_file(owner, repo, path, ref=branch)
if fetched is None:
raise HTTPException(409, "Branch RFC.md not found")
prior_body, prior_sha = fetched
raise HTTPException(409, f"Branch {path} not found")
prior_content, prior_sha = fetched
prior_body = _extract_body(rfc, prior_content)
if prior_body == body.new_content:
return {"ok": True, "noop": True}
# Per §8.11: the manual change is materialized as a `changes`
# row first (state='accepted' on flush, with the commit_sha
# backfilled), so the resolved card binds 1:1 to the commit.
new_file_contents = _wrap_body(rfc, prior_content, body.new_content)
# Per §8.11: materialize the manual change as a `changes` row
# first so the resolved card binds 1:1 to the commit.
cur = db.conn().execute(
"""
INSERT INTO changes
@@ -520,16 +676,14 @@ def make_router(
owner=owner,
repo=repo,
branch=branch,
file_path=RFC_FILE_PATH,
new_content=body.new_content,
file_path=path,
new_content=new_file_contents,
prior_sha=prior_sha,
change_id=change_id,
paragraph_count=body.paragraph_count,
slug=slug,
)
except GiteaError as e:
# Roll back the changes row so a failed commit doesn't
# leave a phantom resolved card in the panel.
db.conn().execute("DELETE FROM changes WHERE id = ?", (change_id,))
raise HTTPException(502, f"Gitea: {e.detail}")
@@ -540,8 +694,7 @@ def make_router(
# Per §10.6: every manual flush drops a system-author message
# into the branch chat. Even before the PR exists, the chat is
# the canonical evidence timeline and a silent diff shift
# would corrupt it.
# the canonical evidence timeline.
main_thread_id = _ensure_branch_chat_thread(slug, branch, viewer)
chat_layer.append_system_message(
thread_id=main_thread_id,
@@ -549,7 +702,7 @@ def make_router(
)
chat_layer.mark_stale_overlapping(rfc_slug=slug, branch_name=branch, new_body=body.new_content)
await cache.refresh_rfc_repo(config, gitea, slug)
await _refresh_cache_for(rfc)
return {"ok": True, "commit_sha": sha, "change_id": change_id}
@@ -560,7 +713,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch}/visibility")
async def set_branch_visibility(slug: str, branch: str, body: VisibilityBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_rfc_with_repo(slug)
creator = _branch_creator(slug, branch)
_require_branch_owner(rfc, viewer, creator)
current = _branch_vis(slug, branch)
@@ -581,7 +734,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch}/grants")
async def add_branch_grant(slug: str, branch: str, body: GrantBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_rfc_with_repo(slug)
creator = _branch_creator(slug, branch)
_require_branch_owner(rfc, viewer, creator)
grantee = db.conn().execute(
@@ -602,7 +755,7 @@ def make_router(
@router.delete("/api/rfcs/{slug}/branches/{branch}/grants/{grantee_login}")
async def revoke_branch_grant(slug: str, branch: str, grantee_login: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_rfc_with_repo(slug)
creator = _branch_creator(slug, branch)
_require_branch_owner(rfc, viewer, creator)
grantee = db.conn().execute(
@@ -622,7 +775,7 @@ def make_router(
@router.get("/api/rfcs/{slug}/branches/{branch}/threads")
async def list_branch_threads(slug: str, branch: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
_require_active_rfc(slug)
_require_rfc_with_repo(slug)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
rows = db.conn().execute(
@@ -640,7 +793,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch}/threads")
async def create_branch_thread(slug: str, branch: str, body: ThreadCreateBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_active_rfc(slug)
_require_rfc_with_repo(slug)
if body.thread_kind == "flag" and not body.label:
raise HTTPException(422, "Flag threads require a label")
cur = db.conn().execute(
@@ -670,7 +823,7 @@ def make_router(
@router.get("/api/rfcs/{slug}/branches/{branch}/threads/{thread_id}/messages")
async def get_thread_messages(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
_require_active_rfc(slug)
_require_rfc_with_repo(slug)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
thread = _require_thread(slug, branch, thread_id)
@@ -695,12 +848,8 @@ def make_router(
slug: str, branch: str, thread_id: int, body: ThreadMessageBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_active_rfc(slug)
_require_rfc_with_repo(slug)
_require_thread(slug, branch, thread_id)
# Posting in a branch chat does NOT require contribute access —
# §8.4 / §11.4: chat visibility follows read visibility, and
# posting requires contributor + read. Anonymous is gated by
# require_contributor already.
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
message_id = chat_layer.append_user_message(
@@ -714,7 +863,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch}/threads/{thread_id}/resolve")
async def resolve_thread(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_rfc_with_repo(slug)
thread = _require_thread(slug, branch, thread_id)
creator = _branch_creator(slug, branch)
if not _can_resolve_thread(rfc, thread, creator, viewer):
@@ -737,7 +886,7 @@ def make_router(
slug: str, branch: str, thread_id: int, body: ChatTurnBody, request: Request
):
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_rfc_with_repo(slug)
thread = _require_thread(slug, branch, thread_id)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
@@ -747,13 +896,13 @@ def make_router(
provider = providers[model_key]
# Fetch the live branch body so the prompt is anchored to
# what's in Gitea right now, not the cache.
owner, repo = _owner_repo(rfc)
fetched = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=branch)
body_text = fetched[0] if fetched else ""
# 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)
fetched = await gitea.read_file(owner, repo, path, ref=branch)
body_text = _extract_body(rfc, fetched[0]) if fetched else ""
# Per §8.12: when a chat turn carries a quote (the selection),
# the model needs to see the quote alongside the document.
prompt_text = body.text
if body.quote:
prompt_text = f'The contributor has selected this passage:\n"{body.quote}"\n\n---\n\n{body.text}'
@@ -766,9 +915,6 @@ def make_router(
)
system = chat_layer.build_system_prompt(title=rfc["title"], body=body_text)
# History is every prior user/assistant row strictly before the
# one we just inserted; the orchestrator appends the current
# user message itself when calling the provider.
rows = db.conn().execute(
"""
SELECT role, text FROM thread_messages
@@ -792,9 +938,6 @@ def make_router(
):
yield chunk
# Per §8.4 the response includes the assistant's message id so
# the client can bind the streamed text to a chat row that
# already exists.
headers = {
"X-Assistant-Message-Id": str(assistant_message_id),
"X-User-Message-Id": str(user_message_id),
@@ -806,20 +949,79 @@ def make_router(
# Permission + state helpers (closures, share `config` 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 — Slice 4 owns super-draft edits")
if not row["repo"]:
return row
def _require_rfc_with_repo(slug: str):
"""Used by every branch-scoped endpoint. For active RFCs, a repo is
required. For super-drafts, the meta repo is the implicit target —
no per-RFC repo check needed."""
row = _require_rfc(slug)
if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn")
if row["state"] == "active" and not row["repo"]:
raise HTTPException(409, "RFC has no repo")
return row
def _owner_repo(rfc) -> tuple[str, str]:
def _require_active_rfc(slug: str):
row = _require_rfc_with_repo(slug)
if row["state"] != "active":
raise HTTPException(409, f"RFC is {row['state']}, not active")
return row
def _require_super_draft(slug: str):
row = _require_rfc(slug)
if row["state"] != "super-draft":
raise HTTPException(409, f"RFC is {row['state']}, not super-draft")
return row
def _is_super_draft(rfc) -> bool:
return rfc["state"] == "super-draft"
def _repo_for(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. For
active RFCs the file is just RFC.md and the whole thing is 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 _wrap_body(rfc, prior_contents: str, new_body: str) -> 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):
return new_body
entry = entry_mod.parse(prior_contents)
# Ensure exactly one trailing newline so the serializer's
# round-trip is stable.
entry.body = new_body if new_body.endswith("\n") else new_body + "\n"
return entry_mod.serialize(entry)
async def _refresh_cache_for(rfc) -> None:
if _is_super_draft(rfc):
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea)
else:
await cache.refresh_rfc_repo(config, gitea, rfc["slug"])
def _ensure_branch_chat_thread(slug: str, branch: str, viewer) -> int:
"""Per §8.12: every branch has a default whole-doc chat thread.
Create it lazily on first read. The created_by is null when an
@@ -846,10 +1048,6 @@ def make_router(
return cur.lastrowid
def _ensure_branch_vis(slug: str, branch: str, *, creator_user_id: int) -> None:
"""Materialize the §11.1 / §6.4 defaults row when a branch is
created. The creator identity is recovered separately by joining
against the `actions` log per §15.9.
"""
del creator_user_id # creator is sourced from actions log
db.conn().execute(
"""
@@ -866,7 +1064,6 @@ def make_router(
).fetchone()
if row:
return {"read_public": bool(row["read_public"]), "contribute_mode": row["contribute_mode"]}
# §11.1 / §6.4 defaults.
return {"read_public": True, "contribute_mode": "just-me"}
def _branch_grants(slug: str, branch: str) -> list[dict]:
@@ -883,8 +1080,6 @@ def make_router(
return [{"gitea_login": r["gitea_login"], "display_name": r["display_name"], "granted_at": r["granted_at"]} for r in rows]
def _branch_creator(slug: str, branch: str) -> str | None:
"""Per §15.9: the underlying-actor-not-bot rule applies to every
attribution surface. We look the creator up in the actions log."""
if branch == "main":
return None
row = db.conn().execute(
@@ -898,8 +1093,6 @@ def make_router(
return row["on_behalf_of"] if row else None
def _can_read_branch(slug: str, branch: str, viewer) -> bool:
"""Per §11.1: branches default read_public=true; the creator
and owners/arbiters can still read a private branch."""
if branch == "main":
return True
vis = _branch_vis(slug, branch)
@@ -918,8 +1111,6 @@ def make_router(
arbiters = json.loads(rfc["arbiters_json"] or "[]")
if viewer.gitea_login in owners or viewer.gitea_login in arbiters:
return True
# Explicit grant (used for the §11.4 "specific" contribute case;
# grant-ees inherit read on the §11.1 default-private branch).
row = db.conn().execute(
"""
SELECT 1 FROM branch_contribute_grants g
@@ -930,11 +1121,10 @@ def make_router(
return row is not None
def _can_contribute(rfc, slug: str, branch: str, viewer) -> bool:
"""§6.4 contribute_mode + §6.3 per-RFC authority + §6.1 admin/owner."""
if viewer is None:
return False
if branch == "main":
return False # main is read-only per §8.3; PRs are the only path
return False
if viewer.role in ("owner", "admin"):
return True
owners = json.loads(rfc["owners_json"] or "[]")
@@ -966,8 +1156,6 @@ def make_router(
raise HTTPException(403, "You do not have contribute access to this branch")
def _require_branch_owner(rfc, viewer, creator: str | None) -> None:
"""The set who can flip visibility / add grants: branch creator,
owners/arbiters, and app admins/owners per §11.1, §11.2, §6.3."""
if viewer.role in ("owner", "admin"):
return
owners = json.loads(rfc["owners_json"] or "[]")
@@ -978,15 +1166,29 @@ def make_router(
return
raise HTTPException(403, "Only the branch creator, an RFC owner/arbiter, or an admin/owner may change branch settings")
def _can_edit_metadata(rfc, viewer) -> bool:
"""§9.5: super-draft owners/arbiters per §6.3 plus app admins/owners.
Until §13.1's claim runs, the super-draft has no owners, so the set
collapses to app admins/owners only — sensible because admin oversight
is the only path to canonicalizing edits on an unclaimed entry."""
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 _capabilities(rfc, slug: str, branch: str, viewer, creator: str | None) -> dict:
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")
return {
"can_read": _can_read_branch(slug, branch, viewer),
"can_contribute": _can_contribute(rfc, slug, branch, viewer) if viewer else False,
"can_change_branch_settings": viewer is not None and (
viewer.role in ("owner", "admin")
or (creator is not None and viewer.gitea_login == creator)
or viewer.gitea_login in (json.loads(rfc["owners_json"] or "[]") + json.loads(rfc["arbiters_json"] or "[]"))
or viewer.gitea_login in (owners + arbiters)
),
"can_edit_metadata": viewer is not None and _is_super_draft(rfc) and _can_edit_metadata(rfc, viewer),
"is_anonymous": viewer is None,
}
@@ -1102,7 +1304,7 @@ def _serialize_message(row) -> dict[str, Any]:
# ---------------------------------------------------------------------------
# Branch name validation + auto-generation per §8.14
# Branch name validation + auto-generation per §8.14 / §9.5
# ---------------------------------------------------------------------------
_BRANCH_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._\-/]*[a-z0-9]$")
@@ -1114,17 +1316,25 @@ def _validate_branch_name(name: str) -> None:
# leading/trailing punctuation, or path components Gitea would refuse.
if len(name) > 120 or not _BRANCH_NAME_RE.match(name):
raise HTTPException(422, "Branch name must be lowercase alphanumerics, hyphens, dots, slashes")
if name == "main" or name.startswith(("propose/", "edit/", "claim/", "metadata/")):
# Reserved-prefix guard: these are bot-internal naming conventions.
# Slice 4 added `edit-` and `metadata-` to dodge the §19.2 path-
# routing candidate while keeping the §9.5 structural shape legible.
if name == "main" or name.startswith(("propose/", "edit/", "edit-", "claim/", "metadata/", "metadata-")):
raise HTTPException(422, "Branch name conflicts with a reserved prefix")
def _auto_branch_name(login: str) -> str:
# Per §8.14: "auto-generated value (user-renamable); the exact
# format is an implementation detail." We use `<login>-draft-<hex>`
# — no slash so FastAPI's default {branch} path segment matches —
# which keeps the branch's origin legible in the Git log without
# depending on the `actions` join to render it. Users who type
# their own name and want a slash can still do so; the
# {branch:path}-tolerant routing in the next slice covers that.
# Per §8.14: auto-generated value, exact format implementation detail.
# `<login>-draft-<hex>` keeps the branch's origin legible in the Git
# log and avoids slashes per the §19.2 path-routing candidate.
import secrets
return f"{login.lower()}-draft-{secrets.token_hex(3)}"
def _auto_edit_branch_name(slug: str) -> str:
# Per §9.5: structural form is `edit/<slug>/<auto-name>`; Slice 4
# uses `edit-<slug>-<6hex>` to dodge the §19.2 path-routing
# candidate — three components separated by dashes, with the slug as
# the second component so the cache parsers can recover it.
import secrets
return f"edit-{slug}-{secrets.token_hex(3)}"
+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.
+59
View File
@@ -221,6 +221,65 @@ class Bot:
pr_number=pr_number,
)
# ----- Meta repo: metadata-pane PRs (§9.5) -----
async def open_metadata_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
slug: str,
new_file_contents: str,
prior_sha: str,
pr_title: str,
pr_description: str,
) -> dict:
"""Per §9.5: a metadata-pane edit (title or tags) on a super-draft
opens a tiny meta-repo PR that touches only the frontmatter of
`rfcs/<slug>.md`. One commit, one PR, easy to triage. The branch
name uses the dash-separated `metadata-<slug>-<6hex>` shape — same
routing-friendly form Slice 4 picked for edit branches per the
§19.2 path-routing candidate.
"""
import secrets
branch = f"metadata-{slug}-{secrets.token_hex(3)}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
commit_subject = pr_title
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=actor.email or f"{actor.gitea_login}@users.noreply",
)
commit_sha = result.get("commit", {}).get("sha") or result.get("content", {}).get("sha") or ""
_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_metadata_pr",
rfc_slug=slug,
branch_name=branch,
pr_number=pr["number"],
bot_commit_sha=commit_sha,
details={"pr_title": pr_title},
)
return pr
# ----- Per-RFC repo: branches (§8.3, §8.14) -----
async def cut_branch_from_main(
+148 -6
View File
@@ -286,6 +286,112 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
)
async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
"""Mirror the meta repo's branches into `cached_branches` for super-draft
edit branches, plus a per-slug `main` row that records the meta-repo
main's tip sha so the §10.1 has-commits-ahead check works uniformly
across active and super-draft surfaces.
Per the §5 super-draft scoping note, super-draft edits are branches on
the meta repo. The naming Slice 4 picked is `edit-<slug>-<6hex>` —
structurally `edit/<slug>/<auto-name>` per §9.5, with dashes in place
of slashes per the §19.2 path-routing candidate.
"""
org, repo = config.gitea_org, config.meta_repo
try:
branches = await gitea.list_branches(org, repo)
except GiteaError as e:
log.warning("refresh_meta_branches: %s", e)
return
meta_main_sha = ""
meta_main_ts = None
edit_keys_seen: set[tuple[str, str]] = set()
for b in branches:
name = b.get("name") or ""
head_sha = (b.get("commit") or {}).get("id") or ""
last_commit_at = (b.get("commit") or {}).get("timestamp")
if name == "main":
meta_main_sha = head_sha
meta_main_ts = last_commit_at
continue
slug = _slug_from_branch_name(name)
if not slug:
continue
rfc = db.conn().execute(
"SELECT state FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if not rfc or rfc["state"] != "super-draft":
continue
edit_keys_seen.add((slug, name))
db.conn().execute(
"""
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, ?, ?, 'open', ?)
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha,
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
last_commit_at = excluded.last_commit_at
""",
(slug, name, head_sha, last_commit_at),
)
# Synthesize a per-slug `main` row for every super-draft entry, so the
# §10.1 has-commits-ahead check in api_prs.py works uniformly. The
# head_sha is the meta-repo main's tip — every super-draft edit branch
# diverges from this single point.
if meta_main_sha:
super_drafts = db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE state = 'super-draft'"
).fetchall()
for r in super_drafts:
db.conn().execute(
"""
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, 'main', ?, 'open', ?)
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha,
last_commit_at = excluded.last_commit_at
""",
(r["slug"], meta_main_sha, meta_main_ts),
)
# Mark previously-known edit branches that disappeared as deleted per
# §11.5 / §12. Keep the row so chat history survives the branch's
# deletion in Gitea.
known = db.conn().execute(
"""
SELECT b.rfc_slug, b.branch_name
FROM cached_branches b
JOIN cached_rfcs r ON r.slug = b.rfc_slug
WHERE r.state = 'super-draft'
AND b.state != 'deleted'
AND b.branch_name != 'main'
"""
).fetchall()
for k in known:
if (k["rfc_slug"], k["branch_name"]) not in edit_keys_seen:
db.conn().execute(
"UPDATE cached_branches SET state = 'deleted' WHERE rfc_slug = ? AND branch_name = ?",
(k["rfc_slug"], k["branch_name"]),
)
def _slug_from_branch_name(name: str) -> str | None:
"""Mirror of `_slug_from_head_branch` for branch-only inputs (no PR
body to consult)."""
if name.startswith("edit-"):
body = name[len("edit-") :]
if "-" in body:
slug, _hex = body.rsplit("-", 1)
return slug or None
if name.startswith("edit/"):
parts = name.split("/", 2)
if len(parts) >= 2:
return parts[1]
return None
async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
"""Reconcile open meta-repo PRs into cached_prs.
@@ -328,13 +434,28 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
pull["number"],
pull.get("body") or "",
)
# §10.8 / Slice 4: a closed body-edit PR may have been withdrawn
# by the contributor. Distinguish from a generic Gitea close via
# the audit log — same shape api_prs.py uses for rfc_branch PRs.
if state == "closed" and pr_kind == "meta_body_edit":
withdrew = db.conn().execute(
"""
SELECT 1 FROM actions
WHERE action_kind = 'withdraw_branch_pr'
AND rfc_slug = ? AND pr_number = ? LIMIT 1
""",
(slug, pull["number"]),
).fetchone()
if withdrew:
state = "withdrawn"
merge_commit_sha = pull.get("merge_commit_sha")
db.conn().execute(
"""
INSERT INTO cached_prs
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
opened_by, opened_at, merged_at, closed_at,
head_branch, base_branch, head_sha)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
head_branch, base_branch, head_sha, merge_commit_sha)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(repo, pr_number) DO UPDATE SET
title = excluded.title,
description = excluded.description,
@@ -342,7 +463,8 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
opened_by = excluded.opened_by,
merged_at = excluded.merged_at,
closed_at = excluded.closed_at,
head_sha = excluded.head_sha
head_sha = excluded.head_sha,
merge_commit_sha = COALESCE(excluded.merge_commit_sha, cached_prs.merge_commit_sha)
""",
(
slug,
@@ -359,6 +481,7 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
head_branch,
(pull.get("base") or {}).get("ref") or "main",
(pull.get("head") or {}).get("sha"),
merge_commit_sha,
),
)
@@ -374,7 +497,7 @@ def _resolve_actor(gitea_opener: str, bot_login: str, slug: str, pr_number: int,
row = db.conn().execute(
"""
SELECT on_behalf_of FROM actions
WHERE action_kind IN ('propose_rfc', 'open_body_edit_pr', 'open_claim_pr', 'open_metadata_pr')
WHERE action_kind IN ('propose_rfc', 'open_body_edit_pr', 'open_branch_pr', 'open_claim_pr', 'open_metadata_pr')
AND rfc_slug = ? AND pr_number = ?
ORDER BY id LIMIT 1
""",
@@ -400,21 +523,39 @@ def _slug_from_head_branch(head_branch: str) -> str | None:
parts = head_branch.split("/", 2)
if len(parts) >= 2:
return parts[1]
if head_branch.startswith("edit-"):
# §9.5 names the structural shape `edit/<slug>/<auto-name>`, but
# FastAPI's default {branch} path-segment matcher refuses slashes
# (the §19.2 routing candidate). Slice 4 picks the same dash-
# separated workaround Slice 2 used for promote-to-branch:
# `edit-<slug>-<6hex>`. The slug is the middle; the final
# dash-segment is a 6-hex suffix.
body = head_branch[len("edit-") :]
if "-" in body:
slug, _hex = body.rsplit("-", 1)
return slug or None
if head_branch.startswith("claim/"):
return head_branch[len("claim/") :]
if head_branch.startswith("metadata/"):
return head_branch[len("metadata/") :]
if head_branch.startswith("metadata-"):
# §9.5 metadata-pane PRs use the same dash-separated branch shape
# as edit branches, for the same routing reason.
body = head_branch[len("metadata-") :]
if "-" in body:
slug, _hex = body.rsplit("-", 1)
return slug or None
return None
def _kind_from_branch(head_branch: str) -> str:
if head_branch.startswith("propose/"):
return "idea"
if head_branch.startswith("edit/"):
if head_branch.startswith("edit/") or head_branch.startswith("edit-"):
return "meta_body_edit"
if head_branch.startswith("claim/"):
return "meta_claim"
if head_branch.startswith("metadata/"):
if head_branch.startswith("metadata/") or head_branch.startswith("metadata-"):
return "meta_metadata"
return "idea" # fallback
@@ -475,6 +616,7 @@ class Reconciler:
log.info("reconciler: starting sweep")
try:
await refresh_meta_repo(self._config, self._gitea)
await refresh_meta_branches(self._config, self._gitea)
await refresh_meta_pulls(self._config, self._gitea)
# Per-RFC repos: refresh each active entry. Meta-repo refresh
# must come first so newly-graduated entries land in
+1
View File
@@ -62,6 +62,7 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
try:
if repo_full == meta_full or not repo_full:
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
else:
slug = _slug_for_repo(repo_full)