From 1b0968a9a2bcb8a2723735fad2dd55a55ae56b3c Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 24 May 2026 21:52:29 -0700 Subject: [PATCH] =?UTF-8?q?Slice=205:=20graduation=20per=20=C2=A713?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 62 +- SPEC.md | 206 +++-- backend/app/api.py | 4 +- backend/app/api_branches.py | 125 ++- backend/app/api_graduation.py | 940 +++++++++++++++++++++ backend/app/api_prs.py | 6 +- backend/app/bot.py | 308 +++++++ backend/tests/test_graduation_vertical.py | 566 +++++++++++++ backend/tests/test_propose_vertical.py | 12 + docs/DEV.md | 217 +++-- frontend/src/App.css | 118 +++ frontend/src/api.js | 48 ++ frontend/src/components/GraduateDialog.jsx | 357 ++++++++ frontend/src/components/RFCView.jsx | 75 ++ 14 files changed, 2872 insertions(+), 172 deletions(-) create mode 100644 backend/app/api_graduation.py create mode 100644 backend/tests/test_graduation_vertical.py create mode 100644 frontend/src/components/GraduateDialog.jsx diff --git a/README.md b/README.md index d3921c7..23bdd49 100644 --- a/README.md +++ b/README.md @@ -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** 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 -repo → an owner merges from the pending-idea view → the super-draft -appears in the catalog → opening it renders the body. +Slices 1–5 are shipped. End-to-end paths the app supports today: -This exercises the §4 cache (webhook + reconciler), the §6 permission -model (the owner-only merge button, the contributor-only propose -modal), the §1 bot wrapper (every Git write goes through it, every -commit and PR carries the `On-behalf-of:` trailer), and the §9 -propose-merge-render path. +- **Propose → idea PR → merge → super-draft** (Slice 1, §9.1–§9.3). +- **Super-draft body editing** via meta-repo edit branches, with AI + participation, the change-card panel, manual flushes, threads, + flags, and DiffView (Slice 4, §9.5–§9.7 + §8 inherited). +- **The §8 active-RFC view** in full: per-branch chat, AI + participation through the `` 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, -AI participation, the change-card panel, PRs against per-RFC repos, -graduation, notifications, the landing page's full polish. Those -slices are listed in [`docs/DEV.md`](./docs/DEV.md). +This exercises the §4 cache (webhook + reconciler), the §6 +permission model in full, the §1 bot wrapper (every Git write goes +through it, every commit and PR carries the `On-behalf-of:` +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 @@ -177,11 +197,12 @@ After bring-up: ## Seeding an active RFC for §8 testing -Slice 2 (the active-RFC view per §8) needs an entry whose `state` is -`active` and whose per-RFC repo exists. Slice 5's graduation flow -will land the proper path; until then, `scripts/seed_test_rfc.py` is -the dev shortcut. Sign in once via OAuth so a `users` row exists, -then: +With Slice 5 shipped, the `/graduate` flow in the app is the +canonical path from super-draft to active. The +[`scripts/seed_test_rfc.py`](./scripts/seed_test_rfc.py) shortcut is +still around for dev sessions that want an active RFC without +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 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-`, seeds `RFC.md` on -`main`, registers the webhook, and graduates the meta entry. The §8 -surface at `/rfc/` then has something real to render. +`main`, registers the webhook, and graduates the meta entry as a +bootstrap-only direct write. The §8 surface at `/rfc/` then +has something real to render. ## Troubleshooting diff --git a/SPEC.md b/SPEC.md index 2917dde..9810577 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2405,86 +2405,96 @@ surface. With Topic 13 folded in, the structural surface is complete. What follows is no longer "topics that block specifying v1" but "topics to address during or shortly after the v1 build." -### 19.1 Next slice: graduation per §13 +### 19.1 Next slice: notifications per §15 -Slice 4 of the build has landed. Super-draft body editing per §9.5 -runs end-to-end against the local Gitea — the §9.4 super-draft view -replaces the Slice 2 placeholder and renders through the same -`RFCView.jsx` surface as an active RFC, dispatched on `entry.state`. -The §9.5 `Start Contributing` gesture cuts a meta-repo edit branch -via `POST /api/rfcs//start-edit-branch`, re-anchors pending -main-scoped `changes` rows, and lands the contributor in contribute -mode on the new branch. From there everything in §8 — chat, AI -participation, accept/decline/edit, manual-edit flushes, range and -paragraph sub-threads, flags, DiffView, stale-change handling — -reaches the super-draft surface through the same routes Slice 2 -shipped, with the dispatch sitting in `api_branches.py`'s helpers: -when `cached_rfcs.state = 'super-draft'`, the bot writes to the -meta repo and the file is `rfcs/.md` (the body wrapped in -frontmatter); when `state = 'active'`, it writes to the per-RFC -repo and the file is `RFC.md`. The body extracted from the entry's -frontmatter envelope is what the editor and the diff see; the -serializer re-wraps on every commit. The §10 PR flow against -meta-repo edit branches falls out structurally unchanged, with -`pr_kind='meta_body_edit'` distinguishing the cache row — the -§10.3 review page, the §10.4 review threads, the §10.5 merge, the -§10.8 withdraw, and the §10.9 conflict-replay path all dispatch the -same way. §9.7's visibility and contribute grants on edit branches -reuse the Slice 2 `branch_visibility` / `branch_contribute_grants` -machinery, keyed on the meta repo. The §9.5 metadata pane lands as -`POST /api/rfcs//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`. +Slice 5 of the build has landed. The §13 graduation flow runs +end-to-end against the local Gitea — the Graduate dialog renders +the three editable fields (integer ID, repo name, initial owners) +with the debounced `GET /api/rfcs//graduate/check` lighting +up per-field validity inline, the precondition popover surfaces +open body-edit PRs via `GET /api/rfcs//blocking-prs` (the +§9.8 gate enforced before the sequence starts), and confirming the +dialog kicks off the §13.3 five-step sequence streamed via +`GET /api/rfcs//graduate/progress`. The orchestrator in +`api_graduation.py` runs the sequence as an asyncio task fed by an +in-memory queue; each step's bot primitive +(`create_rfc_repo_for_graduation`, `seed_graduated_rfc`, +`open_graduation_pr`, `merge_graduation_pr`) lands its own row in +`actions`, bracketed by `graduate_start` and `graduate_complete` +for the linkable sequence. Rollback is per-step and runs in +reverse: each forward step has a paired undo registered in +`_UNDO_BY_STEP` — the create-repo undo deletes the repo (which +also reclaims the seed commits, so seed-files' undo folds into +it), the open-pr undo closes 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 two §17 routes Slice 4 added — `start-edit-branch` and -`metadata` — live in `backend/app/api_branches.py`. The bot grew -`open_metadata_pr`. The §4 cache grew `refresh_meta_branches` -which mirrors `edit--<6hex>` branches into `cached_branches` -and synthesizes a per-slug `main` row so the §10.1 has-commits- -ahead check works uniformly. The §5 schema needed no migration — -the super-draft scoping note already settled that the existing -tables carry both cases. On the frontend, `RFCView.jsx`'s -super-draft placeholder is replaced by the full editor surface; -the `BranchDropdown` renders `canonical body` as the first -position per §9.4; a `MetadataPaneModal` opens from the breadcrumb -actions for viewers holding super-draft edit authority. +§13.4's chat migration landed as a database semantic no-op — +the whole-doc main thread on the super-draft +(`rfc_slug=`, `branch_name='main'`) is the same row before +and after graduation; only the interpretation changes (canonical- +body view becomes per-RFC repo's main). The slug is the canonical +key per §2.3, so no data movement is needed. Edit-branch chats +stay attached to their original `branch_name` 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, with the read path +dispatching against the meta repo via a new `_is_meta_target(rfc, +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` -— ten integration tests against the FakeGitea covering main-view -read, start-edit-branch, body extraction on read, accept and manual -flush both preserving the frontmatter envelope, the body-edit PR's -`pr_kind='meta_body_edit'` cache shape, the full cut-accept-open- -merge loop with the §9.5 admin-only unclaimed-merge gate, the -metadata pane PR cycle, the canonical-body branch (`main` for -super-drafts) refusing contribute writes, and the metadata pane -permission gate refusing plain contributors. The full Slices 1–4 -test suite is 35/35 green. +The §13.1 claim flow landed alongside graduation since it's the +prerequisite for non-admin graduation. The bot grew `open_claim_pr`; +`api_prs._require_pr` 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). -**Slice 5 is graduation per §13.** The five-step transactional -sequence flips a super-draft to active: validate the dialog's -`id`/`repo`/`owners` inputs against the catalog and Gitea, create -the per-RFC repo via `bot.ensure_rfc_repo_seed` (which Slice 2 -added as a forward-looking seam), copy the body from the entry's -frontmatter envelope into the new repo's `RFC.md` on main, strip -the body from the meta-repo entry and fill the `id` / `repo` / -`graduated_at` / `graduated_by` frontmatter fields, and migrate -the chat per §13.4 — the whole-doc main thread and the canonical- -body view's range/paragraph sub-threads re-anchor onto the new -RFC's main thread; edit-branch chats stay attached to their -original `branch_name` on the meta repo per §9.8, surfaced by the -pre-graduation history affordance on the new RFC view. The §9.8 -precondition gate — open body-edit PRs block graduation — is -enforced before the bot starts the sequence, so the §13.3 rollback -complexity does not grow. The Graduate dialog opens a stream -handle for the §17 SSE progress endpoint and renders the step -stack from `pending → running → done/failed` transitions, with a -trailing `rollback` step's events if any earlier step fails. +The five §17 routes Slice 5 added — `claim`, `blocking-prs`, +`graduate/check`, `graduate`, and `graduate/progress` — live in +`backend/app/api_graduation.py`. The §5 schema needed no migration. +On the frontend, `RFCView.jsx`'s breadcrumb actions grew +`Graduate to RFC repo` and `Claim ownership` buttons; +`GraduateDialog.jsx` owns the three-field surface, the precondition +popover, and the live step stack fed by an `EventSource` on the +progress SSE; the `BranchDropdown` gains a `Pre-graduation history` +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` +— ten integration tests against the FakeGitea (extended with +`DELETE /repos/{owner}/{repo}` for the rollback inverse) covering +the dialog validator's per-field checks, the no-owners refusal, +the §9.8 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), concurrent-graduation refusal, +§13.4's chat-row-survives contract, the §9.8 pre-graduation +history surface, and the §13.1 claim PR cycle. The full Slices 1–5 +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`, -`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 apply: implement the slice, correct the spec only where running 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` would shorten the join-against-`cached_rfcs`-state for very 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--<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. Topic 13 (notifications) is settled and folded into §5 (the diff --git a/backend/app/api.py b/backend/app/api.py index ab6b1cb..db1f7e6 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -17,7 +17,7 @@ from typing import Any from fastapi import APIRouter, HTTPException, Request 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 .config import Config from .gitea import Gitea, GiteaError @@ -53,6 +53,8 @@ def make_router( router.include_router(api_branches.make_router(config, gitea, bot, providers)) # Slice 3: the §10 PR-flow endpoints. 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 diff --git a/backend/app/api_branches.py b/backend/app/api_branches.py index d6de02b..ba848d4 100644 --- a/backend/app/api_branches.py +++ b/backend/app/api_branches.py @@ -204,6 +204,46 @@ def make_router( # For super-drafts the cached body is entry.body already (see # 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--` 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 { "slug": slug, "title": rfc["title"], @@ -215,6 +255,7 @@ def make_router( "body_sha": rfc["body_sha"], "branches": branches, "open_prs": prs, + "pre_graduation_history": pre_grad, } # ------------------------------------------------------------------- @@ -232,8 +273,8 @@ def make_router( if not _can_read_branch(slug, branch, viewer): raise HTTPException(403, "Branch is private") - owner, repo = _repo_for(rfc) - path = _file_path_for(rfc) + owner, repo = _repo_for(rfc, branch) + path = _file_path_for(rfc, branch) result = await gitea.read_file(owner, repo, path, ref=branch) if result is None: br = await gitea.get_branch(owner, repo, branch) @@ -242,7 +283,7 @@ def make_router( body, body_sha = "", "" else: 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. 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 # the file is rfcs/.md with frontmatter; for active it's RFC.md. - owner, repo = _repo_for(rfc) - path = _file_path_for(rfc) + owner, repo = _repo_for(rfc, branch) + path = _file_path_for(rfc, branch) fetched = await gitea.read_file(owner, repo, path, ref=branch) if fetched is None: raise HTTPException(409, f"Branch {path} not found") prior_content, prior_sha = fetched - current_body = _extract_body(rfc, prior_content) + current_body = _extract_body(rfc, prior_content, branch) original = row["original"] occurrences = current_body.count(original) @@ -508,7 +549,7 @@ def make_router( else: 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: sha = await bot.commit_accepted_change( @@ -590,10 +631,10 @@ def make_router( if not providers: raise HTTPException(503, "No AI providers configured") - owner, repo = _repo_for(rfc) - path = _file_path_for(rfc) + owner, repo = _repo_for(rfc, branch) + path = _file_path_for(rfc, 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())) system = chat_layer.build_system_prompt(title=rfc["title"], body=body_text) @@ -638,18 +679,18 @@ def make_router( viewer = auth.require_contributor(request) rfc = _require_rfc_with_repo(slug) _require_can_contribute(slug, branch, viewer) - owner, repo = _repo_for(rfc) - path = _file_path_for(rfc) + owner, repo = _repo_for(rfc, branch) + path = _file_path_for(rfc, branch) fetched = await gitea.read_file(owner, repo, path, ref=branch) if fetched is None: raise HTTPException(409, f"Branch {path} not found") 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: 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 # 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 # what's in Gitea right now, not the cache. For super-draft, # extract just the body part from the entry envelope. - owner, repo = _repo_for(rfc) - path = _file_path_for(rfc) + owner, repo = _repo_for(rfc, branch) + path = _file_path_for(rfc, 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 if body.quote: @@ -981,22 +1022,42 @@ def make_router( def _is_super_draft(rfc) -> bool: 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--...` 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): + 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 owner, repo = rfc["repo"].split("/", 1) return owner, repo - def _file_path_for(rfc) -> str: - if _is_super_draft(rfc): + def _file_path_for(rfc, branch: str = "main") -> str: + if _is_meta_target(rfc, branch): return f"rfcs/{rfc['slug']}.md" return RFC_FILE_PATH - def _extract_body(rfc, file_contents: str) -> str: - """For super-draft entries the file on disk is the full - frontmatter+body envelope; the editable body is entry.body. For - active RFCs the file is just RFC.md and the whole thing is body.""" - if not _is_super_draft(rfc): + def _extract_body(rfc, file_contents: str, branch: str = "main") -> str: + """For super-draft entries (and active-RFC pre-graduation reads + per §9.8) the file on disk is the full frontmatter+body envelope; + the editable body is entry.body. For active RFCs reading their + 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 try: entry = entry_mod.parse(file_contents) @@ -1004,10 +1065,10 @@ def make_router( return file_contents 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 envelope, preserving the prior frontmatter exactly.""" - if not _is_super_draft(rfc): + if not _is_meta_target(rfc, branch): return new_body entry = entry_mod.parse(prior_contents) # Ensure exactly one trailing newline so the serializer's @@ -1125,6 +1186,13 @@ def make_router( return False if branch == "main": 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"): return True owners = json.loads(rfc["owners_json"] or "[]") @@ -1150,7 +1218,8 @@ def make_router( def _require_can_contribute(slug: str, branch: str, viewer) -> None: 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() if not _can_contribute(rfc, slug, branch, viewer): raise HTTPException(403, "You do not have contribute access to this branch") diff --git a/backend/app/api_graduation.py b/backend/app/api_graduation.py new file mode 100644 index 0000000..3ba43c3 --- /dev/null +++ b/backend/app/api_graduation.py @@ -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//blocking-prs (§13.2 precondition popover) + - GET /api/rfcs//graduate/check (§13.2 debounced validator) + - POST /api/rfcs//graduate (§13.3 kickoff) + - GET /api/rfcs//graduate/progress (§13.3 SSE step stream) + +Plus the §13.1 claim PR endpoint (POST /api/rfcs//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- 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//blocking-prs + # Lists open meta-repo PRs against rfcs/.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//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//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/.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//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//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/` 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" diff --git a/backend/app/api_prs.py b/backend/app/api_prs.py index 1d08d31..e7f7dab 100644 --- a/backend/app/api_prs.py +++ b/backend/app/api_prs.py @@ -650,11 +650,15 @@ def make_router( # meta repo as pr_kind='meta_body_edit'; active RFC PRs live on # the per-RFC repo as 'rfc_branch'. The API surface and the §10 # treatment are identical. + # 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( """ SELECT * FROM cached_prs 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), ).fetchone() diff --git a/backend/app/bot.py b/backend/app/bot.py index 37659b1..c4f46f2 100644 --- a/backend/app/bot.py +++ b/backend/app/bot.py @@ -627,6 +627,314 @@ class Bot: ) 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--<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--` + 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/.md`. Branch shape is + `claim/` — 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) ----- async def ensure_rfc_repo_seed( diff --git a/backend/tests/test_graduation_vertical.py b/backend/tests/test_graduation_vertical.py new file mode 100644 index 0000000..bd56020 --- /dev/null +++ b/backend/tests/test_graduation_vertical.py @@ -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//graduate/check returns per-field validity for + the dialog. + * GET /api/rfcs//blocking-prs returns the §9.8 precondition list. + * POST /api/rfcs//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/.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" diff --git a/backend/tests/test_propose_vertical.py b/backend/tests/test_propose_vertical.py index f5e769f..d727d6d 100644 --- a/backend/tests/test_propose_vertical.py +++ b/backend/tests/test_propose_vertical.py @@ -128,6 +128,18 @@ class FakeGitea: return httpx.Response(200, json={"name": repo, "full_name": f"{owner}/{repo}"}) 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 m = re.fullmatch(r"/orgs/([^/]+)/repos", path) if method == "POST" and m: diff --git a/docs/DEV.md b/docs/DEV.md index 49b0eb0..40fac7c 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -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 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/.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//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=`, `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 1–5 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 Super-draft body editing per §9.5 + §9.6 + §9.7. The §17 routing-collapse @@ -316,65 +416,86 @@ spec: ## Next slice -**Slice 5: graduation per §13.** +**Slice 6: notifications per §15.** -A super-draft becomes an active RFC through the §13 graduation -sequence — the dialog (§13.2), the five-step transactional sequence -with rollback (§13.3), the chat-follows-the-work migration (§13.4), -the pre-graduation history affordance for the new RFC view (§9.8), -and the precondition gate that refuses to graduate while body-edit -PRs are open (§9.8 / §13.3). +Every other vertical now produces signals: propose, claim, merge, +graduate, body edits, manual flushes, PR open/withdraw/merge, +review threads, conflict-replay, super-draft chat. Slice 6 builds +the inbox, the fan-out, the digest, and the email loop that turn +those signals into a contributor's surface. 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 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 -flow, and the active-RFC PR flow all converge on the same dispatch. -Graduation is the act that flips an entry's state from `super-draft` -to `active`, creates the per-RFC repo via `bot.ensure_rfc_repo_seed` -(which Slice 2 added as a forward-looking seam), copies the body -from the frontmatter envelope into the new repo's `RFC.md`, strips -the body field from the meta-repo entry, mints the integer ID and -fills the `repo`/`graduated_at`/`graduated_by` fields, and migrates -the whole-doc main thread's chat to the new RFC's `branch_name=null` -thread per §13.4. +Slices 1–5 left this clean: every user gesture goes through the +bot wrapper and lands an `actions` row with the underlying actor. +The producer-side hook is "after a write succeeds, evaluate watches +and fan-out notification rows." The consumer-side hook is the +header badge, the inbox panel, the toast surface, and the per-row +read-state machinery. -What Slice 5 owns specifically: +What Slice 6 owns specifically: -- The §13.2 Graduate dialog — three fields (integer ID, repo name, - initial owners), the inline-validation endpoint - `GET /api/rfcs/{slug}/graduate/check`, the blocking-PRs popover - via `GET /api/rfcs/{slug}/blocking-prs`, and the merge-actor set - per §13's authority rules. -- The §13.3 transactional sequence — five steps emitted as an SSE - stream via `GET /api/rfcs/{slug}/graduate/progress`, with each - step's `pending → running → done/failed` transitions surfacing in - the dialog, and a trailing `rollback` step if any earlier step - fails. The bot grows `graduate` plus the rollback primitives the - sequence needs. -- The §13.4 chat migration — the whole-doc main thread on the - super-draft (`rfc_slug=`, `branch_name='main'`) re-anchors - onto the new RFC's main thread; range and paragraph sub-threads - on the canonical-body view migrate too per §9.8's clarification. - Edit-branch chats stay attached to their original `branch_name` - on the meta repo per §9.8 — no data movement, surfaced by the - pre-graduation history affordance. -- The §9.8 pre-graduation history affordance on the new RFC view — - the slug remains the canonical key per §2.3, so the query is a - straightforward lookup of `threads` and `changes` rows where - `rfc_slug = ` and `branch_name` begins with the meta-repo - edit prefix. +- **The producer fan-out.** Every `actions` row whose event maps to a + §15 signal produces zero-or-more `notifications` rows by joining + against `watches` and applying the §15.1 priority rules. The + fan-out lives as a small module that the bot wrapper invokes + inline after each write — same chokepoint shape Slice 1's + `_log` uses. +- **The §15.2 inbox.** `GET /api/notifications` with the + `unread` / `rfc_slug` / `category` / `bundled` filter chips, + `POST /api/notifications//read` for per-row marking, + `POST /api/notifications/read` for the bulk filter mark, and the + SSE `GET /api/notifications/stream` that backs the live badge. +- **The §15.3 surface.** The header badge counter (live via the SSE), + the toast on personal-direct events while the user is active, and + the ambient signal — a colored dot per row on the §7 catalog + pointing at watched RFCs with unseen activity. +- **The §15.4 email loop.** Per-category opt-in/out preferences on + the users table (already in the schema), the `/api/users/me/notification-preferences` + endpoints, the email-send adapter that routes a notification's + category through the user's category toggle, and the + `POST /api/webhooks/email-bounce` receiver that sets the global + opt-out. Plus the `GET /api/email/unsubscribe` signed-URL + one-click flow. +- **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//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 §12 30/90 branch-hygiene timers (still Slice 8). +- The §16 deferred items. -The carryovers Slice 5 inherits — the `ensure_rfc_repo_seed` -primitive Slice 2 added, the body-edit-PR precondition gate -(checked against the same `cached_prs` shape Slice 4 wired), and -the existing `actions` audit-log shape for the rollback record. +The carryovers Slice 6 inherits — the existing `actions` audit log +(every signal traces back to a row there per §15.9), the SSE +machinery from Slices 2 and 5 (chat-stream and graduate-progress +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`, -`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 apply: implement the slice, correct the spec only where running code reveals it was wrong at a structural level, accumulate new diff --git a/frontend/src/App.css b/frontend/src/App.css index a2fe1bf..8fd0d4f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1021,3 +1021,121 @@ } .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; } + +/* ── 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; +} diff --git a/frontend/src/api.js b/frontend/src/api.js index 531e8e4..511d1fb 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -221,6 +221,54 @@ export async function editMetadata(slug, { title, tags, prDescription }) { 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 ───────────────────────────────────────────── export async function draftPRText(slug, branch) { diff --git a/frontend/src/components/GraduateDialog.jsx b/frontend/src/components/GraduateDialog.jsx new file mode 100644 index 0000000..10a5003 --- /dev/null +++ b/frontend/src/components/GraduateDialog.jsx @@ -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 ( +
{ if (e.target === e.currentTarget && phase === 'idle') onClose?.() }}> +
+
+

Graduate `{slug}` to active

+ {phase === 'idle' && } +
+ + {!showStack && ( +
+

+ §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. +

+ +
+ + setRfcId(e.target.value.trim())} + placeholder="RFC-NNNN" + disabled={phase !== 'idle'} + /> +

Pre-filled as the next free integer; editable to reserve gaps.

+ {idError &&

{idError}

} +
+ +
+ + setRepoName(e.target.value.trim())} + placeholder="rfc-NNNN-slug" + disabled={phase !== 'idle'} + /> +

Becomes `<org>/{repoName || 'rfc-…'}` on Gitea.

+ {repoError &&

{repoError}

} +
+ +
+ +
+ {owners.length === 0 && No owners yet — add at least one.} + {owners.map(o => ( + + {o} + + + ))} +
+
+ setNewOwner(e.target.value)} + placeholder="Gitea login" + disabled={phase !== 'idle'} + onKeyDown={(e) => { if (e.key === 'Enter') handleAddOwner() }} + /> + +
+ {ownersError &&

{ownersError}

} +
+ + {blockingPRs.length > 0 && ( +
+ + {precondPopover && ( +
+ {blockingPRs.map(pr => ( +
+
+ PR #{pr.pr_number} — {pr.title || '(no title)'} +
+ {pr.author ? `by @${pr.author}` : ''} + {pr.last_activity_at ? ` · ${pr.last_activity_at.slice(0, 10)}` : ''} +
+
+
+ Open ↗ +
+
+ ))} +

+ §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. +

+
+ )} +
+ )} +
+ )} + + {showStack && ( +
+ + {phase === 'rolled_back' && ( +
+

What happened

+

+ The graduation could not complete. The app rolled back the + steps that had already run; nothing was left half-applied on + Gitea. Error: {streamState?.error || 'unknown'}. +

+

+ 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. +

+
+ )} + {phase === 'done' && ( +
+

Graduation complete

+

+ `{slug}` is now active as {streamState?.rfc_id}{' '} + at {streamState?.repo_full}. The catalog and the + RFC view reflect the new state. +

+
+ )} +
+ )} + +
+ {phase === 'idle' && ( + <> + + + + )} + {phase === 'running' && ( + Running graduation sequence… + )} + {(phase === 'rolled_back' || phase === 'error') && ( + + )} + {phase === 'done' && ( + + )} +
+ {submitError && phase !== 'rolled_back' && ( +
Error: {submitError}
+ )} +
+
+ ) +} + + +function StepStack({ steps, rollbackSteps }) { + return ( +
+ {steps.map(s => )} + {rollbackSteps.length > 0 && ( +
Rollback
+ )} + {rollbackSteps.map(s => )} +
+ ) +} + + +function StepRow({ step }) { + return ( +
+ +
+
{step.label}
+ {step.detail &&
{step.detail}
} +
+
{labelFor(step.status)}
+
+ ) +} + + +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 +} diff --git a/frontend/src/components/RFCView.jsx b/frontend/src/components/RFCView.jsx index 64632bd..7db9a1a 100644 --- a/frontend/src/components/RFCView.jsx +++ b/frontend/src/components/RFCView.jsx @@ -40,6 +40,8 @@ import ChatPanel from './ChatPanel.jsx' import ChangePanel from './ChangePanel.jsx' import DiffView from './DiffView.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_DEBOUNCE_MS = 800 @@ -108,6 +110,8 @@ export default function RFCView({ viewer }) { // metadata pane, and the start-contributing dispatch target. const isSuperDraft = entry?.state === 'super-draft' const [showMetadataPane, setShowMetadataPane] = useState(false) + const [showGraduateDialog, setShowGraduateDialog] = useState(false) + const [claimError, setClaimError] = useState(null) // Load main view + branch view whenever slug/branch changes. useEffect(() => { @@ -538,8 +542,42 @@ export default function RFCView({ viewer }) { Metadata )} + {isSuperDraft && viewer && entry?.owners && !entry.owners.includes(viewer.gitea_login) && ( + + )} + {isSuperDraft && viewer && (viewer.role === 'owner' || viewer.role === 'admin' || (entry?.owners || []).includes(viewer.gitea_login)) && ( + + )} + {claimError && ( +
Claim failed: {claimError}
+ )} {/* Two columns: editor + chat */}
@@ -693,6 +731,20 @@ export default function RFCView({ viewer }) { /> )} + {showGraduateDialog && ( + 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 && ( @@ -804,6 +857,28 @@ function BranchDropdown({ current, mainView, isSuperDraft, onPick }) { )} ))} + {preGrad.length > 0 && ( + <> +
+ Pre-graduation history ({preGrad.length}) +
+ {preGrad.map(b => ( + + ))} + + )}
)}