Slice 5: graduation per §13

The §13.3 transactional sequence flips a super-draft to active —
five steps with paired undoes, an in-process orchestrator fed by
an asyncio.Queue, the §17 SSE endpoint streaming step transitions
to the dialog. Each step is a new bot primitive that logs an
`actions` row, bracketed by `graduate_start` / `graduate_complete`
for the linkable audit sequence. Rollback runs the undoes in
reverse from the last completed step; merge_pr has no undo by
design per §13.5.

The §9.8 precondition gate is enforced server-side at the top of
POST /graduate so the §13.3 rollback complexity does not grow.
The §13.4 chat migration is a database semantic no-op — the
(slug, branch_name='main') threads keep their identity, only the
interpretation changes. The §9.8 pre-graduation history surfaces
via a new _is_meta_target(rfc, branch) dispatch helper and lands
as pre_graduation_history on /main.

§13.1 claim flow landed alongside since it's the prerequisite for
non-admin graduation — bot.open_claim_pr plus broadening
api_prs._require_pr to accept meta_claim.

45/45 tests green; ten new integration tests cover the validator,
the §9.8 precondition refusal, happy path with audit verification,
mid-sequence rollback at steps 2 and 3, concurrent refusal,
chat-survives-without-data-movement, pre-graduation history, and
the §13.1 claim PR cycle.

SPEC.md §19.1 rewritten for Slice 6 (notifications); §19.2 grew
four candidates surfaced during the slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 21:52:29 -07:00
parent 4565a6cb95
commit 1b0968a9a2
14 changed files with 2872 additions and 172 deletions
+42 -20
View File
@@ -148,22 +148,42 @@ Open `http://localhost:5173`. Sign in with your owner-zero Gitea
account. The catalog should appear empty; the **+ Propose New RFC** account. The catalog should appear empty; the **+ Propose New RFC**
button at the bottom opens the propose modal. button at the bottom opens the propose modal.
## What slice 1 lets you do ## What the build lets you do so far
End-to-end: propose a new RFC → an idea PR opens against the meta Slices 15 are shipped. End-to-end paths the app supports today:
repo → an owner merges from the pending-idea view → the super-draft
appears in the catalog → opening it renders the body.
This exercises the §4 cache (webhook + reconciler), the §6 permission - **Propose → idea PR → merge → super-draft** (Slice 1, §9.1–§9.3).
model (the owner-only merge button, the contributor-only propose - **Super-draft body editing** via meta-repo edit branches, with AI
modal), the §1 bot wrapper (every Git write goes through it, every participation, the change-card panel, manual flushes, threads,
commit and PR carries the `On-behalf-of:` trailer), and the §9 flags, and DiffView (Slice 4, §9.5–§9.7 + §8 inherited).
propose-merge-render path. - **The §8 active-RFC view** in full: per-branch chat, AI
participation through the `<change>` protocol, accept / decline /
edit, manual-edit flushes, sub-threads, flags, DiffView (Slice 2,
§8 in full).
- **The §10 PR flow** against both per-RFC repos and meta-repo edit
branches: open, AI-drafted title and description, the §10.3
review page with the per-user seen-cursor, review threads,
merge, withdraw, the §10.9 conflict-replay path (Slice 3 + Slice 4's
routing-collapse extension, §10 in full).
- **§13 graduation** with the three-field dialog, the precondition
popover for blocking body-edit PRs, the SSE-streamed five-step
sequence, rollback on mid-sequence failure, and the §9.8
pre-graduation history affordance on the new RFC view (Slice 5,
§13 in full).
- **§13.1 ownership claim** as a meta-repo PR adding the claimant
to the entry's `owners:` field; admin/owner merges the PR (Slice 5).
Out of scope for slice 1: the active-RFC view (§8), per-branch chat, This exercises the §4 cache (webhook + reconciler), the §6
AI participation, the change-card panel, PRs against per-RFC repos, permission model in full, the §1 bot wrapper (every Git write goes
graduation, notifications, the landing page's full polish. Those through it, every commit and PR carries the `On-behalf-of:`
slices are listed in [`docs/DEV.md`](./docs/DEV.md). trailer), and the §17 routing-collapse rule that lets active and
super-draft surfaces share their endpoints.
Out of scope for the slices shipped so far: notifications (Slice 6,
§15), landing-page and `/philosophy` chrome polish (Slice 7, §14),
the §12 30/90 branch-hygiene timers (Slice 8). The full slicing
plan and the next slice's brief live in
[`docs/DEV.md`](./docs/DEV.md).
## Verifying it worked ## Verifying it worked
@@ -177,11 +197,12 @@ After bring-up:
## Seeding an active RFC for §8 testing ## Seeding an active RFC for §8 testing
Slice 2 (the active-RFC view per §8) needs an entry whose `state` is With Slice 5 shipped, the `/graduate` flow in the app is the
`active` and whose per-RFC repo exists. Slice 5's graduation flow canonical path from super-draft to active. The
will land the proper path; until then, `scripts/seed_test_rfc.py` is [`scripts/seed_test_rfc.py`](./scripts/seed_test_rfc.py) shortcut is
the dev shortcut. Sign in once via OAuth so a `users` row exists, still around for dev sessions that want an active RFC without
then: running the §9.1 propose flow and the §13 graduation dialog by
hand. Sign in once via OAuth so a `users` row exists, then:
```sh ```sh
cd backend && .venv/bin/python ../scripts/seed_test_rfc.py \ cd backend && .venv/bin/python ../scripts/seed_test_rfc.py \
@@ -190,8 +211,9 @@ cd backend && .venv/bin/python ../scripts/seed_test_rfc.py \
``` ```
The script creates `wiggleverse/rfc-NNNN-<slug>`, seeds `RFC.md` on The script creates `wiggleverse/rfc-NNNN-<slug>`, seeds `RFC.md` on
`main`, registers the webhook, and graduates the meta entry. The §8 `main`, registers the webhook, and graduates the meta entry as a
surface at `/rfc/<slug>` then has something real to render. bootstrap-only direct write. The §8 surface at `/rfc/<slug>` then
has something real to render.
## Troubleshooting ## Troubleshooting
+132 -74
View File
@@ -2405,86 +2405,96 @@ surface. With Topic 13 folded in, the structural surface is
complete. What follows is no longer "topics that block specifying complete. What follows is no longer "topics that block specifying
v1" but "topics to address during or shortly after the v1 build." v1" but "topics to address during or shortly after the v1 build."
### 19.1 Next slice: graduation per §13 ### 19.1 Next slice: notifications per §15
Slice 4 of the build has landed. Super-draft body editing per §9.5 Slice 5 of the build has landed. The §13 graduation flow runs
runs end-to-end against the local Gitea — the §9.4 super-draft view end-to-end against the local Gitea — the Graduate dialog renders
replaces the Slice 2 placeholder and renders through the same the three editable fields (integer ID, repo name, initial owners)
`RFCView.jsx` surface as an active RFC, dispatched on `entry.state`. with the debounced `GET /api/rfcs/<slug>/graduate/check` lighting
The §9.5 `Start Contributing` gesture cuts a meta-repo edit branch up per-field validity inline, the precondition popover surfaces
via `POST /api/rfcs/<slug>/start-edit-branch`, re-anchors pending open body-edit PRs via `GET /api/rfcs/<slug>/blocking-prs` (the
main-scoped `changes` rows, and lands the contributor in contribute §9.8 gate enforced before the sequence starts), and confirming the
mode on the new branch. From there everything in §8 — chat, AI dialog kicks off the §13.3 five-step sequence streamed via
participation, accept/decline/edit, manual-edit flushes, range and `GET /api/rfcs/<slug>/graduate/progress`. The orchestrator in
paragraph sub-threads, flags, DiffView, stale-change handling — `api_graduation.py` runs the sequence as an asyncio task fed by an
reaches the super-draft surface through the same routes Slice 2 in-memory queue; each step's bot primitive
shipped, with the dispatch sitting in `api_branches.py`'s helpers: (`create_rfc_repo_for_graduation`, `seed_graduated_rfc`,
when `cached_rfcs.state = 'super-draft'`, the bot writes to the `open_graduation_pr`, `merge_graduation_pr`) lands its own row in
meta repo and the file is `rfcs/<slug>.md` (the body wrapped in `actions`, bracketed by `graduate_start` and `graduate_complete`
frontmatter); when `state = 'active'`, it writes to the per-RFC for the linkable sequence. Rollback is per-step and runs in
repo and the file is `RFC.md`. The body extracted from the entry's reverse: each forward step has a paired undo registered in
frontmatter envelope is what the editor and the diff see; the `_UNDO_BY_STEP` — the create-repo undo deletes the repo (which
serializer re-wraps on every commit. The §10 PR flow against also reclaims the seed commits, so seed-files' undo folds into
meta-repo edit branches falls out structurally unchanged, with it), the open-pr undo closes the graduation PR. There is no
`pr_kind='meta_body_edit'` distinguishing the cache row — the merge-pr undo by design; once the meta-repo merge has landed,
§10.3 review page, the §10.4 review threads, the §10.5 merge, the graduation is irreversible per §13.5.
§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 two §17 routes Slice 4 added — `start-edit-branch` and §13.4's chat migration landed as a database semantic no-op —
`metadata` — live in `backend/app/api_branches.py`. The bot grew the whole-doc main thread on the super-draft
`open_metadata_pr`. The §4 cache grew `refresh_meta_branches` (`rfc_slug=<slug>`, `branch_name='main'`) is the same row before
which mirrors `edit-<slug>-<6hex>` branches into `cached_branches` and after graduation; only the interpretation changes (canonical-
and synthesizes a per-slug `main` row so the §10.1 has-commits- body view becomes per-RFC repo's main). The slug is the canonical
ahead check works uniformly. The §5 schema needed no migration — key per §2.3, so no data movement is needed. Edit-branch chats
the super-draft scoping note already settled that the existing stay attached to their original `branch_name` per §9.8's
tables carry both cases. On the frontend, `RFCView.jsx`'s no-data-movement framing; the §9.8 pre-graduation history
super-draft placeholder is replaced by the full editor surface; affordance on the new RFC view surfaces them as a distinct
the `BranchDropdown` renders `canonical body` as the first disclosure in the breadcrumb dropdown, with the read path
position per §9.4; a `MetadataPaneModal` opens from the breadcrumb dispatching against the meta repo via a new `_is_meta_target(rfc,
actions for viewers holding super-draft edit authority. branch)` helper that handles both super-draft branches and active-
RFC pre-graduation meta-repo branches uniformly.
Slice 4 ships covered by `backend/tests/test_super_draft_vertical.py` The §13.1 claim flow landed alongside graduation since it's the
— ten integration tests against the FakeGitea covering main-view prerequisite for non-admin graduation. The bot grew `open_claim_pr`;
read, start-edit-branch, body extraction on read, accept and manual `api_prs._require_pr` broadened to accept `pr_kind='meta_claim'`
flush both preserving the frontmatter envelope, the body-edit PR's so the merge surface inherits structurally from §10. Until §13.1's
`pr_kind='meta_body_edit'` cache shape, the full cut-accept-open- claim runs, the dialog refuses the start when `owners=[]` and the
merge loop with the §9.5 admin-only unclaimed-merge gate, the popover surfaces "Claim ownership yourself" as a remediation
metadata pane PR cycle, the canonical-body branch (`main` for affordance (admins are contributors per §6.1 and can claim solo).
super-drafts) refusing contribute writes, and the metadata pane
permission gate refusing plain contributors. The full Slices 14
test suite is 35/35 green.
**Slice 5 is graduation per §13.** The five-step transactional The five §17 routes Slice 5 added — `claim`, `blocking-prs`,
sequence flips a super-draft to active: validate the dialog's `graduate/check`, `graduate`, and `graduate/progress` — live in
`id`/`repo`/`owners` inputs against the catalog and Gitea, create `backend/app/api_graduation.py`. The §5 schema needed no migration.
the per-RFC repo via `bot.ensure_rfc_repo_seed` (which Slice 2 On the frontend, `RFCView.jsx`'s breadcrumb actions grew
added as a forward-looking seam), copy the body from the entry's `Graduate to RFC repo` and `Claim ownership` buttons;
frontmatter envelope into the new repo's `RFC.md` on main, strip `GraduateDialog.jsx` owns the three-field surface, the precondition
the body from the meta-repo entry and fill the `id` / `repo` / popover, and the live step stack fed by an `EventSource` on the
`graduated_at` / `graduated_by` frontmatter fields, and migrate progress SSE; the `BranchDropdown` gains a `Pre-graduation history`
the chat per §13.4 — the whole-doc main thread and the canonical- disclosure that surfaces edit-branch threads on the new RFC view
body view's range/paragraph sub-threads re-anchor onto the new per §9.8.
RFC's main thread; edit-branch chats stay attached to their
original `branch_name` on the meta repo per §9.8, surfaced by the Slice 5 ships covered by `backend/tests/test_graduation_vertical.py`
pre-graduation history affordance on the new RFC view. The §9.8 — ten integration tests against the FakeGitea (extended with
precondition gate — open body-edit PRs block graduation — is `DELETE /repos/{owner}/{repo}` for the rollback inverse) covering
enforced before the bot starts the sequence, so the §13.3 rollback the dialog validator's per-field checks, the no-owners refusal,
complexity does not grow. The Graduate dialog opens a stream the §9.8 precondition refusing the start, the §13.3 happy path
handle for the §17 SSE progress endpoint and renders the step end-to-end with audit-log verification, mid-sequence rollback at
stack from `pending → running → done/failed` transitions, with a step 2 (seed) and step 3 (PR open), concurrent-graduation refusal,
trailing `rollback` step's events if any earlier step fails. §13.4's chat-row-survives contract, the §9.8 pre-graduation
history surface, and the §13.1 claim PR cycle. The full Slices 15
test suite is 45/45 green.
**Slice 6 is notifications per §15.** Every other vertical now
produces signals — propose, claim, merge, graduate, body edits,
manual flushes, PR open/withdraw/merge, review threads, conflict-
replay — and Slice 6 builds the surface that turns those signals
into a contributor's inbox. The §5 schema already carries the
notifications, watches, branch_chat_seen, notification_user_mutes,
and notification_digests tables; Topic 13's session settled the
producer-side rules per §15.1, the §15.2 inbox grouping, §15.3
badges and toasts, §15.4 email categories, §15.5 digest cadence,
§15.6 watch/subscription, §15.7 unread mechanism, §15.8 do-not-
disturb, and §15.9 attribution. The producer-side hook is "after
a write succeeds, evaluate watches and fan-out notification rows"
— same chokepoint shape Slice 1's `_log` uses, invoked inline
from the bot wrapper. The consumer-side hook is the header badge,
the inbox panel, the toast surface, and the per-row read-state
machinery. The §15.4 email loop and the §15.5 digest are the
heavier sub-pieces — the digest needs a scheduled-job runner;
the email loop needs a transactional-email adapter and the
`POST /api/webhooks/email-bounce` receiver.
The next build session should read `SPEC.md`, `README.md`, The next build session should read `SPEC.md`, `README.md`,
`docs/DEV.md`, and this §19.1 entry and pick up Slice 5 cleanly `docs/DEV.md`, and this §19.1 entry and pick up Slice 6 cleanly
without re-briefing. The working agreement in §19.3 continues to without re-briefing. The working agreement in §19.3 continues to
apply: implement the slice, correct the spec only where running apply: implement the slice, correct the spec only where running
code reveals it was wrong at a structural level, accumulate new code reveals it was wrong at a structural level, accumulate new
@@ -2688,6 +2698,54 @@ binding.
cheap, but a dedicated index on `cached_branches.branch_name` cheap, but a dedicated index on `cached_branches.branch_name`
would shorten the join-against-`cached_rfcs`-state for very would shorten the join-against-`cached_rfcs`-state for very
large super-draft fleets. Trivial; defer until the cost shows up. large super-draft fleets. Trivial; defer until the cost shows up.
- **Graduation progress persistence across page reloads.** Slice 5's
orchestrator holds the per-step state in a process-local dict
keyed by slug, fed by an asyncio.Queue the §17 SSE drains. A page
reload while the sequence is in flight loses the dialog but the
sequence continues to completion on the server; the user can
reopen the dialog and the snapshot event re-renders the current
state (the in-memory entry persists until cleanup). What is not
yet settled: how long to retain the entry after `finished=True`,
whether to persist enough on `actions` to reconstruct the step
stack from the audit log for a returning admin who missed the
live stream, and whether the dialog should re-open automatically
on a slug whose registry entry is still present. Touches §13.3
(the rendered step stack's durability) and §15.2 (a graduation-
in-progress signal as an inbox row would be a natural alternative
surface for follow-along). Earns its own topic once the build
hits cases where a single sequence runs long enough that the
reload-during-graduation path matters.
- **Graduation PR auto-close on rollback's `close_graduation_pr`.**
Slice 5's rollback closes the graduation PR via `gitea.close_pull`
but leaves the dash-suffixed branch (`graduate-<slug>-<6hex>`)
in place. The branch is short-lived in steady-state — graduation
succeeds — but accumulated failed-graduation branches over time
could clutter the meta repo's branch list. The §12 30/90 hygiene
timers (Slice 8) would naturally sweep them, but a graduation-
specific cleanup that deletes the branch on rollback would close
the loop faster. Trivial to add when evidence shows the branches
pile up.
- **The `_is_meta_target(rfc, branch)` dispatch helper.** Slice 5
generalized the super-draft routing collapse (`_is_super_draft`
alone) to also handle active-RFC reads against pre-graduation
meta-repo branches per §9.8. The helper checks state plus a name-
prefix list (`edit-`, `edit/`, `metadata-`, `metadata/`, `claim/`,
`propose/`, `graduate-`). The prefix list is right for v1's
surface, but a contributor renaming an active-RFC per-RFC branch
to one of those prefixes would have reads route to the meta repo
(and likely 404). The `_validate_branch_name` guard refuses
reserved prefixes on creation, so the only way to reach this
edge is a hand-renamed branch — defer-able until evidence shows
it happens.
- **Test seam for synchronous graduation.** Slice 5's `?_sync=1`
query param on `POST .../graduate` awaits the orchestrator inline
so integration tests can assert post-conditions without driving
the SSE. The seam is documented in code; it does not affect the
production path. A cleaner long-term shape is to expose the
orchestrator as importable for tests and remove the query-param
branch from the route handler, but the current shape is the
minimum that keeps the test surface terse without adding a
separate test-only module.
- **Body full-text search.** When the time comes. - **Body full-text search.** When the time comes.
Topic 13 (notifications) is settled and folded into §5 (the Topic 13 (notifications) is settled and folded into §5 (the
+3 -1
View File
@@ -17,7 +17,7 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from . import api_branches, api_prs, auth, db, entry as entry_mod, cache from . import api_branches, api_graduation, api_prs, auth, db, entry as entry_mod, cache
from .bot import Bot from .bot import Bot
from .config import Config from .config import Config
from .gitea import Gitea, GiteaError from .gitea import Gitea, GiteaError
@@ -53,6 +53,8 @@ def make_router(
router.include_router(api_branches.make_router(config, gitea, bot, providers)) router.include_router(api_branches.make_router(config, gitea, bot, providers))
# Slice 3: the §10 PR-flow endpoints. # Slice 3: the §10 PR-flow endpoints.
router.include_router(api_prs.make_router(config, gitea, bot, providers)) router.include_router(api_prs.make_router(config, gitea, bot, providers))
# Slice 5: §13 graduation + §13.1 claim.
router.include_router(api_graduation.make_router(config, gitea, bot))
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Auth surface — extends the prototype's pattern but reads role # Auth surface — extends the prototype's pattern but reads role
+97 -28
View File
@@ -204,6 +204,46 @@ def make_router(
# For super-drafts the cached body is entry.body already (see # For super-drafts the cached body is entry.body already (see
# cache._upsert_cached_rfc), so no extraction is needed. # cache._upsert_cached_rfc), so no extraction is needed.
# §9.8 / §13.4 pre-graduation history: for active RFCs, surface
# any `threads` or `changes` rows whose `branch_name` starts with
# `edit-<slug>-` so the breadcrumb dropdown can render the
# affordance as a distinct disclosure alongside main, open
# branches, and open PRs. The slug is the canonical key per §2.3
# before and after graduation, so the query is a straightforward
# lookup — no data movement.
pre_grad: list[dict[str, Any]] = []
if rfc["state"] == "active":
pre_grad_rows = db.conn().execute(
"""
SELECT t.branch_name,
COUNT(DISTINCT t.id) AS thread_count,
COUNT(DISTINCT m.id) AS message_count,
MAX(m.created_at) AS last_activity_at
FROM threads t
LEFT JOIN thread_messages m ON m.thread_id = t.id
WHERE t.rfc_slug = ?
AND (
t.branch_name LIKE 'edit-' || ? || '-%'
OR t.branch_name LIKE 'edit/' || ? || '/%'
)
GROUP BY t.branch_name
ORDER BY MAX(m.created_at) DESC NULLS LAST, t.branch_name
""",
(slug, slug, slug),
).fetchall()
for r in pre_grad_rows:
change_count = db.conn().execute(
"SELECT COUNT(*) AS n FROM changes WHERE rfc_slug = ? AND branch_name = ?",
(slug, r["branch_name"]),
).fetchone()["n"]
pre_grad.append({
"branch_name": r["branch_name"],
"thread_count": r["thread_count"],
"message_count": r["message_count"],
"change_count": change_count,
"last_activity_at": r["last_activity_at"],
})
return { return {
"slug": slug, "slug": slug,
"title": rfc["title"], "title": rfc["title"],
@@ -215,6 +255,7 @@ def make_router(
"body_sha": rfc["body_sha"], "body_sha": rfc["body_sha"],
"branches": branches, "branches": branches,
"open_prs": prs, "open_prs": prs,
"pre_graduation_history": pre_grad,
} }
# ------------------------------------------------------------------- # -------------------------------------------------------------------
@@ -232,8 +273,8 @@ def make_router(
if not _can_read_branch(slug, branch, viewer): if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private") raise HTTPException(403, "Branch is private")
owner, repo = _repo_for(rfc) owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc) path = _file_path_for(rfc, branch)
result = await gitea.read_file(owner, repo, path, ref=branch) result = await gitea.read_file(owner, repo, path, ref=branch)
if result is None: if result is None:
br = await gitea.get_branch(owner, repo, branch) br = await gitea.get_branch(owner, repo, branch)
@@ -242,7 +283,7 @@ def make_router(
body, body_sha = "", "" body, body_sha = "", ""
else: else:
content, body_sha = result content, body_sha = result
body = _extract_body(rfc, content) body = _extract_body(rfc, content, branch)
# Ensure the whole-doc chat thread for the branch exists. # Ensure the whole-doc chat thread for the branch exists.
thread_id = _ensure_branch_chat_thread(slug, branch, viewer) thread_id = _ensure_branch_chat_thread(slug, branch, viewer)
@@ -482,13 +523,13 @@ def make_router(
# Fetch current file and extract the editable body. For super-draft # 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. # the file is rfcs/<slug>.md with frontmatter; for active it's RFC.md.
owner, repo = _repo_for(rfc) owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc) path = _file_path_for(rfc, branch)
fetched = await gitea.read_file(owner, repo, path, ref=branch) fetched = await gitea.read_file(owner, repo, path, ref=branch)
if fetched is None: if fetched is None:
raise HTTPException(409, f"Branch {path} not found") raise HTTPException(409, f"Branch {path} not found")
prior_content, prior_sha = fetched prior_content, prior_sha = fetched
current_body = _extract_body(rfc, prior_content) current_body = _extract_body(rfc, prior_content, branch)
original = row["original"] original = row["original"]
occurrences = current_body.count(original) occurrences = current_body.count(original)
@@ -508,7 +549,7 @@ def make_router(
else: else:
new_body = current_body.replace(original, body.proposed, 1) new_body = current_body.replace(original, body.proposed, 1)
new_file_contents = _wrap_body(rfc, prior_content, new_body) new_file_contents = _wrap_body(rfc, prior_content, new_body, branch)
try: try:
sha = await bot.commit_accepted_change( sha = await bot.commit_accepted_change(
@@ -590,10 +631,10 @@ def make_router(
if not providers: if not providers:
raise HTTPException(503, "No AI providers configured") raise HTTPException(503, "No AI providers configured")
owner, repo = _repo_for(rfc) owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc) path = _file_path_for(rfc, branch)
fetched = await gitea.read_file(owner, repo, path, ref=branch) fetched = await gitea.read_file(owner, repo, path, ref=branch)
body_text = _extract_body(rfc, fetched[0]) if fetched else "" body_text = _extract_body(rfc, fetched[0], branch) if fetched else ""
provider = next(iter(providers.values())) provider = next(iter(providers.values()))
system = chat_layer.build_system_prompt(title=rfc["title"], body=body_text) system = chat_layer.build_system_prompt(title=rfc["title"], body=body_text)
@@ -638,18 +679,18 @@ def make_router(
viewer = auth.require_contributor(request) viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug) rfc = _require_rfc_with_repo(slug)
_require_can_contribute(slug, branch, viewer) _require_can_contribute(slug, branch, viewer)
owner, repo = _repo_for(rfc) owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc) path = _file_path_for(rfc, branch)
fetched = await gitea.read_file(owner, repo, path, ref=branch) fetched = await gitea.read_file(owner, repo, path, ref=branch)
if fetched is None: if fetched is None:
raise HTTPException(409, f"Branch {path} not found") raise HTTPException(409, f"Branch {path} not found")
prior_content, prior_sha = fetched prior_content, prior_sha = fetched
prior_body = _extract_body(rfc, prior_content) prior_body = _extract_body(rfc, prior_content, branch)
if prior_body == body.new_content: if prior_body == body.new_content:
return {"ok": True, "noop": True} return {"ok": True, "noop": True}
new_file_contents = _wrap_body(rfc, prior_content, body.new_content) new_file_contents = _wrap_body(rfc, prior_content, body.new_content, branch)
# Per §8.11: materialize the manual change as a `changes` row # Per §8.11: materialize the manual change as a `changes` row
# first so the resolved card binds 1:1 to the commit. # first so the resolved card binds 1:1 to the commit.
@@ -898,10 +939,10 @@ def make_router(
# Fetch the live branch body so the prompt is anchored to # Fetch the live branch body so the prompt is anchored to
# what's in Gitea right now, not the cache. For super-draft, # what's in Gitea right now, not the cache. For super-draft,
# extract just the body part from the entry envelope. # extract just the body part from the entry envelope.
owner, repo = _repo_for(rfc) owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc) path = _file_path_for(rfc, branch)
fetched = await gitea.read_file(owner, repo, path, ref=branch) fetched = await gitea.read_file(owner, repo, path, ref=branch)
body_text = _extract_body(rfc, fetched[0]) if fetched else "" body_text = _extract_body(rfc, fetched[0], branch) if fetched else ""
prompt_text = body.text prompt_text = body.text
if body.quote: if body.quote:
@@ -981,22 +1022,42 @@ def make_router(
def _is_super_draft(rfc) -> bool: def _is_super_draft(rfc) -> bool:
return rfc["state"] == "super-draft" return rfc["state"] == "super-draft"
def _repo_for(rfc) -> tuple[str, str]: def _is_meta_branch_name(name: str) -> bool:
"""A branch name shaped like one of the bot's meta-repo prefixes.
§9.8's pre-graduation history affordance points the new RFC view
at branches matching `edit-<slug>-...` even after the entry is
active; treating those names as meta-repo targets lets the read
path dispatch correctly without a separate endpoint."""
return name != "main" and name.startswith((
"edit-", "edit/", "metadata-", "metadata/", "claim/", "propose/",
"graduate-",
))
def _is_meta_target(rfc, branch: str) -> bool:
"""Either a super-draft branch (active edit branch or the
canonical body) or an active RFC's pre-graduation meta-repo
branch surfaced through the §9.8 history affordance."""
if _is_super_draft(rfc): if _is_super_draft(rfc):
return True
return _is_meta_branch_name(branch)
def _repo_for(rfc, branch: str = "main") -> tuple[str, str]:
if _is_meta_target(rfc, branch):
return config.gitea_org, config.meta_repo return config.gitea_org, config.meta_repo
owner, repo = rfc["repo"].split("/", 1) owner, repo = rfc["repo"].split("/", 1)
return owner, repo return owner, repo
def _file_path_for(rfc) -> str: def _file_path_for(rfc, branch: str = "main") -> str:
if _is_super_draft(rfc): if _is_meta_target(rfc, branch):
return f"rfcs/{rfc['slug']}.md" return f"rfcs/{rfc['slug']}.md"
return RFC_FILE_PATH return RFC_FILE_PATH
def _extract_body(rfc, file_contents: str) -> str: def _extract_body(rfc, file_contents: str, branch: str = "main") -> str:
"""For super-draft entries the file on disk is the full """For super-draft entries (and active-RFC pre-graduation reads
frontmatter+body envelope; the editable body is entry.body. For per §9.8) the file on disk is the full frontmatter+body envelope;
active RFCs the file is just RFC.md and the whole thing is body.""" the editable body is entry.body. For active RFCs reading their
if not _is_super_draft(rfc): per-RFC repo the file is just RFC.md and the whole thing is body."""
if not _is_meta_target(rfc, branch):
return file_contents return file_contents
try: try:
entry = entry_mod.parse(file_contents) entry = entry_mod.parse(file_contents)
@@ -1004,10 +1065,10 @@ def make_router(
return file_contents return file_contents
return entry.body return entry.body
def _wrap_body(rfc, prior_contents: str, new_body: str) -> str: def _wrap_body(rfc, prior_contents: str, new_body: str, branch: str = "main") -> str:
"""Inverse of _extract_body: re-wrap a new body into the entry """Inverse of _extract_body: re-wrap a new body into the entry
envelope, preserving the prior frontmatter exactly.""" envelope, preserving the prior frontmatter exactly."""
if not _is_super_draft(rfc): if not _is_meta_target(rfc, branch):
return new_body return new_body
entry = entry_mod.parse(prior_contents) entry = entry_mod.parse(prior_contents)
# Ensure exactly one trailing newline so the serializer's # Ensure exactly one trailing newline so the serializer's
@@ -1125,6 +1186,13 @@ def make_router(
return False return False
if branch == "main": if branch == "main":
return False return False
# §9.8: pre-graduation history branches are read-only on the
# post-graduation surface. The contributor can re-cut against the
# new repo's main if they still want the work, but the meta-repo
# branches that lived on the super-draft are not editable from
# the active-RFC view.
if rfc["state"] == "active" and _is_meta_branch_name(branch):
return False
if viewer.role in ("owner", "admin"): if viewer.role in ("owner", "admin"):
return True return True
owners = json.loads(rfc["owners_json"] or "[]") owners = json.loads(rfc["owners_json"] or "[]")
@@ -1150,7 +1218,8 @@ def make_router(
def _require_can_contribute(slug: str, branch: str, viewer) -> None: def _require_can_contribute(slug: str, branch: str, viewer) -> None:
rfc = db.conn().execute( rfc = db.conn().execute(
"SELECT owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?", (slug,) "SELECT state, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?",
(slug,),
).fetchone() ).fetchone()
if not _can_contribute(rfc, slug, branch, viewer): if not _can_contribute(rfc, slug, branch, viewer):
raise HTTPException(403, "You do not have contribute access to this branch") raise HTTPException(403, "You do not have contribute access to this branch")
+940
View File
@@ -0,0 +1,940 @@
"""Slice 5 API surface — the §13 graduation flow's endpoints and the
in-process orchestrator that runs the §13.3 transactional sequence with
rollback.
Owns four routes per §17:
- GET /api/rfcs/<slug>/blocking-prs (§13.2 precondition popover)
- GET /api/rfcs/<slug>/graduate/check (§13.2 debounced validator)
- POST /api/rfcs/<slug>/graduate (§13.3 kickoff)
- GET /api/rfcs/<slug>/graduate/progress (§13.3 SSE step stream)
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim), which is
graduation's prerequisite for non-admins per §13.1.
The orchestrator runs in-process — each in-flight graduation lives in a
small `GraduationState` keyed by slug, with an asyncio.Queue feeding the
SSE handler. Per the §13.3 transactional contract, every forward step is
paired with an undo; rollback runs the undos in reverse order from the
last step that completed. §13.4's chat migration is a database semantic
no-op (the threads' `(rfc_slug, branch_name='main')` rows are interpreted
as super-draft canonical-body before graduation and as new-RFC main
afterwards — same shape, different meaning), so the only DB work the
sequence does is the audit-log rows the bot's `_log` writes per step.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from dataclasses import dataclass, field
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from . import auth, cache, db, entry as entry_mod
from .bot import Actor, Bot
from .config import Config
from .gitea import Gitea, GiteaError
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Step machine
# ---------------------------------------------------------------------------
STEP_KEYS = (
"create_repo",
"seed_files",
"open_pr",
"merge_pr",
"refresh_cache",
)
STEP_LABELS = {
"create_repo": "Create per-RFC repository",
"seed_files": "Seed RFC.md, README.md, and .rfc/metadata.yaml",
"open_pr": "Open meta-repo graduation PR",
"merge_pr": "Merge graduation PR",
"refresh_cache": "Refresh catalog and views",
}
@dataclass
class StepState:
key: str
label: str
status: str = "pending" # pending|running|done|failed|not-reached
detail: str = ""
@dataclass
class GraduationState:
slug: str
rfc_id: str
repo_name: str
repo_full: str
owners: list[str]
arbiters: list[str]
steps: list[StepState]
queue: asyncio.Queue = field(default_factory=asyncio.Queue)
finished: bool = False
succeeded: bool = False
error: str | None = None
rollback_started: bool = False
rollback_steps: list[StepState] = field(default_factory=list)
new_pr_number: int | None = None
graduation_branch: str | None = None
def to_payload(self) -> dict:
return {
"slug": self.slug,
"rfc_id": self.rfc_id,
"repo_full": self.repo_full,
"steps": [_step_payload(s) for s in self.steps],
"rollback_steps": [_step_payload(s) for s in self.rollback_steps],
"finished": self.finished,
"succeeded": self.succeeded,
"rolled_back": self.rollback_started,
"error": self.error,
"pr_number": self.new_pr_number,
}
def _step_payload(s: StepState) -> dict:
return {"key": s.key, "label": s.label, "status": s.status, "detail": s.detail}
# Process-local registry. Single-process FastAPI per §4.2 means in-memory
# is fine; the registry is keyed by slug to refuse concurrent graduations
# of the same entry (the §13.2 atomic re-check is a separate defense
# against a concurrent attempt of a DIFFERENT slug claiming the same
# integer ID or repo name).
_active: dict[str, GraduationState] = {}
def _get_active(slug: str) -> GraduationState | None:
return _active.get(slug)
def _new_active(slug: str, *, rfc_id: str, repo_name: str, repo_full: str,
owners: list[str], arbiters: list[str]) -> GraduationState:
state = GraduationState(
slug=slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full,
owners=owners, arbiters=arbiters,
steps=[StepState(key=k, label=STEP_LABELS[k]) for k in STEP_KEYS],
)
_active[slug] = state
return state
# ---------------------------------------------------------------------------
# Validation helpers
# ---------------------------------------------------------------------------
# §13.2: Gitea repo name pattern. Gitea accepts alphanumerics, dashes,
# dots, and underscores; cannot start with a dot. 100-char cap as a sane
# upper bound — the spec doesn't pin a max but Gitea's enforcement does.
_REPO_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$")
_RFC_ID_RE = re.compile(r"^RFC-\d{4,}$")
def _is_valid_repo_name(name: str) -> bool:
return bool(_REPO_NAME_RE.match(name)) and ".." not in name
def _is_valid_rfc_id(rfc_id: str) -> bool:
return bool(_RFC_ID_RE.match(rfc_id))
def _suggest_next_rfc_id() -> str:
rows = db.conn().execute(
"SELECT rfc_id FROM cached_rfcs WHERE rfc_id LIKE 'RFC-%'"
).fetchall()
used: set[int] = set()
for r in rows:
try:
used.add(int(r["rfc_id"].split("-", 1)[1]))
except (IndexError, ValueError):
continue
nxt = (max(used) + 1) if used else 1
return f"RFC-{nxt:04d}"
def _suggest_repo_name(slug: str, rfc_id: str) -> str:
# rfc-NNNN-<slug> per §13.2's default. Strip the 'RFC-' prefix and
# lowercase the number-pad.
num = rfc_id.split("-", 1)[1] if "-" in rfc_id else "0001"
return f"rfc-{num}-{slug}"
def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool:
row = db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE rfc_id = ? AND slug != ?",
(rfc_id, excluding_slug),
).fetchone()
return row is not None
# ---------------------------------------------------------------------------
# Request bodies
# ---------------------------------------------------------------------------
class GraduateBody(BaseModel):
rfc_id: str = Field(min_length=5, max_length=40)
repo_name: str = Field(min_length=1, max_length=100)
owners: list[str] = Field(min_length=1)
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
def make_router(
config: Config,
gitea: Gitea,
bot: Bot,
) -> APIRouter:
router = APIRouter()
# -------------------------------------------------------------------
# §13.2: GET /api/rfcs/<slug>/blocking-prs
# Lists open meta-repo PRs against rfcs/<slug>.md per the precondition
# popover. Returns PR number, title, author, last-activity timestamp,
# and the viewer's available actions (merge, withdraw, open-in-new-tab).
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/blocking-prs")
async def list_blocking_prs(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_super_draft(slug)
# §13's opening paragraph: only body-edit PRs block graduation.
# Bare edit branches without an open PR do not block. The query
# filters cached_prs to open meta_body_edit kinds for this slug.
rows = db.conn().execute(
"""
SELECT pr_number, title, opened_by, opened_at, head_branch, pr_kind
FROM cached_prs
WHERE rfc_slug = ?
AND state = 'open'
AND pr_kind = 'meta_body_edit'
ORDER BY opened_at DESC
""",
(slug,),
).fetchall()
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")
items = []
for r in rows:
can_merge = (
viewer is not None
and (
viewer.role in ("owner", "admin")
or viewer.gitea_login in owners
or viewer.gitea_login in arbiters
)
)
can_withdraw = (
viewer is not None
and (
can_merge
or viewer.gitea_login == (r["opened_by"] or "")
)
)
items.append({
"pr_number": r["pr_number"],
"title": r["title"],
"author": r["opened_by"],
"last_activity_at": r["opened_at"],
"head_branch": r["head_branch"],
"actions": {
"merge": can_merge,
"withdraw": can_withdraw,
"open_in_new_tab": True,
},
})
return {"items": items}
# -------------------------------------------------------------------
# §13.2: GET /api/rfcs/<slug>/graduate/check?id=&repo=
# Inline validation for the Graduate dialog — debounced from the
# client; the dialog calls this as the admin types. Returns per-field
# collision/validity from the catalog cache plus a server-authoritative
# repo-name collision check.
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/graduate/check")
async def graduate_check(
slug: str, request: Request,
) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_super_draft(slug)
del viewer # no permission gate — the dialog only shows up for
# admins/owners, but the check itself is read-only.
candidate_id = (request.query_params.get("id") or "").strip()
candidate_repo = (request.query_params.get("repo") or "").strip()
owners = json.loads(rfc["owners_json"] or "[]")
blocking_count = db.conn().execute(
"""
SELECT COUNT(*) AS n FROM cached_prs
WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit'
""",
(slug,),
).fetchone()["n"]
# ID field
id_payload: dict[str, Any] = {"value": candidate_id, "ok": True, "error": None}
if not candidate_id:
id_payload["ok"] = False
id_payload["error"] = "Integer ID is required"
elif not _is_valid_rfc_id(candidate_id):
id_payload["ok"] = False
id_payload["error"] = "ID must look like RFC-NNNN (at least four digits)"
elif _rfc_id_taken(candidate_id, excluding_slug=slug):
id_payload["ok"] = False
id_payload["error"] = f"Integer ID {candidate_id} is already taken"
# Repo field — validate pattern then probe Gitea for an existing
# repo of that name under our org. The repo lookup is a single GET
# so it's cheap to call on every keystroke (debounced from the
# client per §13.2).
repo_payload: dict[str, Any] = {"value": candidate_repo, "ok": True, "error": None}
if not candidate_repo:
repo_payload["ok"] = False
repo_payload["error"] = "Repo name is required"
elif not _is_valid_repo_name(candidate_repo):
repo_payload["ok"] = False
repo_payload["error"] = (
"Repo name must be alphanumerics, dashes, dots, or underscores "
"(start with alphanumeric)"
)
else:
try:
existing = await gitea.get_repo(config.gitea_org, candidate_repo)
except GiteaError as e:
# Network/auth flake — surface as a non-fatal hint; the
# atomic server-side check at POST time is the authority.
existing = None
log.warning("graduate_check: Gitea get_repo error: %s", e)
if existing is not None:
repo_payload["ok"] = False
repo_payload["error"] = (
f"Repo `{config.gitea_org}/{candidate_repo}` already exists"
)
# Owners precondition — §13's opening paragraph.
owners_payload: dict[str, Any] = {
"ok": len(owners) > 0,
"count": len(owners),
"current": owners,
"error": None if len(owners) > 0 else "No owners claimed yet",
}
# Blocking PR precondition — §9.8 / §13's opening paragraph.
prs_payload: dict[str, Any] = {
"ok": blocking_count == 0,
"count": blocking_count,
"error": (
None if blocking_count == 0
else f"{blocking_count} open body-edit PR{'' if blocking_count == 1 else 's'} blocking graduation"
),
}
in_flight = _get_active(slug)
any_invalid = not (
id_payload["ok"] and repo_payload["ok"]
and owners_payload["ok"] and prs_payload["ok"]
)
return {
"slug": slug,
"id": id_payload,
"repo": repo_payload,
"owners": owners_payload,
"blocking_prs": prs_payload,
"can_submit": (not any_invalid) and (in_flight is None or in_flight.finished),
"in_flight": (
None if in_flight is None
else {"finished": in_flight.finished, "succeeded": in_flight.succeeded}
),
}
# -------------------------------------------------------------------
# §13.3: POST /api/rfcs/<slug>/graduate
# Atomic re-validation, then kicks off the sequence as an async task.
# The client opens GET /graduate/progress on confirm to watch the SSE.
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/graduate")
async def graduate(slug: str, body: GraduateBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug)
# §13: only owners/arbiters of the RFC and app admins/owners may
# graduate. Until §13.1's claim runs the entry has no owners, so
# the set collapses to app admins/owners for unclaimed entries.
if not _can_graduate(rfc, viewer):
raise HTTPException(403, "Only RFC owners/arbiters or app admins/owners may graduate")
# Refuse if an in-flight graduation is still running for this slug.
existing = _get_active(slug)
if existing is not None and not existing.finished:
raise HTTPException(409, "Graduation already in progress for this slug")
# §13.2 atomic re-validation. The dialog's debounced check runs
# client-side as the admin types; this is the authoritative check
# that closes the dialog-open-to-confirm race.
rfc_id = body.rfc_id.strip()
repo_name = body.repo_name.strip()
owners = [o.strip() for o in body.owners if o.strip()]
if not owners:
raise HTTPException(422, "Add at least one initial owner")
if not _is_valid_rfc_id(rfc_id):
raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)")
if _rfc_id_taken(rfc_id, excluding_slug=slug):
raise HTTPException(409, f"Integer ID {rfc_id} is already taken")
if not _is_valid_repo_name(repo_name):
raise HTTPException(422, "Repo name must be alphanumerics, dashes, dots, or underscores")
try:
existing_repo = await gitea.get_repo(config.gitea_org, repo_name)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
if existing_repo is not None:
raise HTTPException(409, f"Repo `{config.gitea_org}/{repo_name}` already exists")
# §9.8 precondition gate — enforced before the bot starts the
# sequence so the §13.3 rollback complexity does not grow. An
# open body-edit PR against rfcs/<slug>.md would attempt to
# re-introduce a body to a frontmatter-only entry after step 3.
blocking = db.conn().execute(
"""
SELECT COUNT(*) AS n FROM cached_prs
WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit'
""",
(slug,),
).fetchone()["n"]
if blocking > 0:
raise HTTPException(
409,
f"{blocking} open body-edit PR{'' if blocking == 1 else 's'} block graduation",
)
# Read the meta-repo entry once — we need the file's sha for the
# graduation PR's update_file call and the original body so the
# bot can seed RFC.md on the new repo with the migrated body.
fetched = await gitea.read_file(
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
)
if fetched is None:
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
meta_text, meta_sha = fetched
try:
super_draft_entry = entry_mod.parse(meta_text)
except Exception as e:
raise HTTPException(500, f"Meta entry malformed: {e}")
repo_full = f"{config.gitea_org}/{repo_name}"
arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1]
# Compose the graduated frontmatter — body stripped, graduation
# fields filled. The serializer is run now so the PR-open step
# has the contents pre-rendered (single source of truth for the
# body migration vs. the meta-entry update).
graduated_entry = entry_mod.Entry(
slug=slug,
title=super_draft_entry.title,
state="active",
id=rfc_id,
repo=repo_full,
proposed_by=super_draft_entry.proposed_by,
proposed_at=super_draft_entry.proposed_at,
graduated_at=entry_mod.today(),
graduated_by=viewer.gitea_login,
owners=owners,
arbiters=arbiters,
tags=list(super_draft_entry.tags),
body="",
)
graduated_contents = entry_mod.serialize(graduated_entry)
state = _new_active(
slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full,
owners=owners, arbiters=arbiters,
)
# Audit: graduation started. The terminal `graduate_complete` /
# `graduate_rollback` rows below close the linkable sequence.
_audit(
viewer.user_id, viewer.gitea_login, "graduate_start",
rfc_slug=slug,
details={
"rfc_id": rfc_id, "repo": repo_full, "owners": owners,
"blocking_prs": blocking,
},
)
# Test seam: `?_sync=1` awaits the orchestrator inline so
# integration tests can assert post-conditions without driving
# the SSE. Production clients use the spec-described shape —
# POST returns immediately, the client subscribes to the
# progress SSE.
coro = _orchestrate(
config=config, gitea=gitea, bot=bot,
actor=viewer.as_actor(), state=state,
super_draft_body=super_draft_entry.body,
super_draft_title=super_draft_entry.title,
super_draft_tags=list(super_draft_entry.tags),
graduated_contents=graduated_contents,
meta_file_sha=meta_sha,
)
if request.query_params.get("_sync") == "1":
await coro
else:
asyncio.create_task(coro)
return {
"ok": True,
"slug": slug,
"rfc_id": rfc_id,
"repo": repo_full,
"stream_url": f"/api/rfcs/{slug}/graduate/progress",
"finished": state.finished,
"succeeded": state.succeeded,
}
# -------------------------------------------------------------------
# §13.3: GET /api/rfcs/<slug>/graduate/progress
# SSE stream of the step transitions. One event per step transition
# (pending → running → done / failed), plus the trailing rollback
# step's events if any earlier step fails.
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/graduate/progress")
async def graduate_progress(slug: str, request: Request):
del request
state = _get_active(slug)
if state is None:
raise HTTPException(404, "No graduation in flight for this slug")
async def event_stream():
# Emit the current snapshot first so a late subscriber sees
# the steps already completed.
yield _sse_event("snapshot", state.to_payload())
if state.finished:
yield _sse_event("done", state.to_payload())
return
while True:
evt = await state.queue.get()
if evt is None:
yield _sse_event("done", state.to_payload())
return
yield _sse_event(evt.get("event", "update"), evt.get("payload"))
headers = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
return StreamingResponse(event_stream(), media_type="text/event-stream", headers=headers)
# -------------------------------------------------------------------
# §13.1: POST /api/rfcs/<slug>/claim
# Opens a meta-repo PR adding the actor's gitea_login to the entry's
# owners list. Anyone signed in may claim — the merge is gated to
# owners/admins per §13.1 (which collapses to admins for unclaimed
# entries since `owners` is empty).
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/claim")
async def claim_ownership(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug)
# Refuse if the actor is already in owners — no-op claim.
existing_owners = json.loads(rfc["owners_json"] or "[]")
if viewer.gitea_login in existing_owners:
return {"ok": True, "noop": True}
# Refuse if a claim PR for this actor is already open. The branch
# name `claim/<slug>` collides per actor implicitly since Gitea
# refuses duplicate branch creation; we surface a clean 409 here
# so the client doesn't see a 502.
already = db.conn().execute(
"""
SELECT pr_number FROM cached_prs
WHERE rfc_slug = ? AND pr_kind = 'meta_claim' AND state = 'open'
""",
(slug,),
).fetchone()
if already:
raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}")
# Compose the new entry contents — owners list with the claimant
# appended.
fetched = await gitea.read_file(
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
)
if fetched is None:
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
meta_text, meta_sha = fetched
try:
ent = entry_mod.parse(meta_text)
except Exception as e:
raise HTTPException(500, f"Meta entry malformed: {e}")
if viewer.gitea_login in ent.owners:
return {"ok": True, "noop": True}
ent.owners = ent.owners + [viewer.gitea_login]
new_contents = entry_mod.serialize(ent)
try:
pr = await bot.open_claim_pr(
viewer.as_actor(),
org=config.gitea_org, meta_repo=config.meta_repo,
slug=slug,
new_file_contents=new_contents, prior_sha=meta_sha,
)
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"]}
# -------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------
def _require_super_draft(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"] != "super-draft":
raise HTTPException(409, f"RFC is {row['state']}, not super-draft")
return row
return router
# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------
async def _orchestrate(
*,
config: Config,
gitea: Gitea,
bot: Bot,
actor: Actor,
state: GraduationState,
super_draft_body: str,
super_draft_title: str,
super_draft_tags: list[str],
graduated_contents: str,
meta_file_sha: str,
) -> None:
"""Run §13.3 step by step. Each step:
- marks itself `running` and pushes an event
- calls the bot method (which writes to Gitea + audit log)
- marks itself `done` (or `failed`) and pushes another event
On failure at step N, every later step is marked `not-reached` and
`_rollback` runs undoes in reverse from N-1 to 1.
"""
try:
# ----- Step 1: create per-RFC repo -----
await _start(state, "create_repo", f"Creating `{state.repo_full}`…")
try:
await bot.create_rfc_repo_for_graduation(
actor, org=config.gitea_org, repo_name=state.repo_name,
slug=state.slug, title=super_draft_title,
)
except GiteaError as e:
await _fail(state, "create_repo", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at="create_repo")
return
await _done(state, "create_repo", state.repo_full)
# ----- Step 2: seed RFC.md, README.md, .rfc/metadata.yaml -----
await _start(state, "seed_files", "Writing initial commit on main…")
try:
await bot.seed_graduated_rfc(
actor,
org=config.gitea_org, repo_name=state.repo_name,
slug=state.slug, title=super_draft_title,
rfc_body=super_draft_body, rfc_id=state.rfc_id,
meta_full=config.meta_repo_full,
meta_path=f"rfcs/{state.slug}.md",
owners=state.owners, arbiters=state.arbiters,
tags=super_draft_tags,
)
except GiteaError as e:
await _fail(state, "seed_files", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at="seed_files")
return
await _done(state, "seed_files", "RFC.md, README.md, .rfc/metadata.yaml")
# ----- Step 3: open graduation PR -----
await _start(state, "open_pr", "Opening graduation PR…")
try:
pr = await bot.open_graduation_pr(
actor,
org=config.gitea_org, meta_repo=config.meta_repo,
slug=state.slug,
new_file_contents=graduated_contents,
prior_sha=meta_file_sha,
rfc_id=state.rfc_id, repo_full=state.repo_full,
owners=state.owners,
)
except GiteaError as e:
await _fail(state, "open_pr", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at="open_pr")
return
state.new_pr_number = pr["number"]
state.graduation_branch = pr["head"]["ref"]
await _done(state, "open_pr", f"PR #{state.new_pr_number}")
# ----- Step 4: merge the graduation PR -----
await _start(state, "merge_pr", f"Merging PR #{state.new_pr_number}")
try:
await bot.merge_graduation_pr(
actor,
org=config.gitea_org, meta_repo=config.meta_repo,
pr_number=state.new_pr_number,
head_branch=state.graduation_branch or "",
slug=state.slug, rfc_id=state.rfc_id,
)
except GiteaError as e:
await _fail(state, "merge_pr", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at="merge_pr")
return
await _done(state, "merge_pr", f"PR #{state.new_pr_number} merged")
# ----- Step 5: refresh the cache so the catalog flips immediately.
# Per §13.3 step 5 the webhook flow is the steady-state path, but
# we refresh inline so the dialog can transition to "graduation
# complete" with the catalog row already showing `active`. A
# cache-refresh failure does not unwind Git state — the
# reconciler will catch up per §4.1.
await _start(state, "refresh_cache", "Refreshing catalog and views…")
try:
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
await cache.refresh_rfc_repo(config, gitea, state.slug)
except Exception as e:
log.warning("graduate refresh_cache failed for %s: %s", state.slug, e)
await _done(state, "refresh_cache", f"Cache will catch up via reconciler ({e})")
else:
await _done(state, "refresh_cache", "Catalog and main view updated")
# Terminal success row in the audit log.
_audit(
None, actor.gitea_login, "graduate_complete",
rfc_slug=state.slug,
details={
"rfc_id": state.rfc_id, "repo": state.repo_full,
"owners": state.owners, "pr_number": state.new_pr_number,
},
)
state.succeeded = True
state.finished = True
await state.queue.put({"event": "completed", "payload": state.to_payload()})
except Exception as e:
log.exception("graduate: unexpected error for %s", state.slug)
# Best-effort: mark the in-flight step failed, then roll back.
running = next((s for s in state.steps if s.status == "running"), None)
if running is not None:
await _fail(state, running.key, f"unexpected: {e}")
await _rollback(
config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at=running.key if running else "unknown",
)
finally:
# Push the sentinel so any open SSE handler returns.
await state.queue.put(None)
async def _rollback(
*,
config: Config, gitea: Gitea, bot: Bot, actor: Actor,
state: GraduationState, failed_at: str,
) -> None:
"""Run undoes in reverse order from the last completed step. Each
undo emits its own rollback-step event so the dialog can render the
cleanup as a visible step appended to the stack per §13.3."""
state.rollback_started = True
# Mark every step after the failed one as not-reached so the rendered
# stack is honest about what didn't run.
seen_failure = False
for s in state.steps:
if s.status == "failed":
seen_failure = True
continue
if seen_failure and s.status == "pending":
s.status = "not-reached"
# Walk completed steps in reverse and run their inverses.
for s in reversed(state.steps):
if s.status != "done":
continue
undo = _UNDO_BY_STEP.get(s.key)
if undo is None:
continue
rb = StepState(key=f"undo:{s.key}", label=f"Undo: {s.label}",
status="running", detail="")
state.rollback_steps.append(rb)
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
try:
detail = await undo(
config=config, gitea=gitea, bot=bot, actor=actor, state=state,
)
except Exception as e:
rb.status = "failed"
rb.detail = f"{e}"
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
continue
rb.status = "done"
rb.detail = detail or ""
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
_audit(
None, actor.gitea_login, "graduate_rollback",
rfc_slug=state.slug,
details={
"failed_at": failed_at,
"error": state.error,
"rfc_id": state.rfc_id,
"repo": state.repo_full,
"undone": [s.key for s in state.rollback_steps if s.status == "done"],
},
)
state.finished = True
state.succeeded = False
await state.queue.put({"event": "rolled_back", "payload": state.to_payload()})
async def _undo_create_repo(*, config, gitea, bot, actor, state) -> str:
await bot.delete_rfc_repo(
actor, org=config.gitea_org, repo_name=state.repo_name,
slug=state.slug, reason="graduation rollback",
)
return f"Deleted `{state.repo_full}`"
async def _undo_seed_files(*, config, gitea, bot, actor, state) -> str:
# The seed commits live inside the per-RFC repo created in step 1;
# deleting the repo (step 1's undo) reclaims them at the same time.
# We surface a separate rollback step here so the rendered stack
# mirrors the forward steps, but the work is folded into _undo_create_repo.
return "Folded into repo deletion"
async def _undo_open_pr(*, config, gitea, bot, actor, state) -> str:
if state.new_pr_number is None:
return "No PR opened"
await bot.close_graduation_pr(
actor,
org=config.gitea_org, meta_repo=config.meta_repo,
pr_number=state.new_pr_number,
head_branch=state.graduation_branch or "",
slug=state.slug, reason="graduation rollback",
)
return f"Closed PR #{state.new_pr_number}"
# merge_pr's undo is intentionally absent — once the meta-repo merge has
# landed, graduation is irreversible per §13.5. If we ever reach a merged
# state and a later step fails (which can't happen — refresh_cache failures
# fold into success), there is no clean undo path; the user transitions
# via §3's `withdraw` instead.
_UNDO_BY_STEP = {
"create_repo": _undo_create_repo,
"seed_files": _undo_seed_files,
"open_pr": _undo_open_pr,
}
# ---------------------------------------------------------------------------
# Permission + audit helpers
# ---------------------------------------------------------------------------
def _can_graduate(rfc, viewer) -> bool:
if viewer is None:
return False
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 _audit(
actor_user_id: int | None,
on_behalf_of: str,
action_kind: str,
*,
rfc_slug: str | None = None,
branch_name: str | None = None,
pr_number: int | None = None,
details: dict | None = None,
) -> None:
"""Direct audit-log write for graduation lifecycle events that don't
correspond to a single Gitea write. The per-step Gitea writes log
themselves via the bot's `_log`; this is for the bracketing
`graduate_start` / `graduate_complete` / `graduate_rollback` rows."""
db.conn().execute(
"""
INSERT INTO actions
(actor_user_id, on_behalf_of, action_kind, rfc_slug, branch_name, pr_number, bot_commit_sha, details)
VALUES (?, ?, ?, ?, ?, ?, NULL, ?)
""",
(
actor_user_id,
on_behalf_of,
action_kind,
rfc_slug,
branch_name,
pr_number,
json.dumps(details) if details else None,
),
)
# ---------------------------------------------------------------------------
# Step state transitions
# ---------------------------------------------------------------------------
async def _start(state: GraduationState, key: str, detail: str) -> None:
step = next(s for s in state.steps if s.key == key)
step.status = "running"
step.detail = detail
await state.queue.put({"event": "step", "payload": state.to_payload()})
async def _done(state: GraduationState, key: str, detail: str) -> None:
step = next(s for s in state.steps if s.key == key)
step.status = "done"
step.detail = detail
await state.queue.put({"event": "step", "payload": state.to_payload()})
async def _fail(state: GraduationState, key: str, detail: str) -> None:
step = next(s for s in state.steps if s.key == key)
step.status = "failed"
step.detail = detail
state.error = detail
await state.queue.put({"event": "step", "payload": state.to_payload()})
def _sse_event(name: str, payload: Any) -> str:
return f"event: {name}\ndata: {json.dumps(payload)}\n\n"
+5 -1
View File
@@ -650,11 +650,15 @@ def make_router(
# meta repo as pr_kind='meta_body_edit'; active RFC PRs live on # 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 # the per-RFC repo as 'rfc_branch'. The API surface and the §10
# treatment are identical. # treatment are identical.
# Slice 5: §13.1 claim PRs (pr_kind='meta_claim') are also
# exposed through this surface — the merge path is the only
# affordance an admin needs, and the §10 review machinery
# gracefully degrades for frontmatter-only PRs.
row = db.conn().execute( row = db.conn().execute(
""" """
SELECT * FROM cached_prs SELECT * FROM cached_prs
WHERE rfc_slug = ? AND pr_number = ? WHERE rfc_slug = ? AND pr_number = ?
AND pr_kind IN ('rfc_branch', 'meta_body_edit') AND pr_kind IN ('rfc_branch', 'meta_body_edit', 'meta_claim')
""", """,
(slug, pr_number), (slug, pr_number),
).fetchone() ).fetchone()
+308
View File
@@ -627,6 +627,314 @@ class Bot:
) )
return sha return sha
# ----- §13 graduation: per-step primitives and rollback inverses -----
async def create_rfc_repo_for_graduation(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
title: str,
) -> dict:
"""§13.3 step 1: create the per-RFC repo.
Empty repo (no auto-init) `seed_graduated_rfc` writes the first
commit on `main`. Returns the Gitea repo payload."""
repo = await self._gitea.create_org_repo(
org, repo_name, description=f"RFC: {title}"
)
_log(
actor,
"graduate_repo_create",
rfc_slug=slug,
details={"repo": f"{org}/{repo_name}", "title": title},
)
return repo
async def seed_graduated_rfc(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
title: str,
rfc_body: str,
rfc_id: str,
meta_full: str,
meta_path: str,
owners: list[str],
arbiters: list[str],
tags: list[str],
) -> str:
"""§13.3 step 2: seed RFC.md, README.md, .rfc/metadata.yaml on the
new repo's `main`. Three create_file calls; one audit row.
Returns the final commit sha on main.
"""
import yaml as _yaml
ae = actor.email or f"{actor.gitea_login}@users.noreply"
# 2a) RFC.md — the document. The super-draft's body is migrated
# verbatim per §13.3; if the body is empty we seed a minimal
# placeholder so the editor has something to render on first open.
body = rfc_body.strip() + "\n" if rfc_body.strip() else (
f"# {title}\n\n*RFC.md to be filled in — the super-draft graduated with an empty body.*\n"
)
rfc_msg = _stamp_single(f"Seed RFC.md from super-draft {slug}", actor)
rfc_result = await self._gitea.create_file(
org, repo_name, "RFC.md",
content=body, message=rfc_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
# 2b) README.md — header pointing back at the meta-repo entry.
readme = (
f"# {rfc_id}{title}\n\n"
f"This repository carries the canonical text of {rfc_id}.\n"
f"The meta-repo entry is `{meta_path}` in `{meta_full}`.\n\n"
f"The RFC body is in `RFC.md`. Contributions go through the\n"
f"app's §8 RFC view — open a branch, propose changes, land a PR.\n"
)
readme_msg = _stamp_single(f"Seed README.md for {rfc_id}", actor)
await self._gitea.create_file(
org, repo_name, "README.md",
content=readme, message=readme_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
# 2c) .rfc/metadata.yaml — mirror of meta-repo frontmatter for
# future tooling (linting, automation, CI lookups).
meta_yaml = _yaml.safe_dump(
{
"slug": slug, "title": title, "id": rfc_id,
"owners": owners, "arbiters": arbiters, "tags": list(tags),
},
sort_keys=False,
)
meta_msg = _stamp_single(f"Seed .rfc/metadata.yaml for {rfc_id}", actor)
meta_result = await self._gitea.create_file(
org, repo_name, ".rfc/metadata.yaml",
content=meta_yaml, message=meta_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
last_sha = (
meta_result.get("commit", {}).get("sha")
or rfc_result.get("commit", {}).get("sha")
or ""
)
_log(
actor,
"graduate_repo_seed",
rfc_slug=slug,
branch_name="main",
bot_commit_sha=last_sha,
details={"repo": f"{org}/{repo_name}", "rfc_id": rfc_id},
)
return last_sha
async def open_graduation_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
slug: str,
new_file_contents: str,
prior_sha: str,
rfc_id: str,
repo_full: str,
owners: list[str],
) -> dict:
"""§13.3 step 3: open a PR against the meta repo that strips the
super-draft body and fills graduation frontmatter fields. Branch
name uses the `graduate-<slug>-<6hex>` shape dash-separated like
the other meta-repo branches per the §19.2 path-routing candidate.
"""
import secrets
branch = f"graduate-{slug}-{secrets.token_hex(3)}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
ae = actor.email or f"{actor.gitea_login}@users.noreply"
commit_subject = f"Graduate {slug}{rfc_id}"
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=ae,
)
commit_sha = (
result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha")
or ""
)
pr_title = f"Graduate {slug}{rfc_id}"
owners_str = ", ".join(owners) if owners else "(none)"
pr_body_text = (
f"Graduates super-draft `{slug}` to active.\n\n"
f"- ID: `{rfc_id}`\n"
f"- Repo: `{repo_full}`\n"
f"- Owners: {owners_str}\n\n"
f"The meta-repo entry becomes frontmatter-only; the canonical body\n"
f"moves to `RFC.md` in the new repo. The graduation sequence is\n"
f"transactional per §13.3."
)
_subject, pr_body = _stamp("", pr_body_text, actor)
pr = await self._gitea.create_pull(
org, meta_repo,
title=pr_title, body=pr_body, head=branch, base="main",
)
_log(
actor,
"graduate_pr_open",
rfc_slug=slug,
branch_name=branch,
pr_number=pr["number"],
bot_commit_sha=commit_sha,
details={"pr_title": pr_title, "rfc_id": rfc_id, "repo": repo_full},
)
return pr
async def merge_graduation_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
pr_number: int,
head_branch: str,
slug: str,
rfc_id: str,
) -> None:
"""§13.3 step 4: auto-merge the graduation PR with the admin as
merge actor. Distinct action_kind so the audit log carries the
graduation as a linkable sequence per §13.3's transactional shape."""
subject = f"Graduate {slug}{rfc_id}"
body = _trailer(actor)
await self._gitea.merge_pull(
org, meta_repo, pr_number,
merge_message_title=subject,
merge_message_body=body,
style="merge",
)
_log(
actor,
"graduate_pr_merge",
rfc_slug=slug,
branch_name=head_branch,
pr_number=pr_number,
details={"rfc_id": rfc_id},
)
# ----- §13.3 rollback inverses -----
async def delete_rfc_repo(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
reason: str,
) -> None:
"""Undo of `create_rfc_repo_for_graduation`. Records `graduate_repo_delete`
in the audit log with the rollback reason so the §13.3 stack's
rendered failure surface can be reconstructed from `actions`."""
await self._gitea.delete_repo(org, repo_name)
_log(
actor,
"graduate_repo_delete",
rfc_slug=slug,
details={"repo": f"{org}/{repo_name}", "reason": reason},
)
async def close_graduation_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
pr_number: int,
head_branch: str,
slug: str,
reason: str,
) -> None:
"""Undo of `open_graduation_pr`. Closes the PR without merging; the
branch is left in place to dodge the case where another graduation
attempt runs immediately it'll get its own `graduate-<slug>-<hex>`
suffix."""
await self._gitea.close_pull(org, meta_repo, pr_number)
_log(
actor,
"graduate_pr_close",
rfc_slug=slug,
branch_name=head_branch,
pr_number=pr_number,
details={"reason": reason},
)
# ----- §13.1 claim PRs -----
async def open_claim_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
slug: str,
new_file_contents: str,
prior_sha: str,
) -> dict:
"""§13.1: open a PR adding the actor to the entry's `owners:` list.
Touches only the frontmatter of `rfcs/<slug>.md`. Branch shape is
`claim/<slug>` single attempt per super-draft per actor (Gitea
refuses duplicate branch creation, which is the right behavior:
if the claim is still open, point the contributor at the existing
PR rather than opening a second one).
"""
branch = f"claim/{slug}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
ae = actor.email or f"{actor.gitea_login}@users.noreply"
commit_subject = f"Claim ownership of {slug} for {actor.gitea_login}"
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=ae,
)
commit_sha = (
result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha")
or ""
)
pr_title = f"Claim ownership: {slug}"
pr_description = (
f"`{actor.gitea_login}` claims ownership of super-draft `{slug}`.\n\n"
f"Per §13.1, owners and admins can merge."
)
_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_claim_pr",
rfc_slug=slug,
branch_name=branch,
pr_number=pr["number"],
bot_commit_sha=commit_sha,
details={"new_owner": actor.gitea_login},
)
return pr
# ----- Per-RFC repo: seeding (test/dev fixtures, future graduation) ----- # ----- Per-RFC repo: seeding (test/dev fixtures, future graduation) -----
async def ensure_rfc_repo_seed( async def ensure_rfc_repo_seed(
+566
View File
@@ -0,0 +1,566 @@
"""End-to-end integration tests for the Slice 5 vertical (§13 in full).
Walks the §13.3 transactional sequence end-to-end against the in-process
FakeGitea from test_propose_vertical.py:
* Seed an owned super-draft (skipping the propose+merge + §13.1 claim
round-trips already proven by Slice 1 and exercised in
test_claim_opens_meta_pr below for the §13.1 surface itself).
* GET /api/rfcs/<slug>/graduate/check returns per-field validity for
the dialog.
* GET /api/rfcs/<slug>/blocking-prs returns the §9.8 precondition list.
* POST /api/rfcs/<slug>/graduate?_sync=1 runs the five-step sequence
inline. On success: per-RFC repo exists with RFC.md / README.md /
.rfc/metadata.yaml, meta-entry body is stripped, frontmatter is
graduated, cached_rfcs.state is 'active'.
* §9.8 precondition gate refuses the start when a body-edit PR is open.
* Rollback on a mid-sequence failure unwinds repo creation cleanly.
* §13.4 chat migration: whole-doc threads under (slug, 'main') survive
graduation unchanged the rfc_slug is the canonical key per §2.3,
so no data movement is needed.
* §9.8 pre-graduation history: the new RFC's /main response surfaces
edit-branch threads under `pre_graduation_history`.
The orchestrator's `?_sync=1` seam awaits the sequence inline so the
test can assert post-conditions on the same event loop tick. Production
clients use the spec-described SSE shape via `/graduate/progress`.
"""
from __future__ import annotations
import json as _json
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_super_draft_vertical import seed_super_draft # noqa: F401
PITCH = (
"Open Human Model is a framework for representing humans.\n\n"
"It defines consent, trait, and agency in compatible terms."
)
def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str,
owners: list[str], arbiters: list[str] | None = None,
proposed_by: str = "alice", tags: list[str] | None = None) -> None:
"""Seed a super-draft directly with owners already filled in — the
§13.1 claim flow is exercised separately."""
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": owners,
"arbiters": arbiters or owners[:1],
"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,
}
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(owners),
_json.dumps(arbiters or owners[:1]),
_json.dumps(tags or []),
body, sha,
),
)
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
# ---------------------------------------------------------------------------
def test_graduate_check_validates_three_fields(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=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="Open Human Model",
pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Happy: a fresh RFC-0001 + rfc-0001-ohm repo name.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
assert r.status_code == 200, r.text
d = r.json()
assert d["id"]["ok"] is True
assert d["repo"]["ok"] is True
assert d["owners"]["ok"] is True
assert d["blocking_prs"]["ok"] is True
assert d["can_submit"] is True
# ID format error — non-numeric tail.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-abcd", "repo": "rfc-0001-ohm"})
d = r.json()
assert d["id"]["ok"] is False
assert d["can_submit"] is False
# Repo name pattern error — leading dot.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": ".bad"})
d = r.json()
assert d["repo"]["ok"] is False
def test_graduate_check_refuses_when_no_owners(app_with_fake_gitea):
"""An unclaimed super-draft fails the owners precondition; can_submit
flips false even with valid id+repo."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
# No owners — simulates an unclaimed super-draft.
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=[])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
d = r.json()
assert d["owners"]["ok"] is False
assert "No owners" in d["owners"]["error"]
assert d["can_submit"] is False
def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea):
"""The full §13.3 sequence: create repo, seed files, open PR, merge
PR, refresh cache. End state: cached_rfcs.state='active', the meta
entry's body is stripped, the per-RFC repo has RFC.md, the audit
log carries graduate_start graduate_complete bracketing the
per-step rows."""
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=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="Open Human Model",
pitch=PITCH, owners=["ben"], arbiters=["ben"],
tags=["identity", "schema"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0042", "repo_name": "rfc-0042-ohm",
"owners": ["ben"]},
)
assert r.status_code == 200, r.text
d = r.json()
assert d["finished"] is True
assert d["succeeded"] is True
assert d["repo"] == "wiggleverse/rfc-0042-ohm"
# 1. Per-RFC repo exists on Gitea.
assert ("wiggleverse", "rfc-0042-ohm") in fake.repos
# 2. Seed files landed on main.
assert ("wiggleverse", "rfc-0042-ohm", "main", "RFC.md") in fake.files
assert ("wiggleverse", "rfc-0042-ohm", "main", "README.md") in fake.files
assert ("wiggleverse", "rfc-0042-ohm", "main", ".rfc/metadata.yaml") in fake.files
rfc_md = fake.files[("wiggleverse", "rfc-0042-ohm", "main", "RFC.md")]["content"]
assert "Open Human Model is a framework" in rfc_md
# 3. Meta entry body is stripped + frontmatter graduated.
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
graduated = entry_mod.parse(meta_text)
assert graduated.state == "active"
assert graduated.id == "RFC-0042"
assert graduated.repo == "wiggleverse/rfc-0042-ohm"
assert graduated.graduated_by == "ben"
assert graduated.graduated_at # non-empty ISO date
assert graduated.body.strip() == ""
# 5. cached_rfcs.state flipped to active via the inline refresh.
cached = db.conn().execute(
"SELECT state, rfc_id, repo, body FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "active"
assert cached["rfc_id"] == "RFC-0042"
assert cached["repo"] == "wiggleverse/rfc-0042-ohm"
# cached body now mirrors RFC.md from the per-RFC repo.
assert "Open Human Model is a framework" in cached["body"]
# Audit log: graduate_start, graduate_repo_create, graduate_repo_seed,
# graduate_pr_open, graduate_pr_merge, graduate_complete, in order.
kinds = [
r["action_kind"]
for r in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
for needed in ("graduate_start", "graduate_repo_create",
"graduate_repo_seed", "graduate_pr_open",
"graduate_pr_merge", "graduate_complete"):
assert needed in kinds, f"missing audit row {needed}: {kinds}"
def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
"""§9.8: an open meta-repo body-edit PR against rfcs/<slug>.md blocks
graduation before the bot starts the sequence §13.3's rollback
complexity does not grow."""
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")
provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
# Cut an edit branch and open a body-edit PR (full Slice 4 path).
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', ?, ?, 'tighten')
""",
(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 dimension."},
).json()["pr_number"]
# /blocking-prs surfaces it.
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.get("/api/rfcs/ohm/blocking-prs")
items = r.json()["items"]
assert len(items) == 1
assert items[0]["pr_number"] == pr_number
# /check refuses can_submit.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
d = r.json()
assert d["blocking_prs"]["ok"] is False
assert d["can_submit"] is False
# POST refuses with 409 — the bot never starts the sequence.
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
"owners": ["ben"]},
)
assert r.status_code == 409
assert "blocking graduation" in r.text or "block" in r.text
def test_graduate_rollback_on_step_2_seed_failure(app_with_fake_gitea):
"""Step 2 (seed files) fails partway → the orchestrator rolls back
step 1 (delete the repo) and records the rollback in the audit log.
The cached_rfcs row stays at 'super-draft'."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
from app.gitea import Gitea, GiteaError
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Monkey-patch the bot to fail on seed_graduated_rfc. The repo
# has already been created in step 1; the rollback must delete it.
orig_seed = Bot.seed_graduated_rfc
async def boom(self, *args, **kwargs):
raise GiteaError(500, "simulated seed failure for rollback test")
Bot.seed_graduated_rfc = boom
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0003", "repo_name": "rfc-0003-ohm",
"owners": ["ben"]},
)
finally:
Bot.seed_graduated_rfc = orig_seed
assert r.status_code == 200, r.text
d = r.json()
assert d["finished"] is True
assert d["succeeded"] is False
# Repo deleted as the rollback inverse.
assert ("wiggleverse", "rfc-0003-ohm") not in fake.repos
# Meta entry unchanged.
cached = db.conn().execute(
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "super-draft"
assert cached["rfc_id"] is None
# Audit log carries the rollback row.
kinds = [
r["action_kind"]
for r in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
assert "graduate_start" in kinds
assert "graduate_repo_create" in kinds
assert "graduate_repo_delete" in kinds
assert "graduate_rollback" in kinds
assert "graduate_complete" not in kinds
def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
"""Step 3 (open PR) fails → the orchestrator rolls back steps 2 and
1 (deleting the repo, which reclaims the seed commits at the same
time). The meta-repo entry is untouched."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
from app.gitea import GiteaError
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
orig_open_pr = Bot.open_graduation_pr
async def boom(self, *args, **kwargs):
raise GiteaError(502, "simulated PR-open failure")
Bot.open_graduation_pr = boom
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0007", "repo_name": "rfc-0007-ohm",
"owners": ["ben"]},
)
finally:
Bot.open_graduation_pr = orig_open_pr
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is False
# Repo torn down.
assert ("wiggleverse", "rfc-0007-ohm") not in fake.repos
# Meta entry's body still has the pitch (not stripped).
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
assert "Open Human Model is a framework" in meta_text
def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
"""A second graduation request for a slug already in-flight is refused."""
from fastapi.testclient import TestClient
from app import api_graduation
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Seed a synthetic in-flight state so the registry refuses the second.
st = api_graduation._new_active(
"ohm", rfc_id="RFC-0001", repo_name="rfc-0001-ohm",
repo_full="wiggleverse/rfc-0001-ohm", owners=["ben"], arbiters=["ben"],
)
st.finished = False
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
"owners": ["ben"]},
)
assert r.status_code == 409
finally:
api_graduation._active.pop("ohm", None)
def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_gitea):
"""§13.4: chat threads on the super-draft's canonical-body view
(`branch_name='main'`) are interpreted as the new RFC's main-thread
after graduation. The rows don't move — the rfc_slug is canonical
per §2.3 so the same thread surfaces from both before and after
the graduation."""
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_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Materialize a whole-doc main thread + a message on it. This
# mirrors what reading the canonical-body view would create
# lazily (§8.12 / api_branches._ensure_branch_chat_thread).
cur = db.conn().execute(
"""
INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, created_by)
VALUES ('ohm', 'main', 'whole-doc', 'chat', 1)
"""
)
thread_id = cur.lastrowid
db.conn().execute(
"""
INSERT INTO thread_messages (thread_id, role, author_user_id, text)
VALUES (?, 'user', 1, 'pre-grad note on the canonical body')
""",
(thread_id,),
)
# Graduate.
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
"owners": ["ben"]},
)
assert r.status_code == 200, r.text
# The thread row's identity is unchanged.
row = db.conn().execute(
"SELECT id, branch_name FROM threads WHERE id = ?", (thread_id,),
).fetchone()
assert row["branch_name"] == "main"
# The new RFC's main view surfaces the same thread id as its
# whole-doc main thread (the entry is now active, the branch
# 'main' now points at the per-RFC repo's main, but the
# `(rfc_slug, branch_name)` key remains the canonical anchor).
r = client.get("/api/rfcs/ohm/branches/main")
assert r.status_code == 200, r.text
assert r.json()["main_thread_id"] == thread_id
def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea):
"""§9.8: after graduation, threads on meta-repo edit branches stay
attached to their original branch_name and surface from the new
RFC's /main response under `pre_graduation_history`."""
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")
provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
# Alice cuts an edit branch and starts chatting on it.
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"]
db.conn().execute(
"""
INSERT INTO thread_messages (thread_id, role, author_user_id, text)
VALUES (?, 'user', 2, 'pre-graduation note on an edit branch')
""",
(thread_id,),
)
# Ben graduates.
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0100", "repo_name": "rfc-0100-ohm",
"owners": ["ben"]},
)
assert r.status_code == 200, r.text
# /main on the now-active RFC surfaces the pre-graduation history.
r = client.get("/api/rfcs/ohm/main")
d = r.json()
assert d["state"] == "active"
hist = d["pre_graduation_history"]
assert len(hist) >= 1
assert any(h["branch_name"] == branch for h in hist)
target = next(h for h in hist if h["branch_name"] == branch)
assert target["message_count"] >= 1
def test_claim_opens_meta_pr(app_with_fake_gitea):
"""§13.1: any signed-in contributor can claim ownership of an
unclaimed super-draft; the result is a meta-repo PR
(`pr_kind='meta_claim'`) adding their gitea_login to the entry's
owners list."""
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_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=[])
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post("/api/rfcs/ohm/claim")
assert r.status_code == 200, r.text
d = r.json()
assert d["branch_name"] == "claim/ohm"
# The PR body's diff carries Alice in owners.
text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"]
ent = entry_mod.parse(text)
assert "alice" in ent.owners
# cached_prs records pr_kind='meta_claim' via refresh_meta_pulls.
row = db.conn().execute(
"SELECT pr_kind FROM cached_prs WHERE pr_number = ?", (d["pr_number"],),
).fetchone()
assert row["pr_kind"] == "meta_claim"
+12
View File
@@ -128,6 +128,18 @@ class FakeGitea:
return httpx.Response(200, json={"name": repo, "full_name": f"{owner}/{repo}"}) return httpx.Response(200, json={"name": repo, "full_name": f"{owner}/{repo}"})
return httpx.Response(404, json={"message": "not found"}) return httpx.Response(404, json={"message": "not found"})
# DELETE /repos/{owner}/{repo} — Slice 5 graduation rollback uses
# this to undo step 1 (repo create). The FakeGitea drops every
# file, branch, and PR tied to the repo so a subsequent retry
# graduation can re-create the repo cleanly.
if method == "DELETE" and m_repo:
owner, repo = m_repo.groups()
self.repos.discard((owner, repo))
self.branches.pop((owner, repo), None)
self.pulls.pop((owner, repo), None)
self.files = {k: v for k, v in self.files.items() if (k[0], k[1]) != (owner, repo)}
return httpx.Response(204, json={})
# POST /orgs/{org}/repos # POST /orgs/{org}/repos
m = re.fullmatch(r"/orgs/([^/]+)/repos", path) m = re.fullmatch(r"/orgs/([^/]+)/repos", path)
if method == "POST" and m: if method == "POST" and m:
+169 -48
View File
@@ -186,6 +186,106 @@ posting, arbiter-only merge, contributor withdraw with the
of a public PR, and the full §10.9 conflict-replay path including 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. the auto-close of the original PR on the resolution PR's merge.
### Slice 5 — shipped
Graduation per §13 in full. The §13.3 five-step transactional sequence
flips a super-draft to active: create the per-RFC repo, seed
`RFC.md` / `README.md` / `.rfc/metadata.yaml`, open a meta-repo PR
that strips the entry's body and fills the graduation frontmatter
(`state: active`, `id: RFC-NNNN`, `repo`, `graduated_at`,
`graduated_by`), auto-merge that PR with the admin as merge actor,
refresh the cache so the catalog row and the new RFC view reflect
`active` immediately. Each step goes through a new bot primitive —
`create_rfc_repo_for_graduation`, `seed_graduated_rfc`,
`open_graduation_pr`, `merge_graduation_pr` — that records its own
row in `actions`, bracketed by `graduate_start` and
`graduate_complete` for the linkable sequence the §13.3 audit shape
calls for. The orchestrator in
[`backend/app/api_graduation.py`](../backend/app/api_graduation.py)
runs the sequence as an asyncio task fed by an in-memory queue; the
§17 SSE endpoint subscribes to that queue and emits one event per
step transition, plus the trailing rollback step's events if any
earlier step fails.
Rollback is per-step and runs in reverse. Each forward step has a
paired undo registered in `_UNDO_BY_STEP`: `create_repo` → delete the
repo, `seed_files` → folded into the repo deletion (the seed commits
live inside the same repo), `open_pr` → close the graduation PR.
There is no `merge_pr` undo by design — once the meta-repo merge has
landed, graduation is irreversible per §13.5; the path forward is
`withdraw` via §3. The rollback also records `graduate_rollback` in
`actions` with the failed-at step name, the error, and the list of
undone steps, so the failure surface in the dialog and the `actions`
log carry the same record.
The §9.8 precondition gate — open body-edit PRs against
`rfcs/<slug>.md` would attempt to re-introduce a body to a
frontmatter-only entry after step 3 — is enforced before the bot
starts the sequence, so the §13.3 rollback complexity does not grow.
The check runs both client-side as the dialog probes
`GET /api/rfcs/<slug>/blocking-prs` and server-side at the top of
`POST .../graduate` as an atomic re-check.
§13.4 chat migration is a database semantic no-op. The whole-doc
main thread on the super-draft (`rfc_slug=<slug>`, `branch_name='main'`)
is the same row interpreted as the super-draft's canonical-body
thread before graduation and as the new RFC's main thread after —
the slug is the canonical key per §2.3, the branch_name 'main' now
points at the per-RFC repo's main, no data movement is needed. Range
and paragraph sub-threads on the canonical-body view migrate the
same way per §9.8. Edit-branch chats stay attached to their original
`branch_name` on the meta repo per §9.8's "no data movement" framing;
the §9.8 pre-graduation history affordance on the new RFC view
surfaces them as a distinct disclosure in the breadcrumb dropdown.
The §13.1 claim flow landed alongside graduation since claiming is
the prerequisite for non-admin graduation. The bot grew
`open_claim_pr`; the existing `api_prs` merge endpoint broadened to
accept `pr_kind='meta_claim'` so the merge surface inherits
structurally from §10. Until §13.1's claim runs, the dialog refuses
the start when `owners=[]` and the popover surfaces "Claim ownership
yourself" as a remediation affordance — admins are contributors per
§6.1 and can claim solo if they intend to graduate without further
ceremony.
The five §17 routes Slice 5 added:
| Method | Path | § |
| ------ | ----------------------------------------------- | ------- |
| POST | `/api/rfcs/{slug}/claim` | §13.1 |
| GET | `/api/rfcs/{slug}/blocking-prs` | §13.2 |
| GET | `/api/rfcs/{slug}/graduate/check` | §13.2 |
| POST | `/api/rfcs/{slug}/graduate` | §13.3 |
| GET | `/api/rfcs/{slug}/graduate/progress` | §13.3 |
On the frontend, `RFCView.jsx`'s breadcrumb actions grew a
`Graduate to RFC repo` button (admins/owners and entry owners) and
a `Claim ownership` button (signed-in non-owners). `GraduateDialog.jsx`
owns the three-field surface with debounced `/check` polling, the
precondition popover backed by `/blocking-prs`, and the live step
stack fed by an `EventSource` on the progress SSE. The `BranchDropdown`
gains a `Pre-graduation history (N)` disclosure that surfaces
edit-branch threads on the new RFC view per §9.8.
Slice 5 ships covered by
[`backend/tests/test_graduation_vertical.py`](../backend/tests/test_graduation_vertical.py) —
ten integration tests against the FakeGitea (extended with
`DELETE /repos/{owner}/{repo}` for the rollback inverse). The tests
cover the dialog validator's per-field checks, the no-owners
refusal, the §9.8 open-body-edit-PR precondition refusing the
start, the §13.3 happy path end-to-end (with audit-log verification),
mid-sequence rollback at step 2 (seed) and step 3 (PR open), the
concurrent-graduation refusal, §13.4's chat-row-survives-without-
data-movement contract, the §9.8 pre-graduation history surface,
and the §13.1 claim PR cycle. The full Slices 15 test suite is
45/45 green.
The orchestrator's `?_sync=1` test seam on `POST .../graduate`
awaits the sequence inline so integration tests can assert
post-conditions without driving the SSE. Production clients use the
spec-described shape — POST returns immediately and the client
subscribes to the progress SSE.
### Slice 4 — shipped ### Slice 4 — shipped
Super-draft body editing per §9.5 + §9.6 + §9.7. The §17 routing-collapse Super-draft body editing per §9.5 + §9.6 + §9.7. The §17 routing-collapse
@@ -316,65 +416,86 @@ spec:
## Next slice ## Next slice
**Slice 5: graduation per §13.** **Slice 6: notifications per §15.**
A super-draft becomes an active RFC through the §13 graduation Every other vertical now produces signals: propose, claim, merge,
sequence — the dialog (§13.2), the five-step transactional sequence graduate, body edits, manual flushes, PR open/withdraw/merge,
with rollback (§13.3), the chat-follows-the-work migration (§13.4), review threads, conflict-replay, super-draft chat. Slice 6 builds
the pre-graduation history affordance for the new RFC view (§9.8), the inbox, the fan-out, the digest, and the email loop that turn
and the precondition gate that refuses to graduate while body-edit those signals into a contributor's surface. The §5 schema already
PRs are open (§9.8 / §13.3). carries the notifications, watches, branch_chat_seen,
notification_user_mutes, and notification_digests tables; Topic 13's
session settled the producer-side rules per §15.1 (the signal-surface
stack), the §15.2 inbox grouping, §15.3 badges and toasts, §15.4
email categories, §15.5 digest cadence, §15.6 watch/subscription,
§15.7 unread mechanism, §15.8 do-not-disturb, and §15.9 attribution.
Slice 4 left this clean: the §9.5 metadata pane, the body-edit PR Slices 15 left this clean: every user gesture goes through the
flow, and the active-RFC PR flow all converge on the same dispatch. bot wrapper and lands an `actions` row with the underlying actor.
Graduation is the act that flips an entry's state from `super-draft` The producer-side hook is "after a write succeeds, evaluate watches
to `active`, creates the per-RFC repo via `bot.ensure_rfc_repo_seed` and fan-out notification rows." The consumer-side hook is the
(which Slice 2 added as a forward-looking seam), copies the body header badge, the inbox panel, the toast surface, and the per-row
from the frontmatter envelope into the new repo's `RFC.md`, strips read-state machinery.
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.
What Slice 5 owns specifically: What Slice 6 owns specifically:
- The §13.2 Graduate dialog — three fields (integer ID, repo name, - **The producer fan-out.** Every `actions` row whose event maps to a
initial owners), the inline-validation endpoint §15 signal produces zero-or-more `notifications` rows by joining
`GET /api/rfcs/{slug}/graduate/check`, the blocking-PRs popover against `watches` and applying the §15.1 priority rules. The
via `GET /api/rfcs/{slug}/blocking-prs`, and the merge-actor set fan-out lives as a small module that the bot wrapper invokes
per §13's authority rules. inline after each write — same chokepoint shape Slice 1's
- The §13.3 transactional sequence — five steps emitted as an SSE `_log` uses.
stream via `GET /api/rfcs/{slug}/graduate/progress`, with each - **The §15.2 inbox.** `GET /api/notifications` with the
step's `pending → running → done/failed` transitions surfacing in `unread` / `rfc_slug` / `category` / `bundled` filter chips,
the dialog, and a trailing `rollback` step if any earlier step `POST /api/notifications/<id>/read` for per-row marking,
fails. The bot grows `graduate` plus the rollback primitives the `POST /api/notifications/read` for the bulk filter mark, and the
sequence needs. SSE `GET /api/notifications/stream` that backs the live badge.
- The §13.4 chat migration — the whole-doc main thread on the - **The §15.3 surface.** The header badge counter (live via the SSE),
super-draft (`rfc_slug=<slug>`, `branch_name='main'`) re-anchors the toast on personal-direct events while the user is active, and
onto the new RFC's main thread; range and paragraph sub-threads the ambient signal — a colored dot per row on the §7 catalog
on the canonical-body view migrate too per §9.8's clarification. pointing at watched RFCs with unseen activity.
Edit-branch chats stay attached to their original `branch_name` - **The §15.4 email loop.** Per-category opt-in/out preferences on
on the meta repo per §9.8 — no data movement, surfaced by the the users table (already in the schema), the `/api/users/me/notification-preferences`
pre-graduation history affordance. endpoints, the email-send adapter that routes a notification's
- The §9.8 pre-graduation history affordance on the new RFC view — category through the user's category toggle, and the
the slug remains the canonical key per §2.3, so the query is a `POST /api/webhooks/email-bounce` receiver that sets the global
straightforward lookup of `threads` and `changes` rows where opt-out. Plus the `GET /api/email/unsubscribe` signed-URL
`rfc_slug = <slug>` and `branch_name` begins with the meta-repo one-click flow.
edit prefix. - **The §15.5 digest.** A scheduled-job that runs daily and weekly
to roll up unseen notifications into a single email, with the
`notification_digests` table tracking what was included so the
next digest skips what already shipped.
- **The §15.6 watch model.** Auto-watch on first interaction with
an RFC, the per-row state column (`watching` / `following` /
`muted`), the 90-day auto-decay for unset rows, and the explicit
`POST /api/rfcs/<slug>/watch` overrides.
- **The §15.7 unread mechanism.** Advance the `branch_chat_seen`
cursor on every branch read, reconcile inbox notifications to
read when their underlying surface is consumed.
- **The §15.8 do-not-disturb.** Quiet-hours config on the user, the
per-user notification mute list, the orthogonality vs §6.2's
app-wide write-mute.
What Slice 5 does NOT own: What Slice 6 does NOT own:
- The §15 notification surface (still Slice 6).
- The §14 chrome polish (still Slice 7). - The §14 chrome polish (still Slice 7).
- The §12 30/90 branch-hygiene timers (still Slice 8). - The §12 30/90 branch-hygiene timers (still Slice 8).
- The §16 deferred items.
The carryovers Slice 5 inherits — the `ensure_rfc_repo_seed` The carryovers Slice 6 inherits — the existing `actions` audit log
primitive Slice 2 added, the body-edit-PR precondition gate (every signal traces back to a row there per §15.9), the SSE
(checked against the same `cached_prs` shape Slice 4 wired), and machinery from Slices 2 and 5 (chat-stream and graduate-progress
the existing `actions` audit-log shape for the rollback record. respectively), and the §5 schema's notification tables (already
in place from Topic 13).
The §15 surface depends on the producers being in place; with
Slice 5 landing the last structural producer (graduation events,
specifically `graduate_complete` as a personal-direct event for
the proposer per §15.4), every signal a contributor needs to see
is now in the audit log waiting to be fanned out.
The next build session should read `SPEC.md`, `README.md`, 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 `docs/DEV.md`, and `SPEC.md`'s §19.1 and pick up Slice 6 cleanly
without re-briefing. The working agreement in §19.3 continues to without re-briefing. The working agreement in §19.3 continues to
apply: implement the slice, correct the spec only where running apply: implement the slice, correct the spec only where running
code reveals it was wrong at a structural level, accumulate new code reveals it was wrong at a structural level, accumulate new
+118
View File
@@ -1021,3 +1021,121 @@
} }
.pr-review-quote { font-size: 11px; color: #6b7280; margin-bottom: 6px; } .pr-review-quote { font-size: 11px; color: #6b7280; margin-bottom: 6px; }
.pr-review-quote pre { background: #fff; padding: 4px 8px; border-radius: 4px; margin: 4px 0 0 0; } .pr-review-quote pre { background: #fff; padding: 4px 8px; border-radius: 4px; margin: 4px 0 0 0; }
/* ── Slice 5: §13 graduation dialog ──────────────────────────────────── */
.modal-wide { width: min(720px, 92vw); }
.modal-intro { margin: 0 0 16px 0; font-size: 13px; color: #4b5563; line-height: 1.55; }
.form-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; }
.form-row label { font-weight: 600; font-size: 12px; color: #1a1a1a; }
.form-row input, .form-row textarea {
font: inherit; padding: 6px 8px; border: 1px solid #d1d5db; border-radius: 4px;
}
.field-help { font-size: 11px; color: #6b7280; margin: 2px 0 0 0; }
.field-error { font-size: 12px; color: #b91c1c; margin: 4px 0 0 0; }
.owner-list {
display: flex; flex-wrap: wrap; gap: 6px; min-height: 28px;
padding: 4px 0;
}
.owner-empty { font-size: 12px; color: #6b7280; font-style: italic; }
.owner-chip {
display: inline-flex; align-items: center; gap: 4px;
background: #eef2ff; color: #3730a3; padding: 2px 8px; border-radius: 99px;
font-size: 12px;
}
.owner-chip-x {
background: none; border: none; cursor: pointer; color: #6b7280;
font-size: 14px; line-height: 1; padding: 0 2px;
}
.owner-chip-x:hover { color: #b91c1c; }
.owner-picker { display: flex; gap: 6px; margin-top: 6px; }
.owner-picker input { flex: 1; }
.precondition-block { margin-top: 12px; padding: 10px; background: #fef2f2; border-radius: 6px; border: 1px solid #fecaca; }
.precondition-toggle {
background: none; border: none; cursor: pointer; color: #b91c1c; font-weight: 600;
font-size: 13px; padding: 0;
}
.precondition-popover { margin-top: 8px; display: flex; flex-direction: column; gap: 8px; }
.precondition-row {
display: flex; justify-content: space-between; align-items: center;
background: #fff; padding: 8px 10px; border-radius: 4px; border: 1px solid #f3f4f6;
}
.precondition-row-meta { font-size: 11px; color: #6b7280; }
.precondition-row-actions a { color: #5b5bd6; }
.precondition-help { font-size: 11px; color: #6b7280; margin: 4px 0 0 0; font-style: italic; }
.btn-graduate { margin-left: 6px; }
.step-stack { display: flex; flex-direction: column; gap: 6px; margin: 12px 0; }
.step-row {
display: grid; grid-template-columns: 24px 1fr auto; gap: 10px; align-items: center;
padding: 8px 10px; border-radius: 6px; background: #f9fafb; border: 1px solid #f3f4f6;
}
.step-row.step-running { background: #eff6ff; border-color: #bfdbfe; }
.step-row.step-done { background: #f0fdf4; border-color: #bbf7d0; }
.step-row.step-failed { background: #fef2f2; border-color: #fecaca; }
.step-row.step-not-reached { opacity: 0.45; }
.step-marker {
display: inline-block; width: 16px; height: 16px; border-radius: 50%;
background: #d1d5db;
}
.step-marker-pending { background: #d1d5db; }
.step-marker-running { background: #3b82f6; animation: pulse 1s ease-in-out infinite; }
.step-marker-done { background: #10b981; }
.step-marker-failed { background: #ef4444; }
.step-marker-not-reached { background: #e5e7eb; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.step-label { font-weight: 600; font-size: 13px; color: #1a1a1a; }
.step-detail { font-size: 11px; color: #6b7280; margin-top: 2px; }
.step-status-pill {
font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 3px;
text-transform: uppercase; letter-spacing: 0.04em;
}
.pill-pending { background: #e5e7eb; color: #4b5563; }
.pill-running { background: #3b82f6; color: #fff; }
.pill-done { background: #10b981; color: #fff; }
.pill-failed { background: #ef4444; color: #fff; }
.pill-not-reached { background: #e5e7eb; color: #9ca3af; }
.rollback-divider {
margin: 10px 0 6px 0; font-size: 11px; text-transform: uppercase;
letter-spacing: 0.06em; color: #b91c1c; font-weight: 700;
}
.what-happened {
margin-top: 14px; padding: 12px; background: #fef2f2;
border: 1px solid #fecaca; border-radius: 6px;
}
.what-happened h3 { margin: 0 0 6px 0; font-size: 14px; color: #991b1b; }
.what-happened p { margin: 0 0 6px 0; font-size: 13px; color: #4b5563; line-height: 1.55; }
.graduation-complete {
margin-top: 14px; padding: 12px; background: #f0fdf4;
border: 1px solid #bbf7d0; border-radius: 6px;
}
.graduation-complete h3 { margin: 0 0 6px 0; font-size: 14px; color: #166534; }
.graduation-complete p { margin: 0; font-size: 13px; color: #14532d; line-height: 1.55; }
.modal-progress-note { font-size: 12px; color: #6b7280; }
.modal-error {
padding: 8px 12px; background: #fef2f2; color: #991b1b;
border-top: 1px solid #fecaca; font-size: 12px;
}
.rfc-error-banner {
padding: 8px 12px; background: #fef2f2; color: #991b1b;
border-bottom: 1px solid #fecaca; font-size: 12px;
}
.branch-dropdown-section {
font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em;
color: #6b7280; padding: 8px 10px 4px 10px; font-weight: 700;
}
.branch-dropdown-item.pre-graduation {
font-style: italic; color: #4b5563;
}
.branch-dropdown-item.pre-graduation .branch-meta {
font-size: 10px; color: #9ca3af; margin-left: auto;
}
+48
View File
@@ -221,6 +221,54 @@ export async function editMetadata(slug, { title, tags, prDescription }) {
return jsonOrThrow(res) return jsonOrThrow(res)
} }
// ── Slice 5: §13 graduation + §13.1 claim ────────────────────────────────
export async function claimOwnership(slug) {
const res = await fetch(`/api/rfcs/${slug}/claim`, { method: 'POST' })
return jsonOrThrow(res)
}
export async function listBlockingPRs(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/blocking-prs`))
}
export async function graduateCheck(slug, { id, repo }) {
const params = new URLSearchParams()
if (id != null) params.set('id', id)
if (repo != null) params.set('repo', repo)
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/graduate/check?${params}`))
}
export async function startGraduation(slug, { rfcId, repoName, owners }) {
const res = await fetch(`/api/rfcs/${slug}/graduate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rfc_id: rfcId, repo_name: repoName, owners }),
})
return jsonOrThrow(res)
}
// Open an EventSource on the §13.3 progress stream. Returns the
// EventSource so the caller can close() on dialog dismiss. Calls
// onUpdate with the parsed state payload for every event.
export function openGraduationProgress(slug, { onUpdate, onDone, onError }) {
const es = new EventSource(`/api/rfcs/${slug}/graduate/progress`)
const handle = (e) => {
try {
const payload = JSON.parse(e.data)
onUpdate?.(payload, e.type)
if (e.type === 'done' && payload?.finished) onDone?.(payload)
} catch (err) {
onError?.(err)
}
}
for (const name of ['snapshot', 'step', 'rollback_step', 'completed', 'rolled_back', 'done']) {
es.addEventListener(name, handle)
}
es.onerror = (e) => { onError?.(e); es.close() }
return es
}
// ── Slice 3: the §10 PR flow ───────────────────────────────────────────── // ── Slice 3: the §10 PR flow ─────────────────────────────────────────────
export async function draftPRText(slug, branch) { export async function draftPRText(slug, branch) {
+357
View File
@@ -0,0 +1,357 @@
// GraduateDialog.jsx the §13.2 Graduate dialog and the §13.3 step stack.
//
// Renders three editable fields (integer ID, repo name, initial owners)
// with debounced server-side validation per §13.2 and a precondition
// popover backed by /blocking-prs for the §9.8 open-body-edit-PR gate.
//
// On confirm, opens the §13.3 SSE stream and renders the five named
// steps with per-step states. On failure, the rollback step's events
// append to the stack and a "What happened" panel renders below until
// the admin dismisses it.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
graduateCheck,
listBlockingPRs,
openGraduationProgress,
startGraduation,
} from '../api'
const CHECK_DEBOUNCE_MS = 250
const STEP_KEY_ORDER = ['create_repo', 'seed_files', 'open_pr', 'merge_pr', 'refresh_cache']
export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
// Suggest defaults from the catalog.
const suggestedId = useMemo(() => suggestNextRfcId(entry?.allKnownIds || []), [entry])
const [rfcId, setRfcId] = useState(suggestedId)
const [repoName, setRepoName] = useState(`rfc-${stripPrefix(suggestedId)}-${slug}`)
const [owners, setOwners] = useState(entry?.owners?.length ? entry.owners : [])
const [newOwner, setNewOwner] = useState('')
const [checkResult, setCheckResult] = useState(null)
const [blockingPRs, setBlockingPRs] = useState([])
const [precondPopover, setPrecondPopover] = useState(false)
const [phase, setPhase] = useState('idle') // idle | running | done | rolled_back | error
const [streamState, setStreamState] = useState(null)
const [submitError, setSubmitError] = useState(null)
const esRef = useRef(null)
// Initial blocking-PRs probe + ongoing /check polling.
useEffect(() => {
listBlockingPRs(slug).then(({ items }) => setBlockingPRs(items || [])).catch(() => {})
}, [slug])
useEffect(() => {
const t = setTimeout(() => {
graduateCheck(slug, { id: rfcId, repo: repoName })
.then(setCheckResult)
.catch(() => {})
}, CHECK_DEBOUNCE_MS)
return () => clearTimeout(t)
}, [slug, rfcId, repoName])
useEffect(() => () => { esRef.current?.close() }, [])
const idError = checkResult?.id?.error || null
const repoError = checkResult?.repo?.error || null
const ownersOk = owners.length > 0
const ownersError = ownersOk ? null : 'Add at least one initial owner'
const blockingError = blockingPRs.length > 0
? `${blockingPRs.length} open body-edit PR${blockingPRs.length === 1 ? '' : 's'} blocking graduation`
: null
// First-blocker tooltip text per §13.2.
const firstBlocker = idError || repoError || ownersError || blockingError
const canSubmit = !firstBlocker && phase === 'idle' && checkResult?.id?.ok && checkResult?.repo?.ok
const handleAddOwner = useCallback(() => {
const v = newOwner.trim().toLowerCase()
if (!v || owners.includes(v)) return
setOwners(prev => [...prev, v])
setNewOwner('')
}, [newOwner, owners])
const handleRemoveOwner = useCallback((login) => {
setOwners(prev => prev.filter(o => o !== login))
}, [])
const handleConfirm = useCallback(async () => {
setSubmitError(null)
setPhase('running')
try {
await startGraduation(slug, { rfcId, repoName, owners })
} catch (err) {
setPhase('idle')
setSubmitError(err.message)
return
}
esRef.current = openGraduationProgress(slug, {
onUpdate: (payload) => {
setStreamState(payload)
if (payload?.finished) {
if (payload.succeeded) {
setPhase('done')
// Short hold per §13.3, then dismiss.
setTimeout(() => onCompleted?.(payload), 1500)
} else {
setPhase('rolled_back')
}
}
},
onError: () => {
setSubmitError('Lost connection to the graduation stream — refresh to see current state.')
setPhase('error')
},
})
}, [slug, rfcId, repoName, owners, onCompleted])
// ----- Render -----
const showStack = phase !== 'idle' && (streamState?.steps?.length || 0) > 0
return (
<div className="modal-overlay" onClick={(e) => { if (e.target === e.currentTarget && phase === 'idle') onClose?.() }}>
<div className="modal modal-wide">
<div className="modal-header">
<h2>Graduate `{slug}` to active</h2>
{phase === 'idle' && <button className="modal-close" onClick={onClose}>×</button>}
</div>
{!showStack && (
<div className="modal-body">
<p className="modal-intro">
§13: graduate the super-draft to its own repo. The meta-repo entry
becomes frontmatter-only; the canonical body moves to `RFC.md` in
the new repo. The sequence runs as five transactional steps with
rollback per §13.3.
</p>
<div className="form-row">
<label>Integer ID</label>
<input
type="text"
value={rfcId}
onChange={(e) => setRfcId(e.target.value.trim())}
placeholder="RFC-NNNN"
disabled={phase !== 'idle'}
/>
<p className="field-help">Pre-filled as the next free integer; editable to reserve gaps.</p>
{idError && <p className="field-error">{idError}</p>}
</div>
<div className="form-row">
<label>Repo name</label>
<input
type="text"
value={repoName}
onChange={(e) => setRepoName(e.target.value.trim())}
placeholder="rfc-NNNN-slug"
disabled={phase !== 'idle'}
/>
<p className="field-help">Becomes `&lt;org&gt;/{repoName || 'rfc-…'}` on Gitea.</p>
{repoError && <p className="field-error">{repoError}</p>}
</div>
<div className="form-row">
<label>Initial owners</label>
<div className="owner-list">
{owners.length === 0 && <span className="owner-empty">No owners yet add at least one.</span>}
{owners.map(o => (
<span key={o} className="owner-chip">
{o}
<button
type="button"
className="owner-chip-x"
onClick={() => handleRemoveOwner(o)}
disabled={phase !== 'idle'}
aria-label={`Remove ${o}`}
>×</button>
</span>
))}
</div>
<div className="owner-picker">
<input
type="text"
value={newOwner}
onChange={(e) => setNewOwner(e.target.value)}
placeholder="Gitea login"
disabled={phase !== 'idle'}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddOwner() }}
/>
<button
type="button"
className="btn-secondary"
onClick={handleAddOwner}
disabled={phase !== 'idle' || !newOwner.trim()}
>Add</button>
</div>
{ownersError && <p className="field-error">{ownersError}</p>}
</div>
{blockingPRs.length > 0 && (
<div className="precondition-block">
<button
type="button"
className="precondition-toggle"
onClick={() => setPrecondPopover(p => !p)}
>
{blockingPRs.length} open body-edit PR{blockingPRs.length === 1 ? '' : 's'} blocking graduation
&nbsp;{precondPopover ? '▾' : '▸'}
</button>
{precondPopover && (
<div className="precondition-popover">
{blockingPRs.map(pr => (
<div key={pr.pr_number} className="precondition-row">
<div className="precondition-row-main">
<strong>PR #{pr.pr_number}</strong> {pr.title || '(no title)'}
<div className="precondition-row-meta">
{pr.author ? `by @${pr.author}` : ''}
{pr.last_activity_at ? ` · ${pr.last_activity_at.slice(0, 10)}` : ''}
</div>
</div>
<div className="precondition-row-actions">
<a
className="btn-link"
href={`/rfc/${slug}/pr/${pr.pr_number}`}
target="_blank"
rel="noreferrer"
>Open </a>
</div>
</div>
))}
<p className="precondition-help">
§9.8: open body-edit PRs would attempt to re-introduce a
body to a frontmatter-only entry after step 3. Resolve
them (merge or withdraw) and re-open this dialog.
</p>
</div>
)}
</div>
)}
</div>
)}
{showStack && (
<div className="modal-body">
<StepStack
steps={streamState?.steps || []}
rollbackSteps={streamState?.rollback_steps || []}
/>
{phase === 'rolled_back' && (
<div className="what-happened">
<h3>What happened</h3>
<p>
The graduation could not complete. The app rolled back the
steps that had already run; nothing was left half-applied on
Gitea. Error: <code>{streamState?.error || 'unknown'}</code>.
</p>
<p>
Read the failure detail next to the red step above. Resolve
the underlying cause (a repo-name collision, a network flake,
a concurrent PR landing on `rfcs/{slug}.md`) and try again.
</p>
</div>
)}
{phase === 'done' && (
<div className="graduation-complete">
<h3>Graduation complete</h3>
<p>
`{slug}` is now active as <strong>{streamState?.rfc_id}</strong>{' '}
at <code>{streamState?.repo_full}</code>. The catalog and the
RFC view reflect the new state.
</p>
</div>
)}
</div>
)}
<div className="modal-actions">
{phase === 'idle' && (
<>
<button className="btn-secondary" onClick={onClose}>Cancel</button>
<button
className="btn-primary"
onClick={handleConfirm}
disabled={!canSubmit}
title={canSubmit ? '' : firstBlocker || ''}
>
Graduate to RFC repo
</button>
</>
)}
{phase === 'running' && (
<span className="modal-progress-note">Running graduation sequence</span>
)}
{(phase === 'rolled_back' || phase === 'error') && (
<button className="btn-secondary" onClick={onClose}>Close</button>
)}
{phase === 'done' && (
<button className="btn-primary" onClick={() => onCompleted?.(streamState)}>
View the new RFC
</button>
)}
</div>
{submitError && phase !== 'rolled_back' && (
<div className="modal-error">Error: {submitError}</div>
)}
</div>
</div>
)
}
function StepStack({ steps, rollbackSteps }) {
return (
<div className="step-stack">
{steps.map(s => <StepRow key={s.key} step={s} />)}
{rollbackSteps.length > 0 && (
<div className="rollback-divider">Rollback</div>
)}
{rollbackSteps.map(s => <StepRow key={s.key} step={s} />)}
</div>
)
}
function StepRow({ step }) {
return (
<div className={`step-row step-${step.status}`}>
<span className={`step-marker step-marker-${step.status}`} />
<div className="step-text">
<div className="step-label">{step.label}</div>
{step.detail && <div className="step-detail">{step.detail}</div>}
</div>
<div className={`step-status-pill pill-${step.status}`}>{labelFor(step.status)}</div>
</div>
)
}
function labelFor(status) {
switch (status) {
case 'pending': return 'pending'
case 'running': return 'running'
case 'done': return 'done'
case 'failed': return 'failed'
case 'not-reached': return 'not reached'
default: return status
}
}
function suggestNextRfcId(existing) {
const used = new Set()
for (const id of existing) {
const m = /^RFC-(\d+)$/.exec(id || '')
if (m) used.add(Number(m[1]))
}
const next = used.size === 0 ? 1 : (Math.max(...used) + 1)
return `RFC-${String(next).padStart(4, '0')}`
}
function stripPrefix(rfcId) {
return rfcId?.startsWith('RFC-') ? rfcId.slice(4) : rfcId
}
+75
View File
@@ -40,6 +40,8 @@ import ChatPanel from './ChatPanel.jsx'
import ChangePanel from './ChangePanel.jsx' import ChangePanel from './ChangePanel.jsx'
import DiffView from './DiffView.jsx' import DiffView from './DiffView.jsx'
import PRModal from './PRModal.jsx' import PRModal from './PRModal.jsx'
import GraduateDialog from './GraduateDialog.jsx'
import { claimOwnership } from '../api'
const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail. const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
const MANUAL_DEBOUNCE_MS = 800 const MANUAL_DEBOUNCE_MS = 800
@@ -108,6 +110,8 @@ export default function RFCView({ viewer }) {
// metadata pane, and the start-contributing dispatch target. // metadata pane, and the start-contributing dispatch target.
const isSuperDraft = entry?.state === 'super-draft' const isSuperDraft = entry?.state === 'super-draft'
const [showMetadataPane, setShowMetadataPane] = useState(false) const [showMetadataPane, setShowMetadataPane] = useState(false)
const [showGraduateDialog, setShowGraduateDialog] = useState(false)
const [claimError, setClaimError] = useState(null)
// Load main view + branch view whenever slug/branch changes. // Load main view + branch view whenever slug/branch changes.
useEffect(() => { useEffect(() => {
@@ -538,8 +542,42 @@ export default function RFCView({ viewer }) {
Metadata Metadata
</button> </button>
)} )}
{isSuperDraft && viewer && entry?.owners && !entry.owners.includes(viewer.gitea_login) && (
<button
type="button"
className="btn-link"
onClick={async () => {
setClaimError(null)
try {
const result = await claimOwnership(slug)
if (result?.noop) return
if (result?.pr_number) {
navigate(`/rfc/${slug}/pr/${result.pr_number}`)
}
} catch (err) {
setClaimError(err.message)
}
}}
title="§13.1 — open a claim PR adding you to the entry's owners"
>
Claim ownership
</button>
)}
{isSuperDraft && viewer && (viewer.role === 'owner' || viewer.role === 'admin' || (entry?.owners || []).includes(viewer.gitea_login)) && (
<button
type="button"
className="btn-primary btn-graduate"
onClick={() => setShowGraduateDialog(true)}
title="§13 — graduate this super-draft to a per-RFC repo"
>
Graduate to RFC repo
</button>
)}
</div> </div>
</div> </div>
{claimError && (
<div className="rfc-error-banner">Claim failed: {claimError}</div>
)}
{/* Two columns: editor + chat */} {/* Two columns: editor + chat */}
<div className="rfc-body"> <div className="rfc-body">
@@ -693,6 +731,20 @@ export default function RFCView({ viewer }) {
/> />
)} )}
{showGraduateDialog && (
<GraduateDialog
slug={slug}
entry={entry}
onClose={() => setShowGraduateDialog(false)}
onCompleted={() => {
setShowGraduateDialog(false)
// The catalog row and the RFC view now reflect `active`.
getRFC(slug).then(setEntry).catch(() => {})
getRFCMain(slug).then(setMainView).catch(() => {})
}}
/>
)}
{showMetadataPane && ( {showMetadataPane && (
<MetadataPaneModal <MetadataPaneModal
slug={slug} slug={slug}
@@ -776,6 +828,7 @@ function BranchDropdown({ current, mainView, isSuperDraft, onPick }) {
// RFCs it is literally `main` per §8.1. // RFCs it is literally `main` per §8.1.
const mainLabel = isSuperDraft ? 'canonical body' : 'main' const mainLabel = isSuperDraft ? 'canonical body' : 'main'
const items = [{ name: 'main', label: mainLabel }, ...(mainView?.branches || [])] const items = [{ name: 'main', label: mainLabel }, ...(mainView?.branches || [])]
const preGrad = mainView?.pre_graduation_history || []
const currentLabel = current === 'main' ? mainLabel : current const currentLabel = current === 'main' ? mainLabel : current
return ( return (
<div className="branch-dropdown"> <div className="branch-dropdown">
@@ -804,6 +857,28 @@ function BranchDropdown({ current, mainView, isSuperDraft, onPick }) {
)} )}
</button> </button>
))} ))}
{preGrad.length > 0 && (
<>
<div className="branch-dropdown-section">
Pre-graduation history ({preGrad.length})
</div>
{preGrad.map(b => (
<button
key={b.branch_name}
type="button"
className={`branch-dropdown-item pre-graduation ${b.branch_name === current ? 'active' : ''}`}
onClick={() => { setOpen(false); onPick(b.branch_name) }}
title="§9.8: meta-repo edit branch from before graduation — read-only"
>
<span className="branch-name">{b.branch_name}</span>
<span className="branch-meta">
{b.thread_count} thread{b.thread_count === 1 ? '' : 's'}
{b.change_count ? ` · ${b.change_count} change${b.change_count === 1 ? '' : 's'}` : ''}
</span>
</button>
))}
</>
)}
</div> </div>
)} )}
</div> </div>