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