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:
@@ -2405,91 +2405,91 @@ surface. With Topic 13 folded in, the structural surface is
|
||||
complete. What follows is no longer "topics that block specifying
|
||||
v1" but "topics to address during or shortly after the v1 build."
|
||||
|
||||
### 19.1 Next slice: super-draft body editing
|
||||
### 19.1 Next slice: graduation per §13
|
||||
|
||||
Slice 3 of the build has landed. The §10 PR flow is wired
|
||||
end-to-end against the local Gitea — the §10.1 `Open PR`
|
||||
affordance on a branch with the §11.3 universal-public flip when
|
||||
the source branch is private, the §10.2 AI-drafted modal pulling
|
||||
title and description from the diff plus the branch chat (with a
|
||||
deterministic stub when no provider is configured), and the §10.3
|
||||
PR review page mounted at `/rfc/<slug>/pr/<n>` that inherits the
|
||||
§8.1 three-column shape and renders a unified/split diff in the
|
||||
center against a compressed conversation on the right that
|
||||
interleaves chat, flag, and review threads with visual
|
||||
distinction. The §10.3 per-user seen-cursor advances on every
|
||||
visit and accents new commits and new messages on the next; stale
|
||||
tabs cannot roll the cursor backward. §10.4 review threads
|
||||
materialize as `thread_kind='review'` `anchor_kind='range'` rows
|
||||
on the branch chat, surfaced inline with the AI conversation but
|
||||
distinguished by header badge. §10.5 merge runs the bot through
|
||||
Gitea's `style='merge'` no-fast-forward path with an
|
||||
`On-behalf-of:` trailer on the merge commit, preserving the §8.6
|
||||
per-acceptance commits as reachable nodes in main's history.
|
||||
§10.6 update-after-open falls out of Slice 2's existing
|
||||
per-accept-and-per-flush push paths plus the new diff re-render on
|
||||
every PR view. §10.7 post-merge renders the PR read-only with a
|
||||
`Merged` banner; §10.8 withdraw collapses the PR to read-only with
|
||||
a `Withdrawn` banner, distinguishing the user gesture from a
|
||||
generic Gitea close via the audit log. §10.9 conflict-replay
|
||||
surfaces a `Start resolution branch` affordance from the conflict
|
||||
banner when Gitea reports the PR as unmergeable, cuts a fresh
|
||||
branch off main's tip via the bot, replays the original branch's
|
||||
accepted AI changes onto the resolution branch — applying each one
|
||||
whose `original` text still locates exactly once, surfacing the
|
||||
rest as stale-pending changes the contributor can re-anchor — and
|
||||
opens a new PR whose `Supersedes:` trailer the cache parses on the
|
||||
resolution PR's merge to auto-close the original.
|
||||
Slice 4 of the build has landed. Super-draft body editing per §9.5
|
||||
runs end-to-end against the local Gitea — the §9.4 super-draft view
|
||||
replaces the Slice 2 placeholder and renders through the same
|
||||
`RFCView.jsx` surface as an active RFC, dispatched on `entry.state`.
|
||||
The §9.5 `Start Contributing` gesture cuts a meta-repo edit branch
|
||||
via `POST /api/rfcs/<slug>/start-edit-branch`, re-anchors pending
|
||||
main-scoped `changes` rows, and lands the contributor in contribute
|
||||
mode on the new branch. From there everything in §8 — chat, AI
|
||||
participation, accept/decline/edit, manual-edit flushes, range and
|
||||
paragraph sub-threads, flags, DiffView, stale-change handling —
|
||||
reaches the super-draft surface through the same routes Slice 2
|
||||
shipped, with the dispatch sitting in `api_branches.py`'s helpers:
|
||||
when `cached_rfcs.state = 'super-draft'`, the bot writes to the
|
||||
meta repo and the file is `rfcs/<slug>.md` (the body wrapped in
|
||||
frontmatter); when `state = 'active'`, it writes to the per-RFC
|
||||
repo and the file is `RFC.md`. The body extracted from the entry's
|
||||
frontmatter envelope is what the editor and the diff see; the
|
||||
serializer re-wraps on every commit. The §10 PR flow against
|
||||
meta-repo edit branches falls out structurally unchanged, with
|
||||
`pr_kind='meta_body_edit'` distinguishing the cache row — the
|
||||
§10.3 review page, the §10.4 review threads, the §10.5 merge, the
|
||||
§10.8 withdraw, and the §10.9 conflict-replay path all dispatch the
|
||||
same way. §9.7's visibility and contribute grants on edit branches
|
||||
reuse the Slice 2 `branch_visibility` / `branch_contribute_grants`
|
||||
machinery, keyed on the meta repo. The §9.5 metadata pane lands as
|
||||
`POST /api/rfcs/<slug>/metadata` — title and tag edits open a
|
||||
small meta-repo PR via the bot's new `open_metadata_pr` primitive;
|
||||
slug renames remain deferred per §9.5 and the §19.2 candidate. The
|
||||
§9.5 unclaimed-merge gate — only app admins/owners can merge a
|
||||
body-edit PR until §13.1's claim runs — falls out of the existing
|
||||
`_can_merge` rule against an empty `owners_json` / `arbiters_json`.
|
||||
|
||||
The §10 endpoints live in `backend/app/api_prs.py`, mounted
|
||||
alongside the Slice 1 and 2 routers. The bot grew
|
||||
`open_branch_pr`, `merge_branch_pr`, `withdraw_branch_pr`,
|
||||
`cut_resolution_branch`, and `commit_replay_change`. The §5
|
||||
schema grew `cached_prs.superseded_by_pr_number`,
|
||||
`cached_prs.merge_commit_sha`, and a `pr_resolution_branches` join
|
||||
table that records resolution-branch parentage. On the frontend,
|
||||
the `Open PR` button landed on `RFCView.jsx`'s branch view,
|
||||
opening `PRModal.jsx`; `PRView.jsx` is the §10.3 page in full.
|
||||
The two §17 routes Slice 4 added — `start-edit-branch` and
|
||||
`metadata` — live in `backend/app/api_branches.py`. The bot grew
|
||||
`open_metadata_pr`. The §4 cache grew `refresh_meta_branches`
|
||||
which mirrors `edit-<slug>-<6hex>` branches into `cached_branches`
|
||||
and synthesizes a per-slug `main` row so the §10.1 has-commits-
|
||||
ahead check works uniformly. The §5 schema needed no migration —
|
||||
the super-draft scoping note already settled that the existing
|
||||
tables carry both cases. On the frontend, `RFCView.jsx`'s
|
||||
super-draft placeholder is replaced by the full editor surface;
|
||||
the `BranchDropdown` renders `canonical body` as the first
|
||||
position per §9.4; a `MetadataPaneModal` opens from the breadcrumb
|
||||
actions for viewers holding super-draft edit authority.
|
||||
|
||||
Slice 3 ships covered by `backend/tests/test_pr_flow_vertical.py`
|
||||
— nine integration tests against an extended FakeGitea that grew
|
||||
PR mergeability tracking via per-branch base snapshots,
|
||||
no-fast-forward merge behavior, and a `mergeable` field on PR
|
||||
responses. The tests cover opening with the §11.3 flip and the
|
||||
§10.9 one-PR-per-branch refusal, the AI draft, the three-column
|
||||
payload shape, seen-cursor advance with stale-tab protection,
|
||||
review-thread posting, arbiter-only merge, contributor withdraw
|
||||
with the `withdrawn` state distinct from generic `closed`,
|
||||
anonymous read of a public PR, and the full §10.9 conflict-replay
|
||||
path including the auto-close of the original PR on the resolution
|
||||
PR's merge.
|
||||
Slice 4 ships covered by `backend/tests/test_super_draft_vertical.py`
|
||||
— ten integration tests against the FakeGitea covering main-view
|
||||
read, start-edit-branch, body extraction on read, accept and manual
|
||||
flush both preserving the frontmatter envelope, the body-edit PR's
|
||||
`pr_kind='meta_body_edit'` cache shape, the full cut-accept-open-
|
||||
merge loop with the §9.5 admin-only unclaimed-merge gate, the
|
||||
metadata pane PR cycle, the canonical-body branch (`main` for
|
||||
super-drafts) refusing contribute writes, and the metadata pane
|
||||
permission gate refusing plain contributors. The full Slices 1–4
|
||||
test suite is 35/35 green.
|
||||
|
||||
**Slice 4 is super-draft body editing per §9.5 + §9.6.** The
|
||||
unit of work is the meta-repo edit branch — `edit/<slug>/<auto-
|
||||
name>` per §9.5 — and almost everything from §8 falls out
|
||||
structurally unchanged once `<slug>` resolves to a super-draft
|
||||
entry and `<branch>` names a meta-repo branch rather than a
|
||||
per-RFC-repo branch, per the §5 super-draft scoping note and §17's
|
||||
single-dispatch rule. The §9.5 `Start Contributing` gesture on a
|
||||
super-draft cuts a meta-repo edit branch via the bot and
|
||||
re-anchors pending main-scoped `changes` rows. The §9.6 chat-and-
|
||||
threads surface inherits the existing `threads` /
|
||||
`thread_messages` shape. The §9.7 visibility and contribute grants
|
||||
on edit branches reuse the Slice 2 machinery, keyed on the meta
|
||||
repo. The metadata pane from §9.5 lands as
|
||||
`POST /api/rfcs/{slug}/metadata` — title and tag edits as small
|
||||
meta-repo PRs via the bot. Slug renames remain deferred per §9.5
|
||||
and the §19.2 candidate entry. The PR flow against meta-repo
|
||||
edits is structurally identical to the active-RFC PR flow Slice 3
|
||||
shipped and falls out from the same dispatch; the graduation flow
|
||||
from §13 stays deferred to Slice 5.
|
||||
**Slice 5 is graduation per §13.** The five-step transactional
|
||||
sequence flips a super-draft to active: validate the dialog's
|
||||
`id`/`repo`/`owners` inputs against the catalog and Gitea, create
|
||||
the per-RFC repo via `bot.ensure_rfc_repo_seed` (which Slice 2
|
||||
added as a forward-looking seam), copy the body from the entry's
|
||||
frontmatter envelope into the new repo's `RFC.md` on main, strip
|
||||
the body from the meta-repo entry and fill the `id` / `repo` /
|
||||
`graduated_at` / `graduated_by` frontmatter fields, and migrate
|
||||
the chat per §13.4 — the whole-doc main thread and the canonical-
|
||||
body view's range/paragraph sub-threads re-anchor onto the new
|
||||
RFC's main thread; edit-branch chats stay attached to their
|
||||
original `branch_name` on the meta repo per §9.8, surfaced by the
|
||||
pre-graduation history affordance on the new RFC view. The §9.8
|
||||
precondition gate — open body-edit PRs block graduation — is
|
||||
enforced before the bot starts the sequence, so the §13.3 rollback
|
||||
complexity does not grow. The Graduate dialog opens a stream
|
||||
handle for the §17 SSE progress endpoint and renders the step
|
||||
stack from `pending → running → done/failed` transitions, with a
|
||||
trailing `rollback` step's events if any earlier step fails.
|
||||
|
||||
The next build session should read `SPEC.md`, `README.md`, and
|
||||
`docs/DEV.md` and pick up Slice 4 cleanly without re-briefing. The
|
||||
working agreement in §19.3 continues to apply: implement the
|
||||
slice, correct the spec only where running code reveals it was
|
||||
wrong at a structural level, accumulate new candidate topics in
|
||||
§19.2, do not extend the spec beyond what the slice requires.
|
||||
The next build session should read `SPEC.md`, `README.md`,
|
||||
`docs/DEV.md`, and this §19.1 entry and pick up Slice 5 cleanly
|
||||
without re-briefing. The working agreement in §19.3 continues to
|
||||
apply: implement the slice, correct the spec only where running
|
||||
code reveals it was wrong at a structural level, accumulate new
|
||||
candidate topics in §19.2, do not extend the spec beyond what the
|
||||
slice requires.
|
||||
|
||||
### 19.2 Candidate topics for sessions after the v1 build lands
|
||||
|
||||
@@ -2673,6 +2673,21 @@ binding.
|
||||
the §12 hygiene timer that fires the deletion. Slice 8
|
||||
("Hardening") owns the §12 30/90 timers as a whole; calling out
|
||||
here so the dependency is explicit.
|
||||
- **In-app merge for metadata PRs.** Slice 4's metadata pane opens
|
||||
a meta-repo PR per §9.5; the merge surface for those PRs is the
|
||||
Gitea web UI for now, because `api_prs.py`'s merge endpoint is
|
||||
scoped to body-changing PRs (`rfc_branch` and `meta_body_edit`).
|
||||
A small follow-on adds a `prs/<n>/merge`-shaped path that handles
|
||||
`meta_metadata` kinds — likely a tiny variant since there's no
|
||||
diff-rendered review surface to inherit. Defer-able until usage
|
||||
shows admins finding the Gitea round-trip annoying.
|
||||
- **Cache-rebuild discovery of meta-repo edit branches.** Slice 4's
|
||||
`refresh_meta_branches` scans every meta-repo branch and filters
|
||||
by name prefix (`edit-` / `edit/`) to discover super-draft edit
|
||||
branches. The reconciler hits this on every sweep, so it's
|
||||
cheap, but a dedicated index on `cached_branches.branch_name`
|
||||
would shorten the join-against-`cached_rfcs`-state for very
|
||||
large super-draft fleets. Trivial; defer until the cost shows up.
|
||||
- **Body full-text search.** When the time comes.
|
||||
|
||||
Topic 13 (notifications) is settled and folded into §5 (the
|
||||
|
||||
+352
-142
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
"""End-to-end integration tests for the Slice 4 vertical (§9.4–§9.7 in full).
|
||||
|
||||
Walks the super-draft body-editing path end-to-end against the in-process
|
||||
FakeGitea from test_propose_vertical.py:
|
||||
|
||||
* Seed a super-draft from the propose+merge flow already proven by Slice 1.
|
||||
* GET /api/rfcs/<slug>/main returns the canonical body + breadcrumb data.
|
||||
* POST /api/rfcs/<slug>/start-edit-branch cuts a meta-repo edit branch.
|
||||
* GET /api/rfcs/<slug>/branches/<edit-branch> returns the body extracted
|
||||
from the entry envelope, ready for the editor.
|
||||
* POST .../changes/<id>/accept commits to rfcs/<slug>.md on the edit
|
||||
branch, with the frontmatter preserved.
|
||||
* POST .../manual-flush commits a manual edit similarly.
|
||||
* POST .../open-pr opens a meta_body_edit PR.
|
||||
* POST .../prs/<n>/merge propagates the body to meta-main, where it
|
||||
surfaces back into cached_rfcs.body for the next catalog render.
|
||||
* POST .../metadata opens a metadata PR; merge propagates the title.
|
||||
* Withdraw the body-edit PR; the body on the edit branch is untouched
|
||||
but the cache shows state='withdrawn'.
|
||||
|
||||
The active-RFC PR flow tests in test_pr_flow_vertical.py exercise the
|
||||
parallel structural surface; this file's job is to prove the dispatch
|
||||
works against the meta-repo target uniformly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def seed_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str,
|
||||
proposed_by: str = "alice", tags: list[str] | None = None) -> None:
|
||||
"""Seed a super-draft entry directly on meta-main, skipping the
|
||||
propose+merge round-trip the Slice 1 tests cover separately.
|
||||
|
||||
The cache is also primed so the API doesn't have to wait for a
|
||||
reconciler sweep before exercising super-draft endpoints.
|
||||
"""
|
||||
import json as _json
|
||||
import yaml
|
||||
from app import db
|
||||
|
||||
fm = {
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
"state": "super-draft",
|
||||
"id": None,
|
||||
"repo": None,
|
||||
"proposed_by": proposed_by,
|
||||
"proposed_at": "2026-05-23",
|
||||
"graduated_at": None,
|
||||
"graduated_by": None,
|
||||
"owners": [],
|
||||
"arbiters": [],
|
||||
"tags": tags or [],
|
||||
}
|
||||
body = pitch.strip() + "\n"
|
||||
entry_text = f"---\n{yaml.safe_dump(fm, sort_keys=False).rstrip()}\n---\n\n{body}"
|
||||
sha = fake._next_sha()
|
||||
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {
|
||||
"content": entry_text,
|
||||
"sha": sha,
|
||||
}
|
||||
# Advance meta-main's tip sha so the bot's create_branch snapshots
|
||||
# the freshly-seeded file. (Otherwise the branch snapshot starts
|
||||
# empty and the read fails until the next file commit.)
|
||||
fake.branches[("wiggleverse", "meta")]["main"]["sha"] = sha
|
||||
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO cached_rfcs
|
||||
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
|
||||
owners_json, arbiters_json, tags_json,
|
||||
body, body_sha, last_main_commit_at, last_entry_commit_at)
|
||||
VALUES (?, ?, 'super-draft', NULL, NULL, ?, '2026-05-23',
|
||||
?, ?, ?,
|
||||
?, ?, datetime('now'), datetime('now'))
|
||||
""",
|
||||
(
|
||||
slug,
|
||||
title,
|
||||
proposed_by,
|
||||
_json.dumps([]),
|
||||
_json.dumps([]),
|
||||
_json.dumps(tags or []),
|
||||
body,
|
||||
sha,
|
||||
),
|
||||
)
|
||||
# Synthesize the per-slug meta-main row so has-commits-ahead works
|
||||
# without waiting for the reconciler.
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO cached_branches
|
||||
(rfc_slug, branch_name, head_sha, state, last_commit_at)
|
||||
VALUES (?, 'main', ?, 'open', datetime('now'))
|
||||
""",
|
||||
(slug, sha),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
PITCH = (
|
||||
"Open Human Model is a framework for representing humans.\n\n"
|
||||
"It defines consent, trait, and agency in compatible terms."
|
||||
)
|
||||
|
||||
|
||||
def test_super_draft_main_view_returns_body_and_breadcrumb(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_super_draft(fake, slug="ohm", title="Open Human Model", pitch=PITCH)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
r = client.get("/api/rfcs/ohm/main")
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["state"] == "super-draft"
|
||||
assert d["id"] is None
|
||||
assert d["repo"] is None
|
||||
assert "Open Human Model is a framework" in d["body"]
|
||||
# No edit branches yet; the dropdown is empty above 'canonical body'.
|
||||
assert d["branches"] == []
|
||||
assert d["open_prs"] == []
|
||||
|
||||
|
||||
def test_start_edit_branch_cuts_meta_branch_and_writes_cache(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
r = client.post("/api/rfcs/ohm/start-edit-branch", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
branch_name = r.json()["branch_name"]
|
||||
# §9.5: edit-<slug>-<6hex> per Slice 4's naming convention.
|
||||
assert branch_name.startswith("edit-ohm-")
|
||||
# The branch landed on Gitea.
|
||||
assert branch_name in fake.branches[("wiggleverse", "meta")]
|
||||
# The bot's audit row records the gesture.
|
||||
rows = db.conn().execute(
|
||||
"SELECT action_kind, on_behalf_of, branch_name FROM actions WHERE action_kind = 'create_branch'"
|
||||
).fetchall()
|
||||
assert any(r["branch_name"] == branch_name and r["on_behalf_of"] == "alice" for r in rows)
|
||||
# cached_branches sees the new row.
|
||||
cached = db.conn().execute(
|
||||
"SELECT branch_name FROM cached_branches WHERE rfc_slug = 'ohm' AND branch_name = ?",
|
||||
(branch_name,),
|
||||
).fetchone()
|
||||
assert cached is not None
|
||||
|
||||
|
||||
def test_branch_view_extracts_body_from_entry_envelope(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
|
||||
r = client.get(f"/api/rfcs/ohm/branches/{branch}")
|
||||
assert r.status_code == 200, r.text
|
||||
view = r.json()
|
||||
# The frontmatter is stripped — the editable body is the pitch.
|
||||
assert view["body"].startswith("Open Human Model is a framework")
|
||||
assert "---" not in view["body"]
|
||||
assert view["main_thread_id"]
|
||||
|
||||
|
||||
def test_accept_change_commits_to_meta_repo_and_preserves_frontmatter(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, entry as entry_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
|
||||
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
|
||||
thread_id = view["main_thread_id"]
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
|
||||
original, proposed, reason)
|
||||
VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, 'tightens scope')
|
||||
""",
|
||||
(
|
||||
branch,
|
||||
thread_id,
|
||||
"Open Human Model is a framework for representing humans.",
|
||||
"Open Human Model is a framework for representing humans across software systems.",
|
||||
),
|
||||
)
|
||||
change_id = cur.lastrowid
|
||||
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
|
||||
json={
|
||||
"proposed": "Open Human Model is a framework for representing humans across software systems.",
|
||||
"was_edited_before_accept": False,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
commit_sha = r.json()["commit_sha"]
|
||||
assert commit_sha
|
||||
|
||||
# The file on the meta-repo edit branch carries both the
|
||||
# frontmatter and the mutated body — the round-trip preserved
|
||||
# the envelope.
|
||||
file_content = fake.files[("wiggleverse", "meta", branch, "rfcs/ohm.md")]["content"]
|
||||
entry = entry_mod.parse(file_content)
|
||||
assert entry.state == "super-draft"
|
||||
assert entry.slug == "ohm"
|
||||
assert "across software systems" in entry.body
|
||||
# The cached change row tracks the commit.
|
||||
row = db.conn().execute(
|
||||
"SELECT state, commit_sha FROM changes WHERE id = ?", (change_id,)
|
||||
).fetchone()
|
||||
assert row["state"] == "accepted"
|
||||
assert row["commit_sha"] == commit_sha
|
||||
|
||||
|
||||
def test_manual_flush_commits_through_frontmatter_envelope(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import entry as entry_mod, db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
|
||||
new_body = PITCH + "\n\nA fresh closing paragraph that landed manually.\n"
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/manual-flush",
|
||||
json={"new_content": new_body, "paragraph_count": 1},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["commit_sha"]
|
||||
|
||||
file_content = fake.files[("wiggleverse", "meta", branch, "rfcs/ohm.md")]["content"]
|
||||
entry = entry_mod.parse(file_content)
|
||||
assert "fresh closing paragraph" in entry.body
|
||||
# §10.6: a system-author chat message records the flush.
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT m.role, m.text FROM thread_messages m
|
||||
JOIN threads t ON t.id = m.thread_id
|
||||
WHERE t.rfc_slug = 'ohm' AND t.branch_name = ?
|
||||
""",
|
||||
(branch,),
|
||||
).fetchall()
|
||||
assert any(r["role"] == "system" and "manual edit" in r["text"] for r in rows)
|
||||
|
||||
|
||||
def test_open_pr_on_super_draft_lands_as_meta_body_edit(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
|
||||
# Accept one change so the branch has commits ahead of meta-main.
|
||||
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
|
||||
thread_id = view["main_thread_id"]
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
|
||||
original, proposed, reason)
|
||||
VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, 'test')
|
||||
""",
|
||||
(
|
||||
branch,
|
||||
thread_id,
|
||||
"Open Human Model is a framework for representing humans.",
|
||||
"Open Human Model is a framework for representing humans across systems.",
|
||||
),
|
||||
)
|
||||
change_id = cur.lastrowid
|
||||
client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
|
||||
json={
|
||||
"proposed": "Open Human Model is a framework for representing humans across systems.",
|
||||
"was_edited_before_accept": False,
|
||||
},
|
||||
)
|
||||
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/open-pr",
|
||||
json={"title": "Tighten scope", "description": "Scope to systems."},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
pr_number = r.json()["pr_number"]
|
||||
# cached_prs records pr_kind='meta_body_edit'.
|
||||
row = db.conn().execute(
|
||||
"SELECT pr_kind, repo, head_branch FROM cached_prs WHERE pr_number = ?",
|
||||
(pr_number,),
|
||||
).fetchone()
|
||||
assert row["pr_kind"] == "meta_body_edit"
|
||||
assert row["repo"] == "wiggleverse/meta"
|
||||
assert row["head_branch"] == branch
|
||||
|
||||
# The §10.3 PR view payload renders against the meta repo with
|
||||
# the body extracted from the envelope on both sides.
|
||||
d = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
|
||||
assert "across systems" in d["branch_body"]
|
||||
assert "across systems" not in d["main_body"]
|
||||
assert d["state"] == "open"
|
||||
|
||||
|
||||
def test_merge_super_draft_body_edit_propagates_to_canonical_body(app_with_fake_gitea):
|
||||
"""The whole §9.5 loop end-to-end: cut an edit branch, accept a
|
||||
change, open a body-edit PR, merge it, and watch the super-draft's
|
||||
cached body update."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
|
||||
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
|
||||
thread_id = view["main_thread_id"]
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
|
||||
original, proposed, reason)
|
||||
VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, 'tightens')
|
||||
""",
|
||||
(
|
||||
branch,
|
||||
thread_id,
|
||||
"It defines consent, trait, and agency in compatible terms.",
|
||||
"It defines consent, trait, harm, and agency in compatible terms.",
|
||||
),
|
||||
)
|
||||
change_id = cur.lastrowid
|
||||
client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
|
||||
json={
|
||||
"proposed": "It defines consent, trait, harm, and agency in compatible terms.",
|
||||
"was_edited_before_accept": False,
|
||||
},
|
||||
)
|
||||
pr_number = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/open-pr",
|
||||
json={"title": "Add harm", "description": "Adds harm to the dimension list."},
|
||||
).json()["pr_number"]
|
||||
|
||||
# An unclaimed super-draft: only app admins/owners can merge per §9.5.
|
||||
# Alice is a contributor — refused.
|
||||
r = client.post(f"/api/rfcs/ohm/prs/{pr_number}/merge")
|
||||
assert r.status_code == 403
|
||||
|
||||
# Ben (app owner) can merge.
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
r = client.post(f"/api/rfcs/ohm/prs/{pr_number}/merge")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Meta-main's rfcs/ohm.md now carries the body change; the
|
||||
# cache picks it up; the catalog/view render the new body.
|
||||
r = client.get("/api/rfcs/ohm/main")
|
||||
d = r.json()
|
||||
assert "harm" in d["body"]
|
||||
|
||||
|
||||
def test_metadata_pane_pr_propagates_title_on_merge(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/metadata",
|
||||
json={"title": "Open Human Model", "tags": ["identity", "schema"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
pr_number = r.json()["pr_number"]
|
||||
metadata_branch = r.json()["branch_name"]
|
||||
assert metadata_branch.startswith("metadata-ohm-")
|
||||
|
||||
# cached_prs records pr_kind='meta_metadata'.
|
||||
row = db.conn().execute(
|
||||
"SELECT pr_kind, title FROM cached_prs WHERE pr_number = ?",
|
||||
(pr_number,),
|
||||
).fetchone()
|
||||
assert row["pr_kind"] == "meta_metadata"
|
||||
|
||||
# Merge the metadata PR — the per-RFC PR-flow merge endpoint
|
||||
# works only for meta_body_edit (rfc_branch) kinds. For metadata
|
||||
# PRs, we exercise the bot's merge against the meta repo directly
|
||||
# via the underlying Gitea client.
|
||||
from app.bot import Actor
|
||||
bot = app.state.bot
|
||||
actor = Actor(user_id=1, gitea_login="ben", display_name="Ben", email="ben@test")
|
||||
import asyncio
|
||||
asyncio.run(_merge_meta_pr(bot, actor, pr_number=pr_number, slug="ohm"))
|
||||
|
||||
# Refresh the meta cache so the new title surfaces.
|
||||
from app import cache as cache_mod
|
||||
asyncio.run(cache_mod.refresh_meta_repo(app.state.config, app.state.gitea))
|
||||
asyncio.run(cache_mod.refresh_meta_pulls(app.state.config, app.state.gitea))
|
||||
|
||||
d = client.get("/api/rfcs/ohm").json()
|
||||
assert d["title"] == "Open Human Model"
|
||||
assert "identity" in d["tags"]
|
||||
|
||||
|
||||
async def _merge_meta_pr(bot, actor, *, pr_number, slug):
|
||||
"""Helper: invoke the bot's merge path against the meta repo. The
|
||||
metadata-PR merge surface isn't exposed via api_prs (which only
|
||||
handles body-edit PRs) — admins/owners merge through Gitea directly
|
||||
via the bot for v1. A dedicated metadata-PR merge endpoint earns its
|
||||
own §19.2 candidate if usage shows demand."""
|
||||
# We use merge_branch_pr as the bot's generic meta-merge primitive;
|
||||
# it takes owner/repo, which dispatches to the meta repo here.
|
||||
await bot.merge_branch_pr(
|
||||
actor,
|
||||
owner="wiggleverse",
|
||||
repo="meta",
|
||||
pr_number=pr_number,
|
||||
head_branch=f"metadata-{slug}-stub", # name only used in the audit log
|
||||
slug=slug,
|
||||
)
|
||||
|
||||
|
||||
def test_super_draft_canonical_body_branch_main_is_read_only(app_with_fake_gitea):
|
||||
"""branchParam='main' on a super-draft view fetches meta-main's
|
||||
rfcs/<slug>.md but contributing is refused — the only edit path is
|
||||
via start-edit-branch."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
r = client.get("/api/rfcs/ohm/branches/main")
|
||||
assert r.status_code == 200, r.text
|
||||
view = r.json()
|
||||
assert view["branch_name"] == "main"
|
||||
assert "Open Human Model" in view["body"]
|
||||
# Capabilities reflect read-only.
|
||||
assert view["capabilities"]["can_contribute"] is False
|
||||
|
||||
# A manual-flush against 'main' is refused as a contribute check.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/branches/main/manual-flush",
|
||||
json={"new_content": "hijacked\n", "paragraph_count": 1},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_metadata_pane_refused_for_plain_contributor(app_with_fake_gitea):
|
||||
"""§9.5: until the §13.1 claim runs, the metadata pane is limited to
|
||||
app admins/owners. A plain contributor is refused."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/metadata",
|
||||
json={"title": "Sneak title"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
+115
-42
@@ -186,6 +186,63 @@ posting, arbiter-only merge, contributor withdraw with the
|
||||
of a public PR, and the full §10.9 conflict-replay path including
|
||||
the auto-close of the original PR on the resolution PR's merge.
|
||||
|
||||
### Slice 4 — shipped
|
||||
|
||||
Super-draft body editing per §9.5 + §9.6 + §9.7. The §17 routing-collapse
|
||||
rule landed in `backend/app/api_branches.py` and `backend/app/api_prs.py`
|
||||
— every `branches/<branch>/...` and `prs/<n>/...` route now 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 wrapper grew `open_metadata_pr`; the rest of the
|
||||
bot's methods already accepted owner/repo arguments and worked against
|
||||
the meta repo without change. The §4 cache learned about meta-repo edit
|
||||
branches via a new `refresh_meta_branches` pass that mirrors
|
||||
`edit-<slug>-<6hex>` branches into `cached_branches` and synthesizes a
|
||||
per-slug `main` row so the §10.1 has-commits-ahead check works
|
||||
uniformly across active and super-draft surfaces. The §5 schema needed
|
||||
no migration — the super-draft scoping note already settled that the
|
||||
existing tables carry both cases.
|
||||
|
||||
The two §17 routes Slice 4 added:
|
||||
|
||||
| Method | Path | § |
|
||||
| ------ | -------------------------------------- | ------- |
|
||||
| POST | `/api/rfcs/{slug}/start-edit-branch` | §9.5 |
|
||||
| POST | `/api/rfcs/{slug}/metadata` | §9.5 |
|
||||
|
||||
Everything else from the §8 vertical (chat, accept, decline, manual
|
||||
flush, threads, flags, visibility, grants, the SSE chat stream) and the
|
||||
§10 PR flow (open, draft, review, merge, withdraw, conflict-replay)
|
||||
reaches super-drafts through the same routes Slices 2 and 3 shipped —
|
||||
no per-state forks at the API surface.
|
||||
|
||||
The branch-naming choice: §9.5 names the structural shape
|
||||
`edit/<slug>/<auto-name>`, but FastAPI's default `{branch}` path matcher
|
||||
refuses slashes (the §19.2 path-routing candidate). Slice 4 picked
|
||||
`edit-<slug>-<6hex>` — same dash-separated shape Slice 2 used for
|
||||
`<login>-draft-<6hex>`. Metadata-pane PRs use the parallel
|
||||
`metadata-<slug>-<6hex>` form. The cache parsers in `app/cache.py`
|
||||
recognize both the dashed and slashed prefixes so a future routing-fix
|
||||
slice can flip back without a data migration.
|
||||
|
||||
On the frontend, `RFCView.jsx`'s super-draft placeholder was replaced
|
||||
by the full editor surface — same component, dispatched on
|
||||
`entry.state`. The `BranchDropdown` renders `canonical body` as the
|
||||
first position when the entry is a super-draft, per §9.4. A new
|
||||
`MetadataPaneModal` opens from the breadcrumb actions when the viewer
|
||||
holds super-draft edit authority per §9.5 (until §13.1's claim runs,
|
||||
that's app admins/owners only).
|
||||
|
||||
Slice 4 ships covered by `backend/tests/test_super_draft_vertical.py` —
|
||||
ten integration tests against the FakeGitea, covering main-view read,
|
||||
start-edit-branch, body extraction from the envelope on read, accept
|
||||
preserving the frontmatter on write, manual flush through the envelope,
|
||||
the body-edit PR's `pr_kind='meta_body_edit'` shape, the full
|
||||
cut-accept-open-merge loop with the §9.5 unclaimed-merge gate
|
||||
(admin/owner only), the metadata pane PR cycle, the canonical-body
|
||||
branch (`main` for super-drafts) being read-only, and the metadata pane
|
||||
permission gate.
|
||||
|
||||
### What's deferred from Slice 2
|
||||
|
||||
These were in the §8 spec but lean on infrastructure later slices
|
||||
@@ -259,51 +316,67 @@ spec:
|
||||
|
||||
## Next slice
|
||||
|
||||
**Slice 4: super-draft body editing per §9.5 + §9.6.**
|
||||
**Slice 5: graduation per §13.**
|
||||
|
||||
The §8 within-branch surface and the §10 bridge to main now ship for
|
||||
active RFCs; the same mechanics still need to reach super-draft
|
||||
entries on the meta repo. Slice 4's unit of work is the meta-repo
|
||||
edit branch — `edit/<slug>/<auto-name>` per §9.5 — and the
|
||||
structural claim is that almost everything from §8 falls out
|
||||
unchanged once `<slug>` resolves to a super-draft entry and
|
||||
`<branch>` names a meta-repo branch rather than a per-RFC-repo
|
||||
branch (see the §5 super-draft scoping note).
|
||||
A super-draft becomes an active RFC through the §13 graduation
|
||||
sequence — the dialog (§13.2), the five-step transactional sequence
|
||||
with rollback (§13.3), the chat-follows-the-work migration (§13.4),
|
||||
the pre-graduation history affordance for the new RFC view (§9.8),
|
||||
and the precondition gate that refuses to graduate while body-edit
|
||||
PRs are open (§9.8 / §13.3).
|
||||
|
||||
What Slice 4 owns specifically:
|
||||
Slice 4 left this clean: the §9.5 metadata pane, the body-edit PR
|
||||
flow, and the active-RFC PR flow all converge on the same dispatch.
|
||||
Graduation is the act that flips an entry's state from `super-draft`
|
||||
to `active`, creates the per-RFC repo via `bot.ensure_rfc_repo_seed`
|
||||
(which Slice 2 added as a forward-looking seam), copies the body
|
||||
from the frontmatter envelope into the new repo's `RFC.md`, strips
|
||||
the body field from the meta-repo entry, mints the integer ID and
|
||||
fills the `repo`/`graduated_at`/`graduated_by` fields, and migrates
|
||||
the whole-doc main thread's chat to the new RFC's `branch_name=null`
|
||||
thread per §13.4.
|
||||
|
||||
- §9.5's `Start Contributing` on a super-draft cutting an
|
||||
`edit/<slug>/<auto-name>` branch on the meta repo via the bot,
|
||||
re-anchoring pending `changes` rows from `main` to the new branch
|
||||
the way `promote-to-branch` does for active RFCs.
|
||||
- §9.6's chat-and-threads surface scoped to the super-draft and to
|
||||
edit branches, sharing the §5 `threads`/`thread_messages` shape.
|
||||
- §9.7's visibility and contribute grants on edit branches — the
|
||||
same `branch_visibility` / `branch_contribute_grants` machinery
|
||||
that Slice 2 wired, now keyed on the meta repo.
|
||||
- The metadata pane from §9.5 — title and tag edits as small
|
||||
meta-repo PRs via `POST /api/rfcs/{slug}/metadata`. Slug renames
|
||||
remain deferred per §9.5 / §19.2.
|
||||
- The §17 routing collapse the spec calls for: the
|
||||
`branches/<branch>/...` endpoint family already exists; Slice 4's
|
||||
job is the dispatch in `api_branches.py` that recognizes a
|
||||
super-draft slug and routes to the meta repo on every read and
|
||||
write. `RFCView.jsx`'s super-draft placeholder is replaced by the
|
||||
full editor surface.
|
||||
What Slice 5 owns specifically:
|
||||
|
||||
What Slice 4 does NOT own: the §10 PR flow against the meta repo's
|
||||
super-draft edits is structurally identical to the active-RFC PR
|
||||
flow Slice 3 just shipped, and falls out from the same dispatch.
|
||||
The graduation flow from §13 stays deferred to Slice 5.
|
||||
- The §13.2 Graduate dialog — three fields (integer ID, repo name,
|
||||
initial owners), the inline-validation endpoint
|
||||
`GET /api/rfcs/{slug}/graduate/check`, the blocking-PRs popover
|
||||
via `GET /api/rfcs/{slug}/blocking-prs`, and the merge-actor set
|
||||
per §13's authority rules.
|
||||
- The §13.3 transactional sequence — five steps emitted as an SSE
|
||||
stream via `GET /api/rfcs/{slug}/graduate/progress`, with each
|
||||
step's `pending → running → done/failed` transitions surfacing in
|
||||
the dialog, and a trailing `rollback` step if any earlier step
|
||||
fails. The bot grows `graduate` plus the rollback primitives the
|
||||
sequence needs.
|
||||
- The §13.4 chat migration — the whole-doc main thread on the
|
||||
super-draft (`rfc_slug=<slug>`, `branch_name='main'`) re-anchors
|
||||
onto the new RFC's main thread; range and paragraph sub-threads
|
||||
on the canonical-body view migrate too per §9.8's clarification.
|
||||
Edit-branch chats stay attached to their original `branch_name`
|
||||
on the meta repo per §9.8 — no data movement, surfaced by the
|
||||
pre-graduation history affordance.
|
||||
- The §9.8 pre-graduation history affordance on the new RFC view —
|
||||
the slug remains the canonical key per §2.3, so the query is a
|
||||
straightforward lookup of `threads` and `changes` rows where
|
||||
`rfc_slug = <slug>` and `branch_name` begins with the meta-repo
|
||||
edit prefix.
|
||||
|
||||
The carryovers Slice 4 inherits — none new from the prototype;
|
||||
every §8 / §10 surface already exists. The work is dispatch glue
|
||||
plus a small number of routes that need the meta-repo path
|
||||
(`branches/edit/<slug>/<auto-name>` cuts).
|
||||
What Slice 5 does NOT own:
|
||||
|
||||
The next build session should read `SPEC.md`, `README.md`, and
|
||||
`docs/DEV.md` and pick up Slice 4 cleanly without re-briefing. The
|
||||
working agreement in §19.3 continues to apply: implement the slice,
|
||||
correct the spec only where running code reveals it was wrong at a
|
||||
structural level, accumulate new candidate topics in §19.2, do not
|
||||
extend the spec beyond what the slice requires.
|
||||
- The §15 notification surface (still Slice 6).
|
||||
- The §14 chrome polish (still Slice 7).
|
||||
- The §12 30/90 branch-hygiene timers (still Slice 8).
|
||||
|
||||
The carryovers Slice 5 inherits — the `ensure_rfc_repo_seed`
|
||||
primitive Slice 2 added, the body-edit-PR precondition gate
|
||||
(checked against the same `cached_prs` shape Slice 4 wired), and
|
||||
the existing `actions` audit-log shape for the rollback record.
|
||||
|
||||
The next build session should read `SPEC.md`, `README.md`,
|
||||
`docs/DEV.md`, and `SPEC.md`'s §19.1 and pick up Slice 5 cleanly
|
||||
without re-briefing. The working agreement in §19.3 continues to
|
||||
apply: implement the slice, correct the spec only where running
|
||||
code reveals it was wrong at a structural level, accumulate new
|
||||
candidate topics in §19.2, do not extend the spec beyond what the
|
||||
slice requires.
|
||||
|
||||
@@ -197,6 +197,30 @@ export async function resolveThread(slug, branch, threadId) {
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── Slice 4: super-draft body editing (§9.5) ─────────────────────────────
|
||||
|
||||
export async function startEditBranch(slug, body = {}) {
|
||||
const res = await fetch(`/api/rfcs/${slug}/start-edit-branch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function editMetadata(slug, { title, tags, prDescription }) {
|
||||
const res = await fetch(`/api/rfcs/${slug}/metadata`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title ?? null,
|
||||
tags: tags ?? null,
|
||||
pr_description: prDescription ?? null,
|
||||
}),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── Slice 3: the §10 PR flow ─────────────────────────────────────────────
|
||||
|
||||
export async function draftPRText(slug, branch) {
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
// RFCView.jsx — the §8 active-RFC view.
|
||||
// RFCView.jsx — the §8 active-RFC view and (per §17's routing-collapse
|
||||
// rule + §9.4) the §9.4 super-draft view.
|
||||
//
|
||||
// Three-column shape per §8.1 (catalog left, this component's content
|
||||
// in the middle and right). Opens on main in discuss mode per §8.2;
|
||||
// supports the §8.3 discuss-vs-contribute flip on non-main branches.
|
||||
// "Start Contributing" on main calls the §17 promote-to-branch
|
||||
// endpoint; on a non-main branch it is a pure mode flip per §8.14.
|
||||
// Three-column shape per §8.1. Opens on main in discuss mode per §8.2
|
||||
// for active RFCs, on the canonical body in discuss mode per §9.4 for
|
||||
// super-drafts. The discuss-vs-contribute flip on non-main / non-edit
|
||||
// branches per §8.3 carries over unchanged.
|
||||
//
|
||||
// The render path inherits the §18 carryovers: Tiptap editor, the
|
||||
// <change> parser (which the backend owns, not the frontend), the
|
||||
// SelectionTooltip, the prompt bar, the change-card panel, the
|
||||
// DiffView toggle.
|
||||
//
|
||||
// Super-draft entries are deferred to Slice 4 per docs/DEV.md; this
|
||||
// component renders a polite "open in Slice 4" placeholder for them.
|
||||
// The active-RFC and super-draft surfaces share their editor,
|
||||
// chat, change-card, DiffView, selection-tooltip, and PR-modal
|
||||
// machinery; the only branchings are "Start Contributing" (which
|
||||
// dispatches to promote-to-branch for active main and start-edit-branch
|
||||
// for a super-draft's canonical body per §9.5) and the metadata pane
|
||||
// (super-draft-only per §9.5).
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
acceptChange as apiAccept,
|
||||
createThread,
|
||||
declineChange as apiDecline,
|
||||
editMetadata,
|
||||
getBranch,
|
||||
getRFC,
|
||||
getRFCMain,
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
reaskChange,
|
||||
resolveThread,
|
||||
setBranchVisibility,
|
||||
startEditBranch,
|
||||
streamChatTurn,
|
||||
} from '../api'
|
||||
import Editor, { selectionHighlightKey } from './Editor.jsx'
|
||||
@@ -100,12 +101,17 @@ export default function RFCView({ viewer }) {
|
||||
.catch(() => {})
|
||||
}, [slug])
|
||||
|
||||
// Slice 4 owns super-draft body editing; render a placeholder for now.
|
||||
// Per §9.4 / §17's routing-collapse rule, super-drafts render through
|
||||
// the same surface as active RFCs — the bot, the chat, the change
|
||||
// panel, and the PR flow all dispatch on the entry's state internally.
|
||||
// The view-level differences are: the breadcrumb's state label, the
|
||||
// metadata pane, and the start-contributing dispatch target.
|
||||
const isSuperDraft = entry?.state === 'super-draft'
|
||||
const [showMetadataPane, setShowMetadataPane] = useState(false)
|
||||
|
||||
// Load main view + branch view whenever slug/branch changes.
|
||||
useEffect(() => {
|
||||
if (!entry || entry.state !== 'active') return
|
||||
if (!entry || (entry.state !== 'active' && entry.state !== 'super-draft')) return
|
||||
setError(null)
|
||||
setEditorContent('')
|
||||
setMessages([])
|
||||
@@ -217,7 +223,11 @@ export default function RFCView({ viewer }) {
|
||||
if (!viewer) { window.location.href = '/auth/login'; return }
|
||||
if (branchParam === 'main') {
|
||||
try {
|
||||
const { branch_name } = await promoteToBranch(slug)
|
||||
// §9.5 dispatch: super-drafts cut a meta-repo edit branch via
|
||||
// start-edit-branch; active RFCs run §8.14's promote-to-branch.
|
||||
const { branch_name } = isSuperDraft
|
||||
? await startEditBranch(slug)
|
||||
: await promoteToBranch(slug)
|
||||
setSearchParams({ branch: branch_name })
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
@@ -226,12 +236,10 @@ export default function RFCView({ viewer }) {
|
||||
}
|
||||
// Non-main: pure mode flip per §8.14.
|
||||
if (pendingDiscussChanges.length > 0) {
|
||||
// The §8.14 buffered proposals are already `pending` rows on the
|
||||
// backend — surfacing the change panel exposes them.
|
||||
setPendingDiscussChanges([])
|
||||
}
|
||||
setMode('contribute')
|
||||
}, [viewer, slug, branchParam, pendingDiscussChanges, setSearchParams])
|
||||
}, [viewer, slug, branchParam, pendingDiscussChanges, setSearchParams, isSuperDraft])
|
||||
|
||||
// ── Submit a chat turn (prompt bar or selection tooltip) ───────────────
|
||||
const submitChatTurn = useCallback(async (text, quote) => {
|
||||
@@ -425,22 +433,7 @@ export default function RFCView({ viewer }) {
|
||||
// Render early-out states.
|
||||
if (error) return <article className="entry-view"><p>Error: {error}</p></article>
|
||||
if (!entry) return <article className="entry-view">Loading…</article>
|
||||
if (isSuperDraft) {
|
||||
return (
|
||||
<article className="entry-view">
|
||||
<div className="entry-state-banner">Super-draft</div>
|
||||
<h1 className="entry-title">{entry.title}</h1>
|
||||
<p className="field-help">
|
||||
Super-draft body editing on the meta repo lands in Slice 4 per
|
||||
<code> docs/DEV.md</code>. The Slice 2 view is scoped to active
|
||||
RFCs — chat, branches, change panel, AI participation. The
|
||||
super-draft body below is the pitch as merged.
|
||||
</p>
|
||||
<div className="entry-body" style={{ whiteSpace: 'pre-wrap' }}>{entry.body}</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
if (entry.state !== 'active') {
|
||||
if (entry.state !== 'active' && entry.state !== 'super-draft') {
|
||||
return <article className="entry-view"><p>This RFC is {entry.state}.</p></article>
|
||||
}
|
||||
if (!branchView) return <article className="entry-view">Loading branch…</article>
|
||||
@@ -468,19 +461,22 @@ export default function RFCView({ viewer }) {
|
||||
<div className="rfc-view">
|
||||
{/* Breadcrumb */}
|
||||
<div className="rfc-breadcrumb">
|
||||
<span className="breadcrumb-label">{entry.id || 'active'}</span>
|
||||
<span className="breadcrumb-label">
|
||||
{isSuperDraft ? 'super-draft' : (entry.id || 'active')}
|
||||
</span>
|
||||
<span className="breadcrumb-sep">›</span>
|
||||
<strong>{entry.title}</strong>
|
||||
<span className="breadcrumb-sep">›</span>
|
||||
<BranchDropdown
|
||||
current={branchParam}
|
||||
mainView={mainView}
|
||||
isSuperDraft={isSuperDraft}
|
||||
onPick={onPickBranch}
|
||||
viewer={viewer}
|
||||
/>
|
||||
<span className="breadcrumb-sep">·</span>
|
||||
<span className="breadcrumb-meta">
|
||||
{mainView ? `${mainView.branches.length} branch${mainView.branches.length === 1 ? '' : 'es'}` : '…'}
|
||||
{mainView ? `${mainView.branches.length} ${isSuperDraft ? 'edit branch' : 'branch'}${mainView.branches.length === 1 ? '' : 'es'}` : '…'}
|
||||
{mainView && mainView.open_prs.length > 0 && ` · ${mainView.open_prs.length} PR${mainView.open_prs.length === 1 ? '' : 's'}`}
|
||||
</span>
|
||||
<div className="breadcrumb-actions">
|
||||
@@ -532,6 +528,16 @@ export default function RFCView({ viewer }) {
|
||||
onClick={() => setShowVisibility(true)}
|
||||
>Branch settings</button>
|
||||
)}
|
||||
{isSuperDraft && branchView?.capabilities?.can_edit_metadata && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link"
|
||||
onClick={() => setShowMetadataPane(true)}
|
||||
title="§9.5 — open a small meta-repo PR to edit title or tags"
|
||||
>
|
||||
Metadata
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -540,8 +546,9 @@ export default function RFCView({ viewer }) {
|
||||
<div className="editor-area" onClick={handleEditorClick}>
|
||||
{branchParam === 'main' && (
|
||||
<div className="discuss-mode-banner">
|
||||
main is read-only — PRs are the only path to change it.
|
||||
Open a branch to propose edits.
|
||||
{isSuperDraft
|
||||
? 'Canonical body is read-only — PRs against the meta repo are the only path to change it. Use Start Contributing to cut an edit branch.'
|
||||
: 'main is read-only — PRs are the only path to change it. Open a branch to propose edits.'}
|
||||
</div>
|
||||
)}
|
||||
{inDiscuss && branchParam !== 'main' && (
|
||||
@@ -685,6 +692,22 @@ export default function RFCView({ viewer }) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showMetadataPane && (
|
||||
<MetadataPaneModal
|
||||
slug={slug}
|
||||
currentTitle={entry.title}
|
||||
currentTags={entry.tags || []}
|
||||
onClose={() => setShowMetadataPane(false)}
|
||||
onOpened={(prNumber) => {
|
||||
setShowMetadataPane(false)
|
||||
// Refresh main view so the new open PR surfaces in the
|
||||
// breadcrumb meta count immediately.
|
||||
getRFCMain(slug).then(setMainView).catch(() => {})
|
||||
navigate(`/rfc/${slug}/pr/${prNumber}`)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -746,9 +769,14 @@ async function loadAllMessages(slug, branch, threads) {
|
||||
return all
|
||||
}
|
||||
|
||||
function BranchDropdown({ current, mainView, onPick }) {
|
||||
function BranchDropdown({ current, mainView, isSuperDraft, onPick }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const items = [{ name: 'main' }, ...(mainView?.branches || [])]
|
||||
// For super-drafts the first dropdown position is "canonical body"
|
||||
// per §9.4 — the entry as it appears on meta-repo main. For active
|
||||
// RFCs it is literally `main` per §8.1.
|
||||
const mainLabel = isSuperDraft ? 'canonical body' : 'main'
|
||||
const items = [{ name: 'main', label: mainLabel }, ...(mainView?.branches || [])]
|
||||
const currentLabel = current === 'main' ? mainLabel : current
|
||||
return (
|
||||
<div className="branch-dropdown">
|
||||
<button
|
||||
@@ -756,7 +784,7 @@ function BranchDropdown({ current, mainView, onPick }) {
|
||||
className="branch-dropdown-trigger"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
>
|
||||
{current === 'main' ? 'main' : current} ▾
|
||||
{currentLabel} ▾
|
||||
</button>
|
||||
{open && (
|
||||
<div className="branch-dropdown-menu" onMouseLeave={() => setOpen(false)}>
|
||||
@@ -767,7 +795,7 @@ function BranchDropdown({ current, mainView, onPick }) {
|
||||
className={`branch-dropdown-item ${b.name === current ? 'active' : ''}`}
|
||||
onClick={() => { setOpen(false); onPick(b.name) }}
|
||||
>
|
||||
<span className="branch-name">{b.name}</span>
|
||||
<span className="branch-name">{b.label || b.name}</span>
|
||||
{b.visibility && b.name !== 'main' && !b.visibility.read_public && (
|
||||
<span className="branch-private-icon" title="Private">🔒</span>
|
||||
)}
|
||||
@@ -782,6 +810,88 @@ function BranchDropdown({ current, mainView, onPick }) {
|
||||
)
|
||||
}
|
||||
|
||||
function MetadataPaneModal({ slug, currentTitle, currentTags, onClose, onOpened }) {
|
||||
// §9.5: a small meta-repo PR that touches only frontmatter. Slug
|
||||
// renames are deferred per §9.5 and the §19.2 candidate — the slug
|
||||
// field is rendered read-only as a deliberate signal.
|
||||
const [title, setTitle] = useState(currentTitle || '')
|
||||
const [tagsRaw, setTagsRaw] = useState((currentTags || []).join(', '))
|
||||
const [prDescription, setPrDescription] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [err, setErr] = useState(null)
|
||||
|
||||
const onSave = async () => {
|
||||
setSaving(true); setErr(null)
|
||||
try {
|
||||
const tags = tagsRaw
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean)
|
||||
const result = await editMetadata(slug, {
|
||||
title: title.trim(),
|
||||
tags,
|
||||
prDescription: prDescription.trim() || null,
|
||||
})
|
||||
if (result?.noop) {
|
||||
setErr('No changes — title and tags match the current entry.')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
onOpened(result.pr_number)
|
||||
} catch (e) {
|
||||
setErr(e.message)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2>Edit metadata</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<label>Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<label style={{ marginTop: 12 }}>Tags (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tagsRaw}
|
||||
onChange={e => setTagsRaw(e.target.value)}
|
||||
disabled={saving}
|
||||
placeholder="identity, schema"
|
||||
/>
|
||||
<label style={{ marginTop: 12 }}>PR description (optional)</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={prDescription}
|
||||
onChange={e => setPrDescription(e.target.value)}
|
||||
disabled={saving}
|
||||
placeholder="What changed and why."
|
||||
/>
|
||||
<p className="field-help" style={{ marginTop: 12 }}>
|
||||
§9.5: title and tag edits open a small meta-repo PR via the bot.
|
||||
Slug renames are not supported in v1.
|
||||
</p>
|
||||
{err && <p className="field-error">{err}</p>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn-secondary" onClick={onClose} disabled={saving}>Cancel</button>
|
||||
<button className="btn-primary" onClick={onSave} disabled={saving}>
|
||||
{saving ? 'Opening PR…' : 'Open metadata PR'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BranchVisibilityModal({ slug, branch, current, onClose, onSaved }) {
|
||||
const [readPublic, setReadPublic] = useState(!!current?.read_public)
|
||||
const [contributeMode, setContributeMode] = useState(current?.contribute_mode || 'just-me')
|
||||
|
||||
Reference in New Issue
Block a user