Compare commits

..

4 Commits

Author SHA1 Message Date
Ben Stull 1d716d0cb8 v0.31.2: landing welcome panel spacing
Visual-only patch. The "/" welcome read-view was jammed against the
catalog divider with no top offset and loose paragraph rhythm: the
.main-pane §8 override (padding:0; display:flex) shadows the padded
read-view rule, so the pane gives no padding, and .welcome (max-width
only) never compensated. The welcome surface now owns its breathing
room — 56px top / 48px side gutters, a 680px measure, a text-3xl hero,
and even --space-8 paragraph spacing at --leading-relaxed. Covers both
the signed-out and signed-in welcome.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:54:29 -07:00
Ben Stull f96883506e v0.31.1: admin Users tab + header UX polish
Visual-only patch atop v0.31.0. CSS plus markup/structure in Admin.jsx;
no API, schema, config, overlay, or secret change — a plain frontend
rebuild applies it.

Two latent CSS defects fixed:
- .invite-badge had no rule, so "(pending invite)" rendered as bare
  parenthetical text; it's now a quiet amber pill.
- .btn-link-quiet never reset native <button> chrome, so the admin
  Revoke/Grant/Remove buttons, the modal close ×, and Login/BetaPending
  link-buttons kept the browser's grey button box. The reset the
  .otc-login scope already carried is folded into the base rule.

Users tab: table headers no longer wrap (WRITE-MUTED), timestamps
render as a date-over-time stack, the duplicated subline email is
de-duped, the Create-user-+-invite action moves flush-right beside the
title, and intro DB-column refs read as quiet chips.

Header: the Inbox (§15.2) trigger was styled for a light surface
(gray-200 border, gray-50 hover) and rendered as a pale box that went
white-on-white on hover; restyled to the nav-link vocabulary
(borderless, gray-300 icon → white on a faint translucent hover).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:32:47 -07:00
Ben Stull 0c972c8af5 v0.31.0: meta-only repository topology (ROADMAP #36)
Retire the per-RFC-repo model. RFCs now live in their meta-repo entry
(rfcs/<slug>.md) for their whole life; graduation is an in-place
super-draft → active state flip that keeps the body in the entry — no
repo creation, no body-strip, no five-step transaction, no rollback.

SPEC: §1 topology rewritten (one meta/content repository, no per-RFC
repos) with a deployer-facing "single content repository" framing; §2
(repo: always-null), §3 (active is in-place), §4, §9.8 (handoff
frictions dissolve), and §13 fully rewritten (two-field dialog, "The
flip", §13.6 RFC-0001 fold-back record).

Code: graduation collapses to open+merge one frontmatter PR; branch/PR/
chat dispatch re-keyed on meta-residency (repo IS NULL) so active RFCs
edit on the meta repo exactly as super-drafts do; the two "RFC has no
repo" 409 guards removed; promote-to-branch slug-embeds an active RFC's
auto-branch (edit-<slug>-<hex>) for shared-repo cache attribution;
refresh_meta_branches + hygiene branch-resolution include meta-resident
actives; the §9.8 read-only guard + pre_graduation_history scoped to
legacy per-repo only; dead bot primitives + GraduateDialog repo field +
blocking-PR popover removed. The repo: frontmatter field and the
/blocking-prs endpoint are retained (schema stability / informational).

Tests: graduation suite rewritten to the flip model; e2e + hygiene
updated. Full backend suite 375 passed; frontend builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 07:12:06 -07:00
Ben Stull d581010063 v0.30.2: header nav label "Philosophy" -> "About"
Revert the §14.3 persistent chrome link's display text from "Philosophy"
back to "About" (relabeled in v0.21.0). Display text only — route
/philosophy, the header-about class, and the page are unchanged. Patch
per SPEC §20.2: cosmetic, no deployment action beyond a frontend rebuild.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 06:56:14 -07:00
18 changed files with 918 additions and 1111 deletions
+129
View File
@@ -23,6 +23,135 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond release's steps in order — no A-to-B path is pre-computed beyond
that. that.
## 0.31.2 — 2026-05-29
**Patch — landing (`/`) welcome panel spacing. Visual only: CSS in
`App.css` (`.welcome`). A plain frontend rebuild applies it.**
The welcome read-view was jammed against the catalog divider with no
top offset and loose, uneven paragraph spacing. Root cause: `.main-pane`
carries a bare `.main-pane { padding: 0; display: flex }` override (the
§8 three-column RFC shell) that shadows the earlier padded read-view
rule, so the pane provides no padding — and `.welcome` (just
`max-width`) never compensated. The welcome surface now owns its own
breathing room: 56px top / 48px side gutters, a capped 680px measure,
a stronger `text-3xl` "Welcome." hero, and even `--space-8` paragraph
rhythm at `--leading-relaxed`. Applies to both the signed-out and
signed-in welcome (same `.welcome` class).
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
## 0.31.1 — 2026-05-29
**Patch — admin Users tab + header UX polish. Visual only: CSS plus
markup/structure in `Admin.jsx` (no API, schema, config, overlay, or
secret change). A plain frontend rebuild applies it.**
Two latent CSS defects fixed:
- **`.invite-badge` had no rule.** The "(pending invite)" marker on
admin-created-but-unclaimed user rows rendered as bare parenthetical
text. It's now a quiet amber pill, consistent with the other status
badges.
- **`.btn-link-quiet` never reset native button chrome.** Used as a
bare link-style `<button>` (admin Revoke / Grant / Remove, the modal
close ×, and link-buttons in Login / BetaPending), it kept the
browser's default grey button box. The reset that the `.otc-login`
scope already carried is folded into the base rule, so every
`btn-link-quiet` is now a true quiet link.
Users-tab cleanups, all token-based:
- Table column headers no longer wrap (`WRITE-MUTED` was breaking onto
two lines); timestamps render as an intentional date-over-time stack
instead of a ragged mid-value wrap; the duplicated email in a row's
subline (the handle already *is* the email when there's no Gitea
login) is de-duplicated; the "Create user + invite" action moves
flush-right beside the title; inline DB-column references in the
intro copy read as quiet chips.
Header:
- **Inbox (§15.2) trigger restyled for the dark header.** It carried a
light-surface treatment — a `gray-200` border and a `gray-50` hover —
that rendered as a pale box in the nav and went white-background /
white-icon (invisible) on hover. It now speaks the nav-link
vocabulary (`.header-about` et al.): borderless, `gray-300` icon
brightening to white on a faint translucent hover, unread badge
unchanged.
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
## 0.31.0 — 2026-05-29
**Minor — meta-only repository topology (SPEC §1, ROADMAP #36). RFCs no
longer graduate into their own Gitea repositories; every RFC lives in
its meta-repo entry (`rfcs/<slug>.md`) for its whole life, and
graduation is an in-place `super-draft → active` state flip that keeps
the body in the entry. The per-RFC-repo machinery — repo creation,
`RFC.md`/`README.md`/`.rfc/metadata.yaml` seeding, body-strip, the
five-step transactional sequence and its rollback — is removed. This is
the framework change behind a much simpler deployer story: one content
repository, every RFC under `rfcs/`.**
What changed, concretely:
- **Graduation is a single flip.** `POST /api/rfcs/<slug>/graduate` now
opens one meta-repo PR that re-serializes the entry with
`state: active`, the integer `id`, and `graduated_at`/`graduated_by`
**body unchanged, `repo` left null** — then auto-merges it. No repo
is created, nothing is seeded, and there is no rollback (an open- or
merge-failure leaves the entry a super-draft; a failed merge's PR and
branch are cleaned up). The Graduate dialog drops the **Repo name**
field (two fields now: integer ID + owners) and the progress stack is
two steps (`open_pr`, `merge_pr`).
- **Active RFCs edit on the meta repo.** Branch/PR/chat dispatch keys on
meta-residency (`repo IS NULL`) rather than `state == 'super-draft'`,
so an active RFC's branches, body-edit PRs, threads, flags, and
`changes` all live on the meta repo exactly as a super-draft's do.
`promote-to-branch` names an active RFC's auto-branch
`edit-<slug>-<hex>` so the shared-repo cache can attribute it.
- **Open body-edit PRs no longer block graduation** (§9.8) — the body is
kept, so they coexist with the flip.
- **`refresh_rfc_repo` and the per-RFC read path are dead** for
meta-only entries (the reconciler only sweeps entries with a non-null
`repo`, of which there are none after the fold-back below).
**Upgrade steps:**
- Deployments **MUST** fold any already-graduated per-RFC-repo RFC back
into its meta entry before/with this deploy: restore the per-RFC
`RFC.md` body into `rfcs/<slug>.md`, set `repo: null` (keep
`state: active` and the integer `id`), and archive the per-RFC repo.
An entry left with a non-null `repo` keeps using the retained legacy
read path, but **no new** per-RFC repos are ever created. For OHM,
RFC-0001 `human` was folded back in driver session 0041.0 (§13.6).
- No schema migration, no new config, no new secret, no overlay change.
A plain code deploy applies it; the running reconciler reconciles the
catalog on its next sweep (≤5 min).
- The `repo:` frontmatter field and the `/api/rfcs/<slug>/blocking-prs`
endpoint are **retained** (the field for schema stability + legacy
entries; the endpoint as an informational, non-blocking surface), so
no client contract is removed — `graduate/check` simply no longer
returns a `repo` field and never reports `blocking_prs` as a gate.
## 0.30.2 — 2026-05-29
**Patch — header nav label: the persistent chrome link reverts from
"Philosophy" back to "About." Display text only — the route
(`/philosophy`), the `header-about` class, and the page itself are
unchanged. A plain frontend rebuild applies it; no schema, API, config,
overlay, or secret change.**
The §14.3 persistent link was relabeled "About" → "Philosophy" in
v0.21.0. This restores "About" as the neutral, framework-native label
(the CSS class `header-about` and the surrounding comment already call
it "the About link"). A deployment that wants a more pointed framing
can title its own About page in the `PHILOSOPHY.md` content the
`PHILOSOPHY_PATH` override serves.
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
## 0.30.1 — 2026-05-29 ## 0.30.1 — 2026-05-29
**Patch — bug fix: a merged idea-PR whose branch was deleted no longer **Patch — bug fix: a merged idea-PR whose branch was deleted no longer
+208 -179
View File
@@ -30,13 +30,44 @@ providers (Anthropic, Google, OpenAI / GitHub Copilot).
## 1. Repository topology ## 1. Repository topology
Each RFC is its own Gitea repository. There is in addition exactly one **meta There is exactly one **meta repository** that holds every RFC in the
repository** that serves as the authoritative directory of all RFCs in the system as a single markdown entry under `rfcs/` — drafts, active work,
system — drafts, active work, and retired entries alike. and retired entries alike — regardless of state. An RFC is a single
canonical document, and its document *is* its meta entry: the body
lives in the entry file at every state, from idea through active. There
are **no per-RFC repositories**.
All Git operations across all repositories are performed by a single **bot For a deployment, this single repository is its **content repository**:
the one place every RFC document lives (under `rfcs/`), alongside the
framework's `PHILOSOPHY.md`, `README.md`, and `CONTRIBUTING.md` (§2). A
deployment names it concretely — `<deployment>-content` reads cleanly
— and the `META_REPO` setting carries the name. The term *meta
repository* persists for the config surface and
historical continuity, but under the meta-only topology this repo is,
functionally, the deployment's content repo: "one repo, your RFCs are
in `rfcs/`" is the whole mental model a new deployer needs.
> **Topology change (v0.31.0, meta-only — supersedes the original
> per-RFC-repo model).** This spec originally said "each RFC is its own
> Gitea repository," and graduation created a dedicated `rfc-NNNN-<slug>`
> repo and moved the body into its `RFC.md`. That model is **retired**.
> The per-RFC-repo machinery never paid for itself under this design:
> authorization is decided in app data before a single bot acts (below),
> not by per-repo Gitea permissions; raw `git clone`+`push` was never a
> supported contribution path; and the super-draft phase already ran
> meta-only. So an RFC now lives in its meta entry for its whole life,
> and graduation is an in-place state flip rather than a repo-creation
> transaction (see §13). Where later sections still say "the RFC's repo"
> or "RFC.md on the new repo," read it as "the RFC's meta entry body" —
> the editing, branch, PR, and chat machinery is unchanged; only the
> location collapses onto the meta repo. The one RFC graduated under the
> old model, **RFC-0001 `human`**, was folded back into its meta entry
> and its `wiggleverse/rfc-0001-human` repo archived (see §13.6). The
> decision record is OHM ROADMAP #36.
All Git operations on the meta repository are performed by a single **bot
service account** in Gitea. Real human users do not have meaningful Gitea service account** in Gitea. Real human users do not have meaningful Gitea
permissions on the repos themselves; their accounts exist for OAuth identity permissions on the repo itself; their accounts exist for OAuth identity
only. The bot is the author of every commit, the opener of every PR, and the only. The bot is the author of every commit, the opener of every PR, and the
merger of every merge. Authorization decisions are made by the app, in app merger of every merge. Authorization decisions are made by the app, in app
data, *before* the bot acts on the user's behalf. data, *before* the bot acts on the user's behalf.
@@ -67,8 +98,8 @@ The meta repo's `main` branch contains:
arriving at the meta-repo via Git rather than via the app. The **index arriving at the meta-repo via Git rather than via the app. The **index
below the header is regenerated by CI on every merge to main** and lists below the header is regenerated by CI on every merge to main** and lists
active RFCs, super-drafts, and (eventually) retired entries with links active RFCs, super-drafts, and (eventually) retired entries with links
into the corresponding entry files and, when present, the RFC's own into the corresponding entry files. (There is no per-RFC repository to
repository. link to under the meta-only topology, §1.)
- `CONTRIBUTING.md` — explains how to propose, claim, and contribute. - `CONTRIBUTING.md` — explains how to propose, claim, and contribute.
- A workflow file (Gitea Actions) that regenerates the README index. - A workflow file (Gitea Actions) that regenerates the README index.
@@ -84,7 +115,9 @@ slug: human
title: Human title: Human
state: super-draft # super-draft | active | withdrawn state: super-draft # super-draft | active | withdrawn
id: null # null until graduated; then "RFC-0042" id: null # null until graduated; then "RFC-0042"
repo: null # null until graduated; then "wiggleverse/rfc-0042-human" repo: null # always null under the meta-only topology (§1).
# Retained for schema stability + historical
# entries; never populated by graduation (§13).
proposed_by: ben@wiggleverse.org proposed_by: ben@wiggleverse.org
proposed_at: 2026-05-22 proposed_at: 2026-05-22
graduated_at: null graduated_at: null
@@ -103,8 +136,9 @@ tags: [identity, schema]
## Why this RFC is needed ## Why this RFC is needed
(One- or two-paragraph pitch from the proposer. While the entry is a (One- or two-paragraph pitch from the proposer. While the entry is a
super-draft, the body may grow into the actual draft document. On super-draft, the body grows into the actual draft document. The body
graduation, this body migrates to RFC.md in the new repo; see §13.) stays in this entry at every state — graduation is an in-place state
flip and does not move it (§13).)
``` ```
### 2.2 Idea submission as PR ### 2.2 Idea submission as PR
@@ -129,11 +163,14 @@ an idea costs nothing in identifier space.
There are three canonical states stored in entry frontmatter: There are three canonical states stored in entry frontmatter:
- **`super-draft`** — the entry exists in the meta repo's `rfcs/` - **`super-draft`** — the entry exists in the meta repo's `rfcs/`
directory. No dedicated repo yet. Anyone signed in can chat on it; anyone directory. Anyone signed in can chat on it; anyone can claim ownership;
can claim ownership; an owner is required before graduation. an owner is required before graduation.
- **`active`** — the entry has been graduated. A dedicated RFC repo - **`active`** — the entry has been graduated: it carries an integer
exists, `repo:` points to it, and real branches/PRs/conversation happen `id` (`RFC-NNNN`) and `graduated_at`/`graduated_by`. It lives in the
there. same `rfcs/<slug>.md` entry it always did — graduation is an in-place
state flip (§13), not a move. Branches, PRs, and conversation happen on
the meta repo against that entry, exactly as they did while it was a
super-draft. `repo:` stays null (§1).
- **`withdrawn`** — pulled before becoming canonical. Stays in the - **`withdrawn`** — pulled before becoming canonical. Stays in the
directory as historical record, hidden from default views, filterable in. directory as historical record, hidden from default views, filterable in.
@@ -163,20 +200,23 @@ for "who clicked the button" (see §6.5).
### 3.2 State change side-effects ### 3.2 State change side-effects
For now, changing state in the meta repo entry is the *only* required Changing state in the meta repo entry is the *only* required operation
operation for a state transition. Graduation has additional side effects for a state transition — graduation included. Graduation additionally
(creating the new repo, seeding it); those are covered in §13. We assigns the integer `id` and stamps `graduated_at`/`graduated_by` in the
deliberately do not tag commits, lock branches, or post notices on same commit (§13); it has no other side effects under the meta-only
state change for now — the entry frontmatter is the single source of topology (no repo to create, nothing to seed). We deliberately do not
truth and any further automation is a later refinement. tag commits, lock branches, or post notices on state change for now —
the entry frontmatter is the single source of truth and any further
automation is a later refinement.
--- ---
## 4. Storage architecture: Git is truth, app keeps a cache ## 4. Storage architecture: Git is truth, app keeps a cache
Gitea remains the source of truth for everything Git-shaped: meta repo Gitea remains the source of truth for everything Git-shaped: meta repo
content, RFC repo content, branches, PRs, commits. Nothing in this system content, branches, PRs, commits — all of which live on the single meta
overrides Gitea on those concerns. repo under the meta-only topology (§1). Nothing in this system overrides
Gitea on those concerns.
The app maintains a **SQLite database**, colocated with the FastAPI The app maintains a **SQLite database**, colocated with the FastAPI
process, that serves three purposes: process, that serves three purposes:
@@ -187,10 +227,11 @@ process, that serves three purposes:
assignments, per-branch grants, branch visibility settings, chat assignments, per-branch grants, branch visibility settings, chat
history, audit logs. This data is canonical; it is not cached, it history, audit logs. This data is canonical; it is not cached, it
is owned by the app. is owned by the app.
3. **Cached bodies** — the main-branch body of each RFC's `RFC.md` (and 3. **Cached bodies** — the main-branch body of each RFC, read from its
each super-draft's entry body) is cached for left-pane previews and `rfcs/<slug>.md` meta entry (the same source for super-drafts and
read-without-roundtrip. Branch bodies are *not* cached; the editor active RFCs alike under the meta-only topology), is cached for
fetches them live from Gitea when opened. left-pane previews and read-without-roundtrip. Branch bodies are
*not* cached; the editor fetches them live from Gitea when opened.
### 4.1 Cache freshness ### 4.1 Cache freshness
@@ -201,7 +242,7 @@ Two paths keep the cache current, running in parallel:
A webhook handler does a focused re-read of just what changed. A webhook handler does a focused re-read of just what changed.
Typical latency: sub-second. Typical latency: sub-second.
- **A periodic reconciler** runs every five minutes and does a full - **A periodic reconciler** runs every five minutes and does a full
sweep — list meta-repo entries, list each RFC repo's branches and sweep — list meta-repo entries, list the meta repo's branches and
PRs, diff against the cache, fix drift. This is the safety net for PRs, diff against the cache, fix drift. This is the safety net for
missed webhooks and downtime. missed webhooks and downtime.
@@ -1607,42 +1648,43 @@ contributor — identical to an active RFC's main chat per §11.4 plus
### 9.8 Graduation handoff additions ### 9.8 Graduation handoff additions
§13's graduation sequence was written before this section's > **Meta-only update (v0.31.0).** This section originally reconciled
machinery existed. The mechanics §13 needs to absorb fold inline > §13's per-RFC-repo graduation transaction with the §9-era editing
into §13.2 and §13.4 in their respective sections; the substantive > machinery. Under the meta-only topology (§1, §13) the entry never
additions are captured here for cross-reference: > moves and its body is never stripped, so the frictions this section
> existed to manage **dissolve**. The bullets are retained, struck
> through, for the audit trail of what the per-repo model required.
- **Open body-edit PRs block graduation.** §13.3's step 3 removes Under meta-only, graduation is a single frontmatter-flipping commit
to `rfcs/<slug>.md` (§13.3). Nothing else changes: the body stays in
the entry; branches, edit-PRs, threads, flags, and `changes` rows all
remain exactly where they were, keyed by the slug per §2.3, and keep
working against the same meta entry after the flip as before it. There
is no entry move, no body strip, no per-repo seed, and therefore no
"handoff" to coordinate.
- ~~**Open body-edit PRs block graduation.** §13.3's step 3 removes
the meta-repo entry's body field, and an open body-edit PR the meta-repo entry's body field, and an open body-edit PR
post-graduation would attempt to re-introduce a body to a post-graduation would attempt to re-introduce a body to a
frontmatter-only entry. The Graduate dialog disables the confirm frontmatter-only entry.~~ **No longer applies** — the body is kept,
button if any meta-repo PR is open against `rfcs/<slug>.md`. The so an open body-edit PR coexists with graduation. Graduation touches
precondition is enforced before the bot starts §13.3's sequence, only the frontmatter; a body-edit PR that merges after graduation
so §13.3's rollback complexity does not grow. edits the same entry's body just as it would have before. The
- **Bare edit branches survive graduation.** Edit branches without Graduate dialog no longer gates on open body-edit PRs (§13.2).
an open PR are not blocked. They remain on the meta repo subject - ~~**Bare edit branches survive graduation.**~~ Trivially true now —
to §12's hygiene timers. The contributor can re-cut against the no repo boundary is crossed, so every edit branch simply remains a
new RFC repo's main if they still want the work. The branch chat meta-repo branch on the same slug, subject to §12's hygiene timers,
persists per §8.4 as historical record even after auto-close, so with no "re-cut against the new repo" step.
the argument that produced the work is preserved regardless of - ~~**Chat migration includes range and paragraph sub-threads.**~~ No
whether the work itself merges. migration occurs: whole-doc, range, and paragraph threads stay on
- **Chat migration includes range and paragraph sub-threads.** their `(rfc_slug, branch_name)` rows. Their anchors resolve against
§13.4's chat-follows-the-work rule covers the whole-doc main the same entry body, which did not move, so §8.12's stale mechanic is
thread; it extends to range and paragraph sub-threads on the not provoked by graduation.
super-draft's main view, which migrate as part of the same - ~~**Pre-graduation history surfaces from the new RFC view.**~~ There
movement. Anchors re-resolve against `RFC.md` on the new repo; is no "new RFC view" distinct from the entry's own view, so there is
since §13.3's step 2 seeds `RFC.md` from the super-draft body no pre-graduation hop to bridge. Edit-branch threads, flags, and
verbatim, anchors typically locate the same content. Where they `changes` rows surface on the active RFC the same way they did on the
do not, §8.12's stale mechanic engages. super-draft — same slug, same surface.
- **Pre-graduation history surfaces from the new RFC view.**
Meta-repo edit-branch chats, flag threads, and `changes` rows
stay attached to their original `branch_name` on the meta repo;
they do not migrate. A **"Pre-graduation history"** affordance on
the new RFC view surfaces these — the slug remains the canonical
key per §2.3, so the query is a straightforward lookup of
`threads` and `changes` rows where `rfc_slug = <slug>` and
`branch_name` begins with `edit/<slug>/`. UI affordance; no data
movement, no rollback cost.
--- ---
@@ -1953,16 +1995,23 @@ email request to an owner.
--- ---
## 13. The graduation flow (super-draft → active RFC repo) ## 13. The graduation flow (super-draft → active, in place)
Graduation is initiated by an owner or admin clicking "Graduate to RFC Graduation is initiated by an owner or admin clicking "Graduate" on a
repo" on a super-draft's page. The button is disabled with a tooltip super-draft's page. The button is disabled with a tooltip when the
when the super-draft has no owners (see §13.1) or when any meta-repo super-draft has no owners (see §13.1). Open meta-repo body-edit PRs no
body-edit PR is open against `rfcs/<slug>.md` (see §9.8 — open longer block graduation: under the meta-only topology (§1) the body is
body-edit PRs would attempt to re-introduce a body to a frontmatter- kept in the entry, so graduation touches only frontmatter and coexists
only entry after step 3 of §13.3). Bare edit branches without an open with body edits (see §9.8). Bare edit branches are likewise unaffected;
PR do not block graduation; they remain on the meta repo subject to they remain on the meta repo subject to §12's hygiene timers.
§12's hygiene timers.
> **Meta-only rewrite (v0.31.0).** §13 originally described a
> transactional create-repo-seed-flip sequence (`super-draft → active
> RFC repo`) with rollback. That is **retired** (§1). Graduation is now
> a single in-place state flip on the entry: no repo is created, the
> body is not moved or stripped, and there is nothing to roll back. The
> subsections below are rewritten to the new model; §13.3 records what
> the old transaction did, struck through, for the audit trail.
### 13.1 Claim ownership (prerequisite) ### 13.1 Claim ownership (prerequisite)
@@ -1983,137 +2032,117 @@ broadening rather than a precondition for the proposer's own RFC.)
### 13.2 The Graduate dialog ### 13.2 The Graduate dialog
Clicking "Graduate to RFC repo" opens a small dialog with three Clicking "Graduate" opens a small dialog with two editable fields:
editable fields:
- **Integer ID** — pre-filled as `max(existing integer IDs) + 1`, - **Integer ID** — pre-filled as `max(existing integer IDs) + 1`,
formatted as `RFC-NNNN`. Editable to allow gap reservations but the formatted as `RFC-NNNN`. Editable to allow gap reservations but the
default is just the next number. default is just the next number.
- **Repo name** — pre-filled as `rfc-NNNN-<slug>`, editable but
constrained to valid Gitea repo names.
- **Initial owners** — pre-filled from the entry's `owners:`, with an - **Initial owners** — pre-filled from the entry's `owners:`, with an
"add owner" picker. Must have at least one. "add owner" picker. Must have at least one.
(The old **Repo name** field is gone — there is no repo to name under
the meta-only topology.)
Each field validates inline as the admin types, with a short Each field validates inline as the admin types, with a short
debounce, against the catalog cache and a regex — integer-ID debounce, against the catalog cache and a regex — integer-ID
collision against existing IDs, repo-name pattern against valid collision against existing IDs, the at-least-one-owner constraint on
Gitea name rules, the at-least-one-owner constraint on the picker. the picker. Errors render as a short line of text beneath the
Errors render as a short line of text beneath the offending field. offending field. The integer-ID collision check is re-issued
The repo-name collision check is re-issued atomically server-side atomically server-side on confirm, since a concurrent graduation
on confirm, since a concurrent graduation could land between could land between dialog-open and submit. While any field is
dialog-open and submit. While any field is invalid, the confirm invalid, the confirm button is disabled and its tooltip names the
button is disabled and its tooltip names the first blocker first blocker specifically — "Integer ID 42 is already taken," "Add
specifically — "Integer ID 42 is already taken," "Repo name must be at least one initial owner" — the same grammar the precondition
lowercase letters, digits, and dashes," "Add at least one initial popover below uses, so the dialog and the gate read as one surface
owner" — the same grammar the precondition popover below uses, so rather than two competing styles.
the dialog and the gate read as one surface rather than two
competing styles.
The dialog's confirm button is also disabled when the preconditions The dialog's confirm button is also disabled when the entry has no
from §13's opening paragraph fail — no owners on the entry, or any owners. The disabled button opens a small popover on hover or click
open meta-repo PR against `rfcs/<slug>.md`. The disabled button listing the failing precondition with an inline remediation
opens a small popover on hover or click that lists each failing affordance: "No owners claimed yet" surfaces a "Copy share link"
precondition as its own line item with an inline remediation affordance for surfacing the super-draft to a would-be claimer, plus
affordance per item. "No owners claimed yet" surfaces a "Copy share a secondary "Claim ownership yourself" — admins are contributors per
link" affordance for surfacing the super-draft to a would-be §6.1, so they can claim if they intend to graduate solo. Open
claimer, plus a secondary "Claim ownership yourself" — admins are body-edit PRs are **not** a precondition anymore (§9.8): the body is
contributors per §6.1, so they can claim if they intend to graduate kept, so they coexist with graduation.
solo. "N open body-edit PRs" expands inline within the popover to a
list of the offending PRs, one per row, carrying each PR's title,
author, and last-activity timestamp plus inline merge, withdraw,
and open-in-new-tab affordances; admins hold §6.3 authority on
those PRs and can resolve the precondition from the popover without
leaving the Graduate context.
The preconditions are enforced before the bot starts §13.3's ### 13.3 The flip
sequence, so §13.3's rollback complexity is unchanged.
### 13.3 The transactional sequence Confirming the dialog runs a single operation as the bot: open a PR
against the meta repo that re-serializes `rfcs/<slug>.md` with
`state: active`, `id: RFC-NNNN`, `graduated_at: <timestamp>`,
`graduated_by: <admin username>`, and the `owners:` from the dialog —
**leaving the body unchanged** — then auto-merge it (the admin who
clicked is the merge actor). The webhook flow updates the SQLite cache
and the catalog row transitions per §7.2.
Confirming the dialog runs this sequence as the bot: ```
super-draft entry ──[graduate]──▶ same entry, state: active, id assigned
(body unchanged, repo: null, lives in rfcs/<slug>.md throughout)
```
1. Create the new Gitea repo. There is no repo to create, nothing to seed, and the body is neither
2. Seed it with an initial commit on `main` containing: moved nor stripped, so there is **no multi-step transaction and no
- `README.md` (header pointing at the meta-repo entry, plus the rollback**. If opening or merging the flip PR fails, the entry simply
super-draft's pitch body migrated over). stays a super-draft and the admin sees the error — nothing partial was
- `RFC.md` (the actual document, starting from the super-draft body created that needs cleaning up. The dialog reports a single in-flight
or a template if the body is empty). "Graduating…" state that resolves to success (the PR merged) or a
- `.rfc/metadata.yaml` — mirror of the meta-repo frontmatter for plain error (the PR could not be opened or merged), rather than the
future tooling. old five-step stack. On success, a brief "Graduation complete" frame
3. Open a PR against the meta repo updating the entry: `state: active`, holds for a moment before the dialog closes.
`id: RFC-NNNN`, `repo: <new repo URL>`, `graduated_at: <timestamp>`,
`graduated_by: <admin username>`. The meta-repo entry's body field
is removed (frontmatter only, plus a generated "see the full RFC at
<repo>" link).
4. Auto-merge the PR (the same admin who clicked the button is the
merge actor).
5. Webhook flow updates the SQLite cache; left pane reflects the new
state immediately.
The dialog renders the sequence in flight as a stack of the five > ~~**The old transactional sequence (per-RFC-repo model, retired).**
named steps with per-step states — `pending`, `running`, `done`, > Confirming created a new Gitea repo; seeded it with `README.md`,
`failed`, `not reached` — and a one-line caption beneath the current > `RFC.md` (body migrated), and `.rfc/metadata.yaml`; opened a meta-repo
step naming the concrete operation ("Creating repository > PR flipping `state`/`id`/`repo`/`graduated_*` **and stripping the
wiggleverse/rfc-0042-human…"). The stack streams from > entry body** to frontmatter-only with a "see the full RFC at <repo>"
the server via the SSE surface in §17, one event per step > link; auto-merged it; refreshed the cache. A five-step SSE stack
transition. On success, a brief "Graduation complete" frame holds > rendered progress, and any mid-sequence failure rolled back (delete
for a moment before the dialog closes and the catalog row > the half-created repo, abandon the PR). All of that machinery is
transitions per §7.2. > removed — the body strip was the only reason most of it existed.~~
If any step fails partway, the app rolls back: deletes the ### 13.4 Chat, branches, and history stay put
half-created repo, abandons the unmerged PR, surfaces a clear error
to the admin. The rollback is itself a visible step appended to the
stack on failure — the admin sees that cleanup ran, not just that
the act failed. The failed step turns red, later original-sequence
steps mark "not reached," and a "What happened" panel renders below
the stack explaining what was rolled back, what wasn't (if anything
is unrecoverable), and what to do next. The panel persists until the
admin dismisses it — a failure surface is not auto-dismissed.
Graduation is rare enough to afford this level of care.
### 13.4 Chat history follows the work Nothing moves at graduation. The whole-doc main thread (§8.4), range
and paragraph sub-threads (§8.12), edit-branch chats, flag threads,
and `changes` rows all remain on their existing `(rfc_slug,
branch_name)` rows — the slug is the canonical key per §2.3 and does
not change. Their anchors resolve against the same entry body, which
did not move, so §8.12's stale mechanic is not provoked by graduation.
The chat thread attached to the super-draft moves to the new repo's Because there is no repo boundary to cross, there is no
main-branch chat at graduation. This covers both the whole-doc main "pre-graduation history" hop: the active RFC's view is the same view
thread per §8.4 and any range or paragraph sub-threads per §8.12 the super-draft had, listing the same `main`, open branches, and open
anchored to the super-draft's main view; anchors re-resolve against PRs in the §8.1 breadcrumb dropdown. Edit branches that closed during
`RFC.md` on the new repo and, where they fail, §8.12's stale the super-draft phase surface through the ordinary "Show closed
mechanic engages. The meta-repo entry retains a generated link branches" filter — there is no separate "lived on the meta repo before
"Conversation continues at <repo URL>." The chat is about the RFC, the repo existed" set to distinguish, because the repo never existed.
not the meta-repo entry, and it should travel with the work.
Meta-repo edit-branch chats, flag threads, and `changes` rows from ### 13.5 Reversing graduation
the super-draft phase **do not migrate**. They stay attached to
their original `branch_name` on the meta repo and surface from the
new RFC view via a **"Pre-graduation history"** affordance — a
straightforward lookup of `threads` and `changes` rows where
`rfc_slug = <slug>` and `branch_name` begins with `edit/<slug>/`
(the slug remains the canonical key per §2.3, before and after
graduation). UI affordance; no data movement, no rollback cost.
The affordance renders as a section in the §8.1 breadcrumb dropdown The canonical forward path from `active` is still `withdrawn` (§3.1),
on the new RFC view, alongside `main`, open branches, and open PRs, and `withdrawn → super-draft` reopens an entry. Under the meta-only
headed "Pre-graduation history (N)" with each pre-graduation edit topology, reversing a graduation is no longer operationally messy —
branch listed as its own row. Selecting a row swaps the center there are no repo commits to orphan, only a frontmatter flip — but the
column to a read-only render of that branch's body at its last state graph in §3.1 remains the authority: `active → withdrawn →
commit and the right column to that branch's chat, with associated super-draft` is the supported route, and the integer `id`, once
change-cards and flags inline — the same machinery a closed branch assigned, is not reclaimed (gap-free allocation per §2.3 tolerates
on an active RFC uses per §10.7 and §11.5. Anchors on pre-graduation gaps from withdrawals). A direct `active → super-draft` un-graduate is
threads resolve against the pre-graduation body, not against not exposed in v1; withdraw-and-reopen covers the need.
`RFC.md` on the new repo. The pre-graduation set is kept distinct
from the post-graduation "Show closed branches" filter in the same
dropdown — "branches that closed normally on this repo" and
"branches that lived on the meta repo before this repo existed" are
semantically different sets, and conflating them would obscure the
graduation hop.
### 13.5 Graduation is not reversible ### 13.6 RFC-0001 fold-back (migration record)
Once an entry is graduated to `active`, the path forward is RFC-0001 `human` was graduated under the original per-RFC-repo model
`withdrawn`, not back to `super-draft`. Reversing graduation cleanly (2026-05-26) into `wiggleverse/rfc-0001-human`, with its body in that
is operationally messy (existing commits in the new repo, etc.) and repo's `RFC.md` and its meta entry stripped to frontmatter. When the
the cost of not having it is low — withdraw and re-graduate as a meta-only topology landed (v0.31.0, OHM ROADMAP #36, driver session
fresh idea if needed. 0041.0), RFC-0001 was folded back to the single model: the full
`RFC.md` body was restored into `rfcs/human.md` in the meta repo
(`repo:` set null, `state: active` and `id: RFC-0001` retained), and
`wiggleverse/rfc-0001-human` was archived with a `README` pointing at
the canonical home in the app. RFC-0001 is therefore an ordinary
meta-only active RFC like any other; no grandfathered per-repo path
remains in the code.
--- ---
+1 -1
View File
@@ -1 +1 @@
0.30.1 0.31.2
+59 -37
View File
@@ -150,7 +150,7 @@ def make_router(
# `refresh_meta_branches` writes is internal scaffolding for the # `refresh_meta_branches` writes is internal scaffolding for the
# §10.1 has-commits-ahead check — the §9.4 dropdown's first # §10.1 has-commits-ahead check — the §9.4 dropdown's first
# position is rendered separately as 'canonical body'. # position is rendered separately as 'canonical body'.
if _is_super_draft(rfc): if _is_meta_resident(rfc):
branch_rows = db.conn().execute( branch_rows = db.conn().execute(
""" """
SELECT branch_name, head_sha, state, last_commit_at, pinned SELECT branch_name, head_sha, state, last_commit_at, pinned
@@ -180,7 +180,7 @@ def make_router(
# per-RFC repo. For super-draft: meta_body_edit and meta_metadata # per-RFC repo. For super-draft: meta_body_edit and meta_metadata
# PRs on the meta repo. Same shape either way — the §9.4 dropdown # PRs on the meta repo. Same shape either way — the §9.4 dropdown
# treats both as "open work against this entry." # treats both as "open work against this entry."
pr_kinds = ("meta_body_edit", "meta_metadata") if _is_super_draft(rfc) else ("rfc_branch",) pr_kinds = ("meta_body_edit", "meta_metadata") if _is_meta_resident(rfc) else ("rfc_branch",)
placeholders = ",".join("?" * len(pr_kinds)) placeholders = ",".join("?" * len(pr_kinds))
pr_rows = db.conn().execute( pr_rows = db.conn().execute(
f""" f"""
@@ -204,17 +204,18 @@ def make_router(
for r in pr_rows for r in pr_rows
] ]
# For super-drafts the cached body is entry.body already (see # For meta-resident entries the cached body is entry.body already
# cache._upsert_cached_rfc), so no extraction is needed. # (see cache._upsert_cached_rfc), so no extraction is needed.
# §9.8 / §13.4 pre-graduation history: for active RFCs, surface # Pre-graduation history is a LEGACY-only affordance: under the
# any `threads` or `changes` rows whose `branch_name` starts with # meta-only topology (§1, §13.4) graduation moves nothing, so an
# `edit-<slug>-` so the breadcrumb dropdown can render the # active RFC's edit branches are its *current* branches and already
# affordance as a distinct disclosure alongside main, open # surface in `branches` above — there is no separate pre-graduation
# branches, and open PRs. The slug is the canonical key per §2.3 # set. The disclosure is therefore computed only for a legacy
# before and after graduation, so the query is a straightforward # per-RFC-repo active entry (`repo` set), where edit branches on the
# lookup — no data movement. # meta repo genuinely predate the per-RFC repo and would otherwise
# not appear. After the RFC-0001 fold-back (§13.6) nothing matches.
pre_grad: list[dict[str, Any]] = [] pre_grad: list[dict[str, Any]] = []
if rfc["state"] == "active": if rfc["state"] == "active" and rfc["repo"]:
pre_grad_rows = db.conn().execute( pre_grad_rows = db.conn().execute(
""" """
SELECT t.branch_name, SELECT t.branch_name,
@@ -292,8 +293,20 @@ def make_router(
owner, repo = _repo_for(rfc) owner, repo = _repo_for(rfc)
new_branch = (body.branch_name or "").strip() new_branch = (body.branch_name or "").strip()
if not new_branch: if not new_branch:
new_branch = _auto_branch_name(viewer.gitea_login) # Meta-only topology (§1): an active RFC's branches live on the
_validate_branch_name(new_branch) # shared meta repo, so the auto name must embed the slug for the
# cache to attribute it (`edit-<slug>-<hex>`, recovered by
# `_slug_from_branch_name`). A legacy per-RFC-repo entry can use
# the slug-free `<login>-draft-<hex>` since every branch there
# belongs to the one RFC. The auto name is trusted (it carries a
# reserved `edit-` prefix by design); only a user-supplied name
# is validated, mirroring `start_edit_branch`.
new_branch = (
_auto_edit_branch_name(slug) if _is_meta_resident(rfc)
else _auto_branch_name(viewer.gitea_login)
)
else:
_validate_branch_name(new_branch)
try: try:
await bot.cut_branch_from_main( await bot.cut_branch_from_main(
viewer.as_actor(), viewer.as_actor(),
@@ -326,8 +339,10 @@ def make_router(
_ensure_branch_vis(slug, new_branch, creator_user_id=viewer.user_id) _ensure_branch_vis(slug, new_branch, creator_user_id=viewer.user_id)
# Make the cache aware immediately so the breadcrumb reflects # Make the cache aware immediately so the breadcrumb reflects
# the new branch without waiting for the webhook hop. # the new branch without waiting for the webhook hop. Meta-resident
await cache.refresh_rfc_repo(config, gitea, slug) # entries (§1) refresh meta branches; a legacy per-RFC repo refreshes
# its own — `_refresh_cache_for` dispatches on residency.
await _refresh_cache_for(rfc)
return {"branch_name": new_branch, "slug": slug} return {"branch_name": new_branch, "slug": slug}
@@ -1081,14 +1096,14 @@ def make_router(
return row return row
def _require_rfc_with_repo(slug: str): def _require_rfc_with_repo(slug: str):
"""Used by every branch-scoped endpoint. For active RFCs, a repo is """Used by every branch-scoped endpoint. Under the meta-only
required. For super-drafts, the meta repo is the implicit target topology (§1) the meta repo is the implicit target for every
no per-RFC repo check needed.""" entry super-draft and active alike so there is no per-RFC
repo check. The name is retained for call-site stability; a
withdrawn entry is still rejected."""
row = _require_rfc(slug) row = _require_rfc(slug)
if row["state"] == "withdrawn": if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn") raise HTTPException(409, "RFC is withdrawn")
if row["state"] == "active" and not row["repo"]:
raise HTTPException(409, "RFC has no repo")
return row return row
def _require_active_rfc(slug: str): def _require_active_rfc(slug: str):
@@ -1106,22 +1121,28 @@ 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 _is_meta_resident(rfc) -> bool:
"""Meta-only topology (§1): an entry lives in the meta repo's
`rfcs/<slug>.md` (super-draft or active-in-place) unless it carries
a legacy per-RFC `repo:` which nothing does after the RFC-0001
fold-back (§13.6)."""
return not rfc["repo"]
def _is_meta_branch_name(name: str) -> bool: def _is_meta_branch_name(name: str) -> bool:
"""A branch name shaped like one of the bot's meta-repo prefixes. """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 Retained for the legacy per-RFC-repo read path; under meta-only
at branches matching `edit-<slug>-...` even after the entry is every entry is already a meta target via `_is_meta_resident`."""
active; treating those names as meta-repo targets lets the read
path dispatch correctly without a separate endpoint."""
return name != "main" and name.startswith(( return name != "main" and name.startswith((
"edit-", "edit/", "metadata-", "metadata/", "claim/", "propose/", "edit-", "edit/", "metadata-", "metadata/", "claim/", "propose/",
"graduate-", "graduate-",
)) ))
def _is_meta_target(rfc, branch: str) -> bool: def _is_meta_target(rfc, branch: str) -> bool:
"""Either a super-draft branch (active edit branch or the """A meta-resident entry (super-draft or active-in-place, §1)
canonical body) or an active RFC's pre-graduation meta-repo targets the meta repo for every branch. The branch-name fallback
branch surfaced through the §9.8 history affordance.""" covers the retired per-RFC-repo case for any legacy entry that
if _is_super_draft(rfc): still carries a `repo:`."""
if _is_meta_resident(rfc):
return True return True
return _is_meta_branch_name(branch) return _is_meta_branch_name(branch)
@@ -1161,7 +1182,7 @@ def make_router(
return entry_mod.serialize(entry) return entry_mod.serialize(entry)
async def _refresh_cache_for(rfc) -> None: async def _refresh_cache_for(rfc) -> None:
if _is_super_draft(rfc): if _is_meta_resident(rfc):
await cache.refresh_meta_repo(config, gitea) await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea) await cache.refresh_meta_branches(config, gitea)
else: else:
@@ -1270,12 +1291,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 # §9.8 (LEGACY per-repo only): pre-graduation history branches are
# post-graduation surface. The contributor can re-cut against the # read-only on the post-graduation surface of a per-RFC-repo active
# new repo's main if they still want the work, but the meta-repo # entry. Under the meta-only topology (§1, §13.4) an active RFC's
# branches that lived on the super-draft are not editable from # `edit-<slug>-…` branches are its *current* editable branches, not
# the active-RFC view. # a frozen pre-graduation set, so this guard applies only when a
if rfc["state"] == "active" and _is_meta_branch_name(branch): # legacy `repo:` is set (nothing, after the RFC-0001 fold-back).
if rfc["state"] == "active" and rfc["repo"] and _is_meta_branch_name(branch):
return False return False
if viewer.role in ("owner", "admin"): if viewer.role in ("owner", "admin"):
return True return True
@@ -1302,7 +1324,7 @@ 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 state, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?", "SELECT state, repo, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?",
(slug,), (slug,),
).fetchone() ).fetchone()
if not _can_contribute(rfc, slug, branch, viewer): if not _can_contribute(rfc, slug, branch, viewer):
+132 -365
View File
@@ -1,26 +1,30 @@
"""Slice 5 API surface — the §13 graduation flow's endpoints and the """§13 graduation flow — the meta-only in-place state flip.
in-process orchestrator that runs the §13.3 transactional sequence with
rollback.
Owns four routes per §17: Under the meta-only topology (SPEC §1), graduation no longer creates a
per-RFC repo. It is a single frontmatter-flipping commit to the entry's
`rfcs/<slug>.md` on the meta repo: open a PR that re-serializes the entry
with `state: active`, the assigned integer `id`, `graduated_at` /
`graduated_by`, and the dialog's owners — **leaving the body unchanged** —
then auto-merge it. There is no repo to create, nothing to seed, and the
body is neither moved nor stripped, so there is no multi-step transaction
and no rollback (§13.3). If the open or merge fails, the entry stays a
super-draft and we clean up the half-open PR/branch (the only artifact a
mid-flip failure can leave behind).
Routes (§17):
- GET /api/rfcs/<slug>/blocking-prs (§13.2 precondition popover)
- GET /api/rfcs/<slug>/graduate/check (§13.2 debounced validator) - GET /api/rfcs/<slug>/graduate/check (§13.2 debounced validator)
- POST /api/rfcs/<slug>/graduate (§13.3 kickoff) - POST /api/rfcs/<slug>/graduate (§13.3 the flip)
- GET /api/rfcs/<slug>/graduate/progress (§13.3 SSE step stream) - GET /api/rfcs/<slug>/graduate/progress (§13.3 SSE step stream)
- GET /api/rfcs/<slug>/blocking-prs (informational; no longer a
graduation precondition)
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim), which is Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim).
graduation's prerequisite for non-admins per §13.1.
The orchestrator runs in-process each in-flight graduation lives in a The orchestrator runs in-process each in-flight graduation lives in a
small `GraduationState` keyed by slug, with an asyncio.Queue feeding the small `GraduationState` keyed by slug, with an asyncio.Queue feeding the
SSE handler. Per the §13.3 transactional contract, every forward step is SSE handler. §13.4's chat/branch/history are a database no-op: every row
paired with an undo; rollback runs the undos in reverse order from the is keyed by the slug per §2.3 and stays put across the flip.
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 from __future__ import annotations
@@ -44,24 +48,18 @@ log = logging.getLogger(__name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step machine # Step machine — two steps under meta-only: open the flip PR, merge it.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
STEP_KEYS = ( STEP_KEYS = (
"create_repo",
"seed_files",
"open_pr", "open_pr",
"merge_pr", "merge_pr",
"refresh_cache",
) )
STEP_LABELS = { STEP_LABELS = {
"create_repo": "Create per-RFC repository", "open_pr": "Open graduation PR (flip state to active)",
"seed_files": "Seed RFC.md, README.md, and .rfc/metadata.yaml",
"open_pr": "Open meta-repo graduation PR",
"merge_pr": "Merge graduation PR", "merge_pr": "Merge graduation PR",
"refresh_cache": "Refresh catalog and views",
} }
@@ -77,8 +75,6 @@ class StepState:
class GraduationState: class GraduationState:
slug: str slug: str
rfc_id: str rfc_id: str
repo_name: str
repo_full: str
owners: list[str] owners: list[str]
arbiters: list[str] arbiters: list[str]
steps: list[StepState] steps: list[StepState]
@@ -86,8 +82,6 @@ class GraduationState:
finished: bool = False finished: bool = False
succeeded: bool = False succeeded: bool = False
error: str | None = None error: str | None = None
rollback_started: bool = False
rollback_steps: list[StepState] = field(default_factory=list)
new_pr_number: int | None = None new_pr_number: int | None = None
graduation_branch: str | None = None graduation_branch: str | None = None
@@ -95,12 +89,9 @@ class GraduationState:
return { return {
"slug": self.slug, "slug": self.slug,
"rfc_id": self.rfc_id, "rfc_id": self.rfc_id,
"repo_full": self.repo_full,
"steps": [_step_payload(s) for s in self.steps], "steps": [_step_payload(s) for s in self.steps],
"rollback_steps": [_step_payload(s) for s in self.rollback_steps],
"finished": self.finished, "finished": self.finished,
"succeeded": self.succeeded, "succeeded": self.succeeded,
"rolled_back": self.rollback_started,
"error": self.error, "error": self.error,
"pr_number": self.new_pr_number, "pr_number": self.new_pr_number,
} }
@@ -114,7 +105,7 @@ def _step_payload(s: StepState) -> dict:
# is fine; the registry is keyed by slug to refuse concurrent graduations # 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 # 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 # against a concurrent attempt of a DIFFERENT slug claiming the same
# integer ID or repo name). # integer ID).
_active: dict[str, GraduationState] = {} _active: dict[str, GraduationState] = {}
@@ -122,10 +113,10 @@ def _get_active(slug: str) -> GraduationState | None:
return _active.get(slug) return _active.get(slug)
def _new_active(slug: str, *, rfc_id: str, repo_name: str, repo_full: str, def _new_active(slug: str, *, rfc_id: str,
owners: list[str], arbiters: list[str]) -> GraduationState: owners: list[str], arbiters: list[str]) -> GraduationState:
state = GraduationState( state = GraduationState(
slug=slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full, slug=slug, rfc_id=rfc_id,
owners=owners, arbiters=arbiters, owners=owners, arbiters=arbiters,
steps=[StepState(key=k, label=STEP_LABELS[k]) for k in STEP_KEYS], steps=[StepState(key=k, label=STEP_LABELS[k]) for k in STEP_KEYS],
) )
@@ -138,17 +129,9 @@ def _new_active(slug: str, *, rfc_id: str, repo_name: str, repo_full: str,
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# §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,}$") _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: def _is_valid_rfc_id(rfc_id: str) -> bool:
return bool(_RFC_ID_RE.match(rfc_id)) return bool(_RFC_ID_RE.match(rfc_id))
@@ -167,13 +150,6 @@ def _suggest_next_rfc_id() -> str:
return f"RFC-{nxt:04d}" 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: def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool:
row = db.conn().execute( row = db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE rfc_id = ? AND slug != ?", "SELECT slug FROM cached_rfcs WHERE rfc_id = ? AND slug != ?",
@@ -189,7 +165,6 @@ def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool:
class GraduateBody(BaseModel): class GraduateBody(BaseModel):
rfc_id: str = Field(min_length=5, max_length=40) 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) owners: list[str] = Field(min_length=1)
@@ -206,19 +181,17 @@ def make_router(
router = APIRouter() router = APIRouter()
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# §13.2: GET /api/rfcs/<slug>/blocking-prs # GET /api/rfcs/<slug>/blocking-prs
# Lists open meta-repo PRs against rfcs/<slug>.md per the precondition # Lists open meta-repo body-edit PRs against rfcs/<slug>.md. Under the
# popover. Returns PR number, title, author, last-activity timestamp, # meta-only topology (§9.8) these no longer block graduation — the body
# and the viewer's available actions (merge, withdraw, open-in-new-tab). # is kept, so a body-edit PR coexists with the flip. Retained as an
# informational surface (the dialog can show "N body-edit PRs open").
# ------------------------------------------------------------------- # -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/blocking-prs") @router.get("/api/rfcs/{slug}/blocking-prs")
async def list_blocking_prs(slug: str, request: Request) -> dict[str, Any]: async def list_blocking_prs(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request) viewer = auth.current_user(request)
rfc = _require_super_draft(slug) 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( rows = db.conn().execute(
""" """
SELECT pr_number, title, opened_by, opened_at, head_branch, pr_kind SELECT pr_number, title, opened_by, opened_at, head_branch, pr_kind
@@ -261,14 +234,15 @@ def make_router(
"open_in_new_tab": True, "open_in_new_tab": True,
}, },
}) })
return {"items": items} # `blocking` is a legacy field name kept for client compatibility;
# under §9.8 these PRs do not block graduation.
return {"items": items, "blocking": False}
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# §13.2: GET /api/rfcs/<slug>/graduate/check?id=&repo= # GET /api/rfcs/<slug>/graduate/check?id=
# Inline validation for the Graduate dialog — debounced from the # Inline validation for the Graduate dialog — debounced from the
# client; the dialog calls this as the admin types. Returns per-field # client. Two fields under meta-only: the integer ID and the owners
# collision/validity from the catalog cache plus a server-authoritative # precondition. There is no repo name to validate (§13.2).
# repo-name collision check.
# ------------------------------------------------------------------- # -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/graduate/check") @router.get("/api/rfcs/{slug}/graduate/check")
@@ -281,16 +255,7 @@ def make_router(
# admins/owners, but the check itself is read-only. # admins/owners, but the check itself is read-only.
candidate_id = (request.query_params.get("id") or "").strip() 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 "[]") 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 field
id_payload: dict[str, Any] = {"value": candidate_id, "ok": True, "error": None} id_payload: dict[str, Any] = {"value": candidate_id, "ok": True, "error": None}
@@ -304,34 +269,6 @@ def make_router(
id_payload["ok"] = False id_payload["ok"] = False
id_payload["error"] = f"Integer ID {candidate_id} is already taken" 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 precondition — §13's opening paragraph.
owners_payload: dict[str, Any] = { owners_payload: dict[str, Any] = {
"ok": len(owners) > 0, "ok": len(owners) > 0,
@@ -340,28 +277,13 @@ def make_router(
"error": None if len(owners) > 0 else "No owners claimed yet", "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) in_flight = _get_active(slug)
any_invalid = not ( any_invalid = not (id_payload["ok"] and owners_payload["ok"])
id_payload["ok"] and repo_payload["ok"]
and owners_payload["ok"] and prs_payload["ok"]
)
return { return {
"slug": slug, "slug": slug,
"id": id_payload, "id": id_payload,
"repo": repo_payload,
"owners": owners_payload, "owners": owners_payload,
"blocking_prs": prs_payload,
"can_submit": (not any_invalid) and (in_flight is None or in_flight.finished), "can_submit": (not any_invalid) and (in_flight is None or in_flight.finished),
"in_flight": ( "in_flight": (
None if in_flight is None None if in_flight is None
@@ -370,8 +292,8 @@ def make_router(
} }
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# §13.3: POST /api/rfcs/<slug>/graduate # POST /api/rfcs/<slug>/graduate
# Atomic re-validation, then kicks off the sequence as an async task. # Atomic re-validation, then kicks off the flip as an async task.
# The client opens GET /graduate/progress on confirm to watch the SSE. # The client opens GET /graduate/progress on confirm to watch the SSE.
# ------------------------------------------------------------------- # -------------------------------------------------------------------
@@ -392,9 +314,8 @@ def make_router(
# §13.2 atomic re-validation. The dialog's debounced check runs # §13.2 atomic re-validation. The dialog's debounced check runs
# client-side as the admin types; this is the authoritative check # client-side as the admin types; this is the authoritative check
# that closes the dialog-open-to-confirm race. # that closes the dialog-open-to-confirm race on the integer ID.
rfc_id = body.rfc_id.strip() rfc_id = body.rfc_id.strip()
repo_name = body.repo_name.strip()
owners = [o.strip() for o in body.owners if o.strip()] owners = [o.strip() for o in body.owners if o.strip()]
if not owners: if not owners:
raise HTTPException(422, "Add at least one initial owner") raise HTTPException(422, "Add at least one initial owner")
@@ -402,35 +323,10 @@ def make_router(
raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)") raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)")
if _rfc_id_taken(rfc_id, excluding_slug=slug): if _rfc_id_taken(rfc_id, excluding_slug=slug):
raise HTTPException(409, f"Integer ID {rfc_id} is already taken") 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 # 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 # graduation PR's update_file call and the body to carry through
# bot can seed RFC.md on the new repo with the migrated body. # unchanged (meta-only keeps the body in the entry, §13.3).
fetched = await gitea.read_file( fetched = await gitea.read_file(
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main", config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
) )
@@ -442,19 +338,17 @@ def make_router(
except Exception as e: except Exception as e:
raise HTTPException(500, f"Meta entry malformed: {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] arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1]
# Compose the graduated frontmatter — body stripped, graduation # Compose the graduated frontmatter — body KEPT, graduation fields
# fields filled. The serializer is run now so the PR-open step # filled, repo left null (§1). Serialized now so the PR-open step
# has the contents pre-rendered (single source of truth for the # has the contents pre-rendered.
# body migration vs. the meta-entry update).
graduated_entry = entry_mod.Entry( graduated_entry = entry_mod.Entry(
slug=slug, slug=slug,
title=super_draft_entry.title, title=super_draft_entry.title,
state="active", state="active",
id=rfc_id, id=rfc_id,
repo=repo_full, repo=None,
proposed_by=super_draft_entry.proposed_by, proposed_by=super_draft_entry.proposed_by,
proposed_at=super_draft_entry.proposed_at, proposed_at=super_draft_entry.proposed_at,
graduated_at=entry_mod.today(), graduated_at=entry_mod.today(),
@@ -462,37 +356,30 @@ def make_router(
owners=owners, owners=owners,
arbiters=arbiters, arbiters=arbiters,
tags=list(super_draft_entry.tags), tags=list(super_draft_entry.tags),
body="", models=super_draft_entry.models,
funder=super_draft_entry.funder,
body=super_draft_entry.body,
) )
graduated_contents = entry_mod.serialize(graduated_entry) graduated_contents = entry_mod.serialize(graduated_entry)
state = _new_active( state = _new_active(
slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full, slug, rfc_id=rfc_id, owners=owners, arbiters=arbiters,
owners=owners, arbiters=arbiters,
) )
# Audit: graduation started. The terminal `graduate_complete` / # Audit: graduation started. The terminal `graduate_complete` /
# `graduate_rollback` rows below close the linkable sequence. # `graduate_failed` rows below close the linkable sequence.
_audit( _audit(
viewer.user_id, viewer.gitea_login, "graduate_start", viewer.user_id, viewer.gitea_login, "graduate_start",
rfc_slug=slug, rfc_slug=slug,
details={ details={"rfc_id": rfc_id, "owners": owners},
"rfc_id": rfc_id, "repo": repo_full, "owners": owners,
"blocking_prs": blocking,
},
) )
# Test seam: `?_sync=1` awaits the orchestrator inline so # Test seam: `?_sync=1` awaits the orchestrator inline so
# integration tests can assert post-conditions without driving # integration tests can assert post-conditions without driving
# the SSE. Production clients use the spec-described shape — # the SSE. Production clients POST then subscribe to the SSE.
# POST returns immediately, the client subscribes to the
# progress SSE.
coro = _orchestrate( coro = _orchestrate(
config=config, gitea=gitea, bot=bot, config=config, gitea=gitea, bot=bot,
actor=viewer.as_actor(), state=state, 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, graduated_contents=graduated_contents,
meta_file_sha=meta_sha, meta_file_sha=meta_sha,
) )
@@ -505,38 +392,31 @@ def make_router(
"ok": True, "ok": True,
"slug": slug, "slug": slug,
"rfc_id": rfc_id, "rfc_id": rfc_id,
"repo": repo_full,
"stream_url": f"/api/rfcs/{slug}/graduate/progress", "stream_url": f"/api/rfcs/{slug}/graduate/progress",
"finished": state.finished, "finished": state.finished,
"succeeded": state.succeeded, "succeeded": state.succeeded,
} }
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# §13.3: GET /api/rfcs/<slug>/graduate/progress # GET /api/rfcs/<slug>/graduate/progress
# SSE stream of the step transitions. One event per step transition # SSE stream of the flip's step transitions (open_pr, merge_pr).
# (pending → running → done / failed), plus the trailing rollback
# step's events if any earlier step fails.
# ------------------------------------------------------------------- # -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/graduate/progress") @router.get("/api/rfcs/{slug}/graduate/progress")
async def graduate_progress(slug: str, request: Request): async def graduate_progress(slug: str, request: Request):
# v0.6.0 (item #4): the progress SSE surfaces admin-internal step # The progress SSE surfaces admin-internal step detail (PR number)
# detail (repo name, PR number, rollback steps) that isn't part of # that isn't part of the anonymous-read contract. POST /graduate is
# the v0.3.0 anonymous-read contract for catalog/RFC bodies. The # gated to RFC owners/arbiters and app admins/owners; the read SSE
# corresponding POST /graduate is gated to RFC owners/arbiters and # shares that operator-visible surface and requires an authenticated
# app admins/owners via `_can_graduate`; the read SSE shares that # viewer. We keep the floor at require_user (not require_contributor)
# operator-visible surface, so it requires at least an # so a write-muted operator can still observe a graduation they
# authenticated viewer. We keep the floor at require_user (not # kicked off before being muted.
# require_contributor) so a write-muted operator can still observe
# the progress of a graduation they kicked off before being muted.
auth.require_user(request) auth.require_user(request)
state = _get_active(slug) state = _get_active(slug)
if state is None: if state is None:
raise HTTPException(404, "No graduation in flight for this slug") raise HTTPException(404, "No graduation in flight for this slug")
async def event_stream(): 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()) yield _sse_event("snapshot", state.to_payload())
if state.finished: if state.finished:
yield _sse_event("done", state.to_payload()) yield _sse_event("done", state.to_payload())
@@ -555,22 +435,16 @@ def make_router(
# §13.1: POST /api/rfcs/<slug>/claim # §13.1: POST /api/rfcs/<slug>/claim
# Opens a meta-repo PR adding the actor's gitea_login to the entry's # 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 list. Anyone signed in may claim — the merge is gated to
# owners/admins per §13.1 (which collapses to admins for unclaimed # owners/admins per §13.1.
# entries since `owners` is empty).
# ------------------------------------------------------------------- # -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/claim") @router.post("/api/rfcs/{slug}/claim")
async def claim_ownership(slug: str, request: Request) -> dict[str, Any]: async def claim_ownership(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request) viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug) rfc = _require_super_draft(slug)
# Refuse if the actor is already in owners — no-op claim.
existing_owners = json.loads(rfc["owners_json"] or "[]") existing_owners = json.loads(rfc["owners_json"] or "[]")
if viewer.gitea_login in existing_owners: if viewer.gitea_login in existing_owners:
return {"ok": True, "noop": True} 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( already = db.conn().execute(
""" """
SELECT pr_number FROM cached_prs SELECT pr_number FROM cached_prs
@@ -581,8 +455,6 @@ def make_router(
if already: if already:
raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}") 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( fetched = await gitea.read_file(
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main", config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
) )
@@ -626,7 +498,7 @@ def make_router(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Orchestrator # Orchestrator — the §13.3 in-place flip
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -637,57 +509,21 @@ async def _orchestrate(
bot: Bot, bot: Bot,
actor: Actor, actor: Actor,
state: GraduationState, state: GraduationState,
super_draft_body: str,
super_draft_title: str,
super_draft_tags: list[str],
graduated_contents: str, graduated_contents: str,
meta_file_sha: str, meta_file_sha: str,
) -> None: ) -> None:
"""Run §13.3 step by step. Each step: """Open the flip PR, then merge it. Two steps, no transaction:
- marks itself `running` and pushes an event - open_pr fails nothing was created; the entry stays a super-draft.
- calls the bot method (which writes to Gitea + audit log) - merge_pr fails close the open PR and delete its branch (the only
- marks itself `done` (or `failed`) and pushes another event artifact a mid-flip failure can leave on the meta repo), then the
entry stays a super-draft.
On failure at step N, every later step is marked `not-reached` and There is no rollback of a *merged* flip once the meta-repo merge has
`_rollback` runs undoes in reverse from N-1 to 1. landed, the path forward is §3's `withdraw` (§13.5).
""" """
try: try:
# ----- Step 1: create per-RFC repo ----- # ----- Step 1: open the graduation PR (flip frontmatter) -----
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…") await _start(state, "open_pr", "Opening graduation PR…")
try: try:
pr = await bot.open_graduation_pr( pr = await bot.open_graduation_pr(
@@ -696,19 +532,18 @@ async def _orchestrate(
slug=state.slug, slug=state.slug,
new_file_contents=graduated_contents, new_file_contents=graduated_contents,
prior_sha=meta_file_sha, prior_sha=meta_file_sha,
rfc_id=state.rfc_id, repo_full=state.repo_full, rfc_id=state.rfc_id,
owners=state.owners, owners=state.owners,
) )
except GiteaError as e: except GiteaError as e:
await _fail(state, "open_pr", f"Gitea: {e.detail}") await _fail(state, "open_pr", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor, await _finish_failed(state, failed_at="open_pr", on_behalf_of=actor.gitea_login)
state=state, failed_at="open_pr")
return return
state.new_pr_number = pr["number"] state.new_pr_number = pr["number"]
state.graduation_branch = pr["head"]["ref"] state.graduation_branch = pr["head"]["ref"]
await _done(state, "open_pr", f"PR #{state.new_pr_number}") await _done(state, "open_pr", f"PR #{state.new_pr_number}")
# ----- Step 4: merge the graduation PR ----- # ----- Step 2: merge the graduation PR -----
await _start(state, "merge_pr", f"Merging PR #{state.new_pr_number}") await _start(state, "merge_pr", f"Merging PR #{state.new_pr_number}")
try: try:
await bot.merge_graduation_pr( await bot.merge_graduation_pr(
@@ -720,35 +555,28 @@ async def _orchestrate(
) )
except GiteaError as e: except GiteaError as e:
await _fail(state, "merge_pr", f"Gitea: {e.detail}") await _fail(state, "merge_pr", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor, await _cleanup_unmerged(config=config, bot=bot, actor=actor, state=state)
state=state, failed_at="merge_pr") await _finish_failed(state, failed_at="merge_pr", on_behalf_of=actor.gitea_login)
return return
await _done(state, "merge_pr", f"PR #{state.new_pr_number} merged") await _done(state, "merge_pr", f"PR #{state.new_pr_number} merged")
# ----- Step 5: refresh the cache so the catalog flips immediately. # Refresh the cache so the catalog flips immediately. The webhook
# Per §13.3 step 5 the webhook flow is the steady-state path, but # flow is the steady-state path (§13.3); we refresh inline so the
# we refresh inline so the dialog can transition to "graduation # dialog can transition to "graduation complete" with the catalog
# complete" with the catalog row already showing `active`. A # row already showing `active`. A refresh failure does not unwind
# cache-refresh failure does not unwind Git state — the # the merge — the reconciler catches up per §4.1.
# reconciler will catch up per §4.1.
await _start(state, "refresh_cache", "Refreshing catalog and views…")
try: try:
await cache.refresh_meta_repo(config, gitea) await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea) await cache.refresh_meta_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea) await cache.refresh_meta_pulls(config, gitea)
await cache.refresh_rfc_repo(config, gitea, state.slug)
except Exception as e: except Exception as e:
log.warning("graduate refresh_cache failed for %s: %s", state.slug, e) log.warning("graduate cache refresh 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( _audit(
None, actor.gitea_login, "graduate_complete", None, actor.gitea_login, "graduate_complete",
rfc_slug=state.slug, rfc_slug=state.slug,
details={ details={
"rfc_id": state.rfc_id, "repo": state.repo_full, "rfc_id": state.rfc_id,
"owners": state.owners, "pr_number": state.new_pr_number, "owners": state.owners, "pr_number": state.new_pr_number,
}, },
) )
@@ -757,109 +585,38 @@ async def _orchestrate(
await state.queue.put({"event": "completed", "payload": state.to_payload()}) await state.queue.put({"event": "completed", "payload": state.to_payload()})
except Exception as e: except Exception as e:
log.exception("graduate: unexpected error for %s", state.slug) 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) running = next((s for s in state.steps if s.status == "running"), None)
if running is not None: if running is not None:
await _fail(state, running.key, f"unexpected: {e}") await _fail(state, running.key, f"unexpected: {e}")
await _rollback( await _finish_failed(
config=config, gitea=gitea, bot=bot, actor=actor, state, failed_at=running.key if running else "unknown",
state=state, failed_at=running.key if running else "unknown", on_behalf_of=actor.gitea_login,
) )
finally: finally:
# Push the sentinel so any open SSE handler returns. # Push the sentinel so any open SSE handler returns.
await state.queue.put(None) await state.queue.put(None)
async def _rollback( async def _cleanup_unmerged(
*, *, config: Config, bot: Bot, actor: Actor, state: GraduationState,
config: Config, gitea: Gitea, bot: Bot, actor: Actor,
state: GraduationState, failed_at: str,
) -> None: ) -> None:
"""Run undoes in reverse order from the last completed step. Each """A merge failure leaves the flip PR open on its `graduate-<slug>-<hex>`
undo emits its own rollback-step event so the dialog can render the branch. Close the PR and delete the branch so failed attempts don't
cleanup as a visible step appended to the stack per §13.3.""" accumulate on the meta repo. Best-effort failures here are logged,
state.rollback_started = True not surfaced as a separate step (the entry already stays a super-draft).
# 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: if state.new_pr_number is None:
return "No PR opened" return
await bot.close_graduation_pr( try:
actor, await bot.close_graduation_pr(
org=config.gitea_org, meta_repo=config.meta_repo, actor,
pr_number=state.new_pr_number, org=config.gitea_org, meta_repo=config.meta_repo,
head_branch=state.graduation_branch or "", pr_number=state.new_pr_number,
slug=state.slug, reason="graduation rollback", head_branch=state.graduation_branch or "",
) slug=state.slug, reason="graduation merge failed",
# Per the §19.2 "graduation rollback's branch cleanup" candidate )
# that Slice 8 settles: delete the dash-suffixed branch on rollback except Exception:
# so failed-graduation branches don't accumulate on the meta repo. log.exception("graduate cleanup: close PR #%s failed", state.new_pr_number)
# The §12 hygiene sweep would catch this eventually, but closing
# the loop here removes the chance of pile-up across retries.
branch_name = state.graduation_branch or "" branch_name = state.graduation_branch or ""
if branch_name: if branch_name:
try: try:
@@ -870,24 +627,35 @@ async def _undo_open_pr(*, config, gitea, bot, actor, state) -> str:
branch=branch_name, branch=branch_name,
slug=state.slug, slug=state.slug,
action_kind="delete_post_merge_branch", action_kind="delete_post_merge_branch",
reason="graduation rollback", reason="graduation merge failed",
) )
except Exception: except Exception:
log.exception("rollback: delete_branch failed for %s", branch_name) log.exception("graduate cleanup: delete_branch %s failed", branch_name)
return f"Closed PR #{state.new_pr_number}"
# merge_pr's undo is intentionally absent — once the meta-repo merge has async def _finish_failed(state: GraduationState, *, failed_at: str, on_behalf_of: str) -> None:
# landed, graduation is irreversible per §13.5. If we ever reach a merged """Mark any step after the failure as not-reached, write the audit
# state and a later step fails (which can't happen — refresh_cache failures row, and emit the terminal failed event."""
# fold into success), there is no clean undo path; the user transitions seen_failure = False
# via §3's `withdraw` instead. for s in state.steps:
if s.status == "failed":
_UNDO_BY_STEP = { seen_failure = True
"create_repo": _undo_create_repo, continue
"seed_files": _undo_seed_files, if seen_failure and s.status == "pending":
"open_pr": _undo_open_pr, s.status = "not-reached"
} _audit(
None, on_behalf_of, "graduate_failed",
rfc_slug=state.slug,
details={
"failed_at": failed_at,
"error": state.error,
"rfc_id": state.rfc_id,
"pr_number": state.new_pr_number,
},
)
state.finished = True
state.succeeded = False
await state.queue.put({"event": "failed", "payload": state.to_payload()})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -907,7 +675,7 @@ def _can_graduate(rfc, viewer) -> bool:
def _audit( def _audit(
actor_user_id: int | None, actor_user_id: int | None,
on_behalf_of: str, on_behalf_of: str | None,
action_kind: str, action_kind: str,
*, *,
rfc_slug: str | None = None, rfc_slug: str | None = None,
@@ -918,7 +686,7 @@ def _audit(
"""Direct audit-log write for graduation lifecycle events that don't """Direct audit-log write for graduation lifecycle events that don't
correspond to a single Gitea write. The per-step Gitea writes log correspond to a single Gitea write. The per-step Gitea writes log
themselves via the bot's `_log`; this is for the bracketing themselves via the bot's `_log`; this is for the bracketing
`graduate_start` / `graduate_complete` / `graduate_rollback` rows.""" `graduate_start` / `graduate_complete` / `graduate_failed` rows."""
db.conn().execute( db.conn().execute(
""" """
INSERT INTO actions INSERT INTO actions
@@ -935,8 +703,7 @@ def _audit(
json.dumps(details) if details else None, json.dumps(details) if details else None,
), ),
) )
# §15 chokepoint per Slice 6: the bracket rows (graduate_start, # §15 chokepoint: the bracket rows drive their own notifications.
# graduate_complete) drive their own notifications per §15.1.
from . import notify from . import notify
notify.fan_out_from_action( notify.fan_out_from_action(
actor_user_id=actor_user_id, actor_user_id=actor_user_id,
+15 -11
View File
@@ -603,7 +603,7 @@ def make_router(
repo=repo, repo=repo,
slug=slug, slug=slug,
file_path=_file_path_for(rfc), file_path=_file_path_for(rfc),
is_super_draft=_is_super_draft(rfc), is_super_draft=_is_meta_resident(rfc),
original_branch=original_branch, original_branch=original_branch,
resolution_branch=resolution_branch, resolution_branch=resolution_branch,
) )
@@ -671,32 +671,36 @@ def make_router(
"""Used by the §10 PR-flow read and write paths. Per §17's routing- """Used by the §10 PR-flow read and write paths. Per §17's routing-
collapse rule, a super-draft RFC also routes here its body-edit collapse rule, a super-draft RFC also routes here its body-edit
PRs are meta-repo PRs with pr_kind='meta_body_edit', but the API PRs are meta-repo PRs with pr_kind='meta_body_edit', but the API
surface is identical.""" surface is identical. Under the meta-only topology (§1) an active
RFC is meta-resident too (repo is null) that is normal, not an
error, so there is no per-RFC-repo precondition."""
row = _require_rfc(slug) row = _require_rfc(slug)
if row["state"] not in ("active", "super-draft"): if row["state"] not in ("active", "super-draft"):
raise HTTPException(409, f"RFC is {row['state']}") raise HTTPException(409, f"RFC is {row['state']}")
if row["state"] == "active" and not row["repo"]:
raise HTTPException(409, "RFC has no repo")
return row return row
def _is_super_draft(rfc) -> bool: def _is_meta_resident(rfc) -> bool:
return rfc["state"] == "super-draft" """Meta-only topology (§1): an entry lives in the meta repo's
`rfcs/<slug>.md` (super-draft or active-in-place) unless it carries
a legacy per-RFC `repo:` which nothing does after the RFC-0001
fold-back (§13.6). Drives the body/path/repo dispatch below."""
return not rfc["repo"]
def _owner_repo(rfc) -> tuple[str, str]: def _owner_repo(rfc) -> tuple[str, str]:
if _is_super_draft(rfc): if _is_meta_resident(rfc):
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) -> str:
if _is_super_draft(rfc): if _is_meta_resident(rfc):
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) -> str:
"""For super-draft entries the file on disk is the full """For meta-resident entries the file on disk is the full
frontmatter+body envelope; the editable body is entry.body.""" frontmatter+body envelope; the editable body is entry.body."""
if not _is_super_draft(rfc): if not _is_meta_resident(rfc):
return file_contents return file_contents
try: try:
entry = entry_mod.parse(file_contents) entry = entry_mod.parse(file_contents)
@@ -760,7 +764,7 @@ def make_router(
return row["original_pr_number"] if row else None return row["original_pr_number"] if row else None
async def _refresh_after_pr_write(rfc) -> None: async def _refresh_after_pr_write(rfc) -> None:
if _is_super_draft(rfc): if _is_meta_resident(rfc):
await cache.refresh_meta_repo(config, gitea) await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea) await cache.refresh_meta_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea) await cache.refresh_meta_pulls(config, gitea)
+13 -136
View File
@@ -695,111 +695,7 @@ class Bot:
) )
return sha return sha
# ----- §13 graduation: per-step primitives and rollback inverses ----- # ----- §13 graduation (meta-only): open + merge the flip PR -----
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( async def open_graduation_pr(
self, self,
@@ -811,13 +707,14 @@ class Bot:
new_file_contents: str, new_file_contents: str,
prior_sha: str, prior_sha: str,
rfc_id: str, rfc_id: str,
repo_full: str,
owners: list[str], owners: list[str],
) -> dict: ) -> dict:
"""§13.3 step 3: open a PR against the meta repo that strips the """§13.3 (meta-only): open a PR against the meta repo that flips the
super-draft body and fills graduation frontmatter fields. Branch entry's frontmatter to `state: active` with the integer `id` and
name uses the `graduate-<slug>-<6hex>` shape dash-separated like graduation stamps **keeping the body unchanged** (§1 meta-only
the other meta-repo branches per the §19.2 path-routing candidate. topology; no repo is created and no body is stripped). 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 import secrets
@@ -844,11 +741,11 @@ class Bot:
pr_body_text = ( pr_body_text = (
f"Graduates super-draft `{slug}` to active.\n\n" f"Graduates super-draft `{slug}` to active.\n\n"
f"- ID: `{rfc_id}`\n" f"- ID: `{rfc_id}`\n"
f"- Repo: `{repo_full}`\n"
f"- Owners: {owners_str}\n\n" f"- Owners: {owners_str}\n\n"
f"The meta-repo entry becomes frontmatter-only; the canonical body\n" f"This is an in-place state flip per the meta-only topology\n"
f"moves to `RFC.md` in the new repo. The graduation sequence is\n" f"(SPEC §1, §13.3): the entry `rfcs/{slug}.md` keeps its body and\n"
f"transactional per §13.3." f"stays in the meta repo. Only the frontmatter changes — `state`,\n"
f"`id`, and the graduation stamps."
) )
_subject, pr_body = _stamp("", pr_body_text, actor) _subject, pr_body = _stamp("", pr_body_text, actor)
pr = await self._gitea.create_pull( pr = await self._gitea.create_pull(
@@ -862,7 +759,7 @@ class Bot:
branch_name=branch, branch_name=branch,
pr_number=pr["number"], pr_number=pr["number"],
bot_commit_sha=commit_sha, bot_commit_sha=commit_sha,
details={"pr_title": pr_title, "rfc_id": rfc_id, "repo": repo_full}, details={"pr_title": pr_title, "rfc_id": rfc_id},
) )
return pr return pr
@@ -912,27 +809,7 @@ class Bot:
details={"rfc_id": rfc_id}, details={"rfc_id": rfc_id},
) )
# ----- §13.3 rollback inverses ----- # ----- §13.3 (meta-only): cleanup of an unmerged flip PR -----
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( async def close_graduation_pr(
self, self,
+10 -4
View File
@@ -342,9 +342,13 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
if not slug: if not slug:
continue continue
rfc = db.conn().execute( rfc = db.conn().execute(
"SELECT state FROM cached_rfcs WHERE slug = ?", (slug,) "SELECT state, repo FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone() ).fetchone()
if not rfc or rfc["state"] != "super-draft": # Meta-only topology (§1): edit branches live on the meta repo for
# every meta-resident entry — super-drafts and active RFCs alike
# (active RFCs are graduated in place and keep editing here, §13).
# A legacy per-RFC repo (repo set) is the only thing excluded.
if not rfc or rfc["repo"] or rfc["state"] not in ("super-draft", "active"):
continue continue
edit_keys_seen.add((slug, name)) edit_keys_seen.add((slug, name))
db.conn().execute( db.conn().execute(
@@ -365,7 +369,8 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
# diverges from this single point. # diverges from this single point.
if meta_main_sha: if meta_main_sha:
super_drafts = db.conn().execute( super_drafts = db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE state = 'super-draft'" "SELECT slug FROM cached_rfcs "
"WHERE repo IS NULL AND state IN ('super-draft', 'active')"
).fetchall() ).fetchall()
for r in super_drafts: for r in super_drafts:
db.conn().execute( db.conn().execute(
@@ -387,7 +392,8 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
SELECT b.rfc_slug, b.branch_name SELECT b.rfc_slug, b.branch_name
FROM cached_branches b FROM cached_branches b
JOIN cached_rfcs r ON r.slug = b.rfc_slug JOIN cached_rfcs r ON r.slug = b.rfc_slug
WHERE r.state = 'super-draft' WHERE r.repo IS NULL
AND r.state IN ('super-draft', 'active')
AND b.state != 'deleted' AND b.state != 'deleted'
AND b.branch_name != 'main' AND b.branch_name != 'main'
""" """
+8 -6
View File
@@ -284,9 +284,10 @@ async def _delete_branch_via_bot(
reason: str, reason: str,
) -> bool: ) -> bool:
"""Call `bot.delete_branch` with the system actor. Resolves the """Call `bot.delete_branch` with the system actor. Resolves the
`(org, repo)` pair from the slug: super-draft edit branches and `(org, repo)` pair from the slug: under the meta-only topology (§1)
graduation branches live on the meta repo; active-RFC branches every meta-resident entry's edit branches and graduation branches
live on the per-RFC repo named by `cached_rfcs.repo`. live on the meta repo; a legacy per-RFC repo (a `repo:` that survives
from before the fold-back, §13.6) is named by `cached_rfcs.repo`.
Returns True on a clean delete; False if the rfc row is missing Returns True on a clean delete; False if the rfc row is missing
(we leave the branch row in place a subsequent reconciler sweep (we leave the branch row in place a subsequent reconciler sweep
@@ -297,12 +298,13 @@ async def _delete_branch_via_bot(
if rfc is None: if rfc is None:
log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch) log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch)
return False return False
if rfc["state"] == "super-draft": if not rfc["repo"]:
owner, repo = config.gitea_org, config.meta_repo owner, repo = config.gitea_org, config.meta_repo
elif rfc["state"] == "active" and rfc["repo"] and "/" in rfc["repo"]: elif "/" in rfc["repo"]:
owner, repo = rfc["repo"].split("/", 1) owner, repo = rfc["repo"].split("/", 1)
else: else:
log.warning("hygiene: cannot resolve repo for %s state=%s", slug, rfc["state"]) log.warning("hygiene: cannot resolve repo for %s state=%s repo=%r",
slug, rfc["state"], rfc["repo"])
return False return False
try: try:
await bot.delete_branch( await bot.delete_branch(
+8 -7
View File
@@ -119,19 +119,20 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
r = client.post(f"/api/rfcs/ohm/prs/{body_pr}/merge") r = client.post(f"/api/rfcs/ohm/prs/{body_pr}/merge")
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
# --- 7. Graduate the super-draft. --- # --- 7. Graduate the super-draft (in-place flip, §13). ---
r = client.post( r = client.post(
"/api/rfcs/ohm/graduate?_sync=1", "/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm", json={"rfc_id": "RFC-0001", "owners": ["ben"]},
"owners": ["ben"]},
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert r.json()["succeeded"] is True assert r.json()["succeeded"] is True
d = client.get("/api/rfcs/ohm").json() d = client.get("/api/rfcs/ohm").json()
assert d["state"] == "active" assert d["state"] == "active"
assert d["repo"] == "wiggleverse/rfc-0001-ohm" # Meta-only topology (§1): no per-RFC repo — the active RFC lives
# in its meta entry, `repo` stays null.
assert d["repo"] is None
# --- 8. Alice opens a PR on the now-active RFC's per-RFC repo. --- # --- 8. Alice opens a PR on the now-active RFC (meta repo). ---
# v0.16.0 (item #12): ben is the RFC owner now; alice needs a # v0.16.0 (item #12): ben is the RFC owner now; alice needs a
# per-RFC contributor invitation to cut a branch. In the # per-RFC contributor invitation to cut a branch. In the
# production flow, ben would invite her via /invitations and # production flow, ben would invite her via /invitations and
@@ -200,8 +201,8 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
) )
assert counters["deleted_post_merge"] >= 1, counters assert counters["deleted_post_merge"] >= 1, counters
# The branch is gone from FakeGitea + cached row flipped. # The branch is gone from FakeGitea (meta repo) + cached row flipped.
assert active_branch not in fake.branches[("wiggleverse", "rfc-0001-ohm")] assert active_branch not in fake.branches[("wiggleverse", "meta")]
cached = db.conn().execute( cached = db.conn().execute(
"SELECT state FROM cached_branches WHERE rfc_slug = 'ohm' AND branch_name = ?", "SELECT state FROM cached_branches WHERE rfc_slug = 'ohm' AND branch_name = ?",
(active_branch,), (active_branch,),
+171 -198
View File
@@ -1,29 +1,29 @@
"""End-to-end integration tests for the Slice 5 vertical (§13 in full). """End-to-end integration tests for the §13 graduation flow under the
meta-only topology (SPEC §1, ROADMAP #36).
Walks the §13.3 transactional sequence end-to-end against the in-process Graduation is an in-place state flip on the meta entry no per-RFC repo
FakeGitea from test_propose_vertical.py: is created, the body is kept, and there is no multi-step transaction or
rollback (§13.3). These tests walk it against the in-process FakeGitea
from test_propose_vertical.py:
* Seed an owned super-draft (skipping the propose+merge + §13.1 claim * Seed an owned super-draft (the §13.1 claim flow is exercised
round-trips already proven by Slice 1 and exercised in separately in test_claim_opens_meta_pr).
test_claim_opens_meta_pr below for the §13.1 surface itself).
* GET /api/rfcs/<slug>/graduate/check returns per-field validity for * GET /api/rfcs/<slug>/graduate/check returns per-field validity for
the dialog. the two-field dialog (integer id + owners; no repo name).
* GET /api/rfcs/<slug>/blocking-prs returns the §9.8 precondition list. * POST /api/rfcs/<slug>/graduate?_sync=1 opens + merges the flip PR
* POST /api/rfcs/<slug>/graduate?_sync=1 runs the five-step sequence inline. On success: NO per-RFC repo, the meta entry is `state:
inline. On success: per-RFC repo exists with RFC.md / README.md / active` with the body KEPT and `repo` null, cached_rfcs.state flips
.rfc/metadata.yaml, meta-entry body is stripped, frontmatter is to 'active'.
graduated, cached_rfcs.state is 'active'. * An open body-edit PR no longer blocks graduation (§9.8) they
* §9.8 precondition gate refuses the start when a body-edit PR is open. coexist.
* Rollback on a mid-sequence failure unwinds repo creation cleanly. * An open-PR failure leaves the entry a super-draft (nothing created);
* §13.4 chat migration: whole-doc threads under (slug, 'main') survive a merge failure cleans up the half-open PR/branch and leaves the
graduation unchanged the rfc_slug is the canonical key per §2.3, entry a super-draft.
so no data movement is needed. * §13.4: chat threads + edit branches stay put the slug is the
* §9.8 pre-graduation history: the new RFC's /main response surfaces canonical key per §2.3, so nothing moves at the flip.
edit-branch threads under `pre_graduation_history`.
The orchestrator's `?_sync=1` seam awaits the sequence inline so the The orchestrator's `?_sync=1` seam awaits the flip inline so the test can
test can assert post-conditions on the same event loop tick. Production assert post-conditions on the same event loop tick.
clients use the spec-described SSE shape via `/graduate/progress`.
""" """
from __future__ import annotations from __future__ import annotations
@@ -110,7 +110,9 @@ def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_graduate_check_validates_three_fields(app_with_fake_gitea): def test_graduate_check_validates_id_and_owners(app_with_fake_gitea):
"""Two-field dialog under meta-only: integer id + owners. No repo
name to validate (§13.2)."""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea app, fake = app_with_fake_gitea
@@ -121,57 +123,46 @@ def test_graduate_check_validates_three_fields(app_with_fake_gitea):
sign_in_as(client, user_id=1, gitea_login="ben", sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner") display_name="Ben", role="owner")
# Happy: a fresh RFC-0001 + rfc-0001-ohm repo name. # Happy: a fresh RFC-0001.
r = client.get("/api/rfcs/ohm/graduate/check", r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
d = r.json() d = r.json()
assert d["id"]["ok"] is True assert d["id"]["ok"] is True
assert d["repo"]["ok"] is True
assert d["owners"]["ok"] is True assert d["owners"]["ok"] is True
assert d["blocking_prs"]["ok"] is True
assert d["can_submit"] is True assert d["can_submit"] is True
# No repo field in the meta-only check response.
assert "repo" not in d
# ID format error — non-numeric tail. # ID format error — non-numeric tail.
r = client.get("/api/rfcs/ohm/graduate/check", r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-abcd"})
params={"id": "RFC-abcd", "repo": "rfc-0001-ohm"})
d = r.json() d = r.json()
assert d["id"]["ok"] is False assert d["id"]["ok"] is False
assert d["can_submit"] 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): def test_graduate_check_refuses_when_no_owners(app_with_fake_gitea):
"""An unclaimed super-draft fails the owners precondition; can_submit """An unclaimed super-draft fails the owners precondition; can_submit
flips false even with valid id+repo.""" flips false even with a valid id."""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea app, fake = app_with_fake_gitea
with TestClient(app) as client: with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner") 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=[]) seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=[])
sign_in_as(client, user_id=1, gitea_login="ben", sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner") display_name="Ben", role="owner")
r = client.get("/api/rfcs/ohm/graduate/check", r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
d = r.json() d = r.json()
assert d["owners"]["ok"] is False assert d["owners"]["ok"] is False
assert "No owners" in d["owners"]["error"] assert "No owners" in d["owners"]["error"]
assert d["can_submit"] is False assert d["can_submit"] is False
def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea): def test_graduate_happy_path_flips_in_place_keeping_body(app_with_fake_gitea):
"""The full §13.3 sequence: create repo, seed files, open PR, merge """The meta-only flip: open + merge a frontmatter PR. End state:
PR, refresh cache. End state: cached_rfcs.state='active', the meta cached_rfcs.state='active', the meta entry's body is KEPT, `repo` is
entry's body is stripped, the per-RFC repo has RFC.md, the audit null, NO per-RFC repo exists, and the audit log carries graduate_start
log carries graduate_start graduate_complete bracketing the graduate_pr_open graduate_pr_merge graduate_complete."""
per-step rows."""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app import db, entry as entry_mod from app import db, entry as entry_mod
@@ -186,60 +177,57 @@ def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea
r = client.post( r = client.post(
"/api/rfcs/ohm/graduate?_sync=1", "/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0042", "repo_name": "rfc-0042-ohm", json={"rfc_id": "RFC-0042", "owners": ["ben"]},
"owners": ["ben"]},
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
d = r.json() d = r.json()
assert d["finished"] is True assert d["finished"] is True
assert d["succeeded"] is True assert d["succeeded"] is True
assert d["repo"] == "wiggleverse/rfc-0042-ohm" # No repo in the response, no per-RFC repo on Gitea.
assert "repo" not in d
assert ("wiggleverse", "rfc-0042-ohm") not in fake.repos
assert not any(
k[1].startswith("rfc-0042") for k in fake.repos
), f"a per-RFC repo was created: {fake.repos}"
# 1. Per-RFC repo exists on Gitea. # Meta entry on main: state flipped, body KEPT, repo null.
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"] meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
graduated = entry_mod.parse(meta_text) graduated = entry_mod.parse(meta_text)
assert graduated.state == "active" assert graduated.state == "active"
assert graduated.id == "RFC-0042" assert graduated.id == "RFC-0042"
assert graduated.repo == "wiggleverse/rfc-0042-ohm" assert graduated.repo is None
assert graduated.graduated_by == "ben" assert graduated.graduated_by == "ben"
assert graduated.graduated_at # non-empty ISO date assert graduated.graduated_at # non-empty ISO date
assert graduated.body.strip() == "" assert "Open Human Model is a framework" in graduated.body
# 5. cached_rfcs.state flipped to active via the inline refresh.
# cached_rfcs flipped to active via the inline refresh; body intact.
cached = db.conn().execute( cached = db.conn().execute(
"SELECT state, rfc_id, repo, body FROM cached_rfcs WHERE slug = 'ohm'" "SELECT state, rfc_id, repo, body FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone() ).fetchone()
assert cached["state"] == "active" assert cached["state"] == "active"
assert cached["rfc_id"] == "RFC-0042" assert cached["rfc_id"] == "RFC-0042"
assert cached["repo"] == "wiggleverse/rfc-0042-ohm" assert cached["repo"] is None
# cached body now mirrors RFC.md from the per-RFC repo.
assert "Open Human Model is a framework" in cached["body"] 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 = [ kinds = [
r["action_kind"] row["action_kind"]
for r in db.conn().execute( for row in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id" "SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
) )
] ]
for needed in ("graduate_start", "graduate_repo_create", for needed in ("graduate_start", "graduate_pr_open",
"graduate_repo_seed", "graduate_pr_open",
"graduate_pr_merge", "graduate_complete"): "graduate_pr_merge", "graduate_complete"):
assert needed in kinds, f"missing audit row {needed}: {kinds}" assert needed in kinds, f"missing audit row {needed}: {kinds}"
# The retired per-repo steps must NOT appear.
for gone in ("graduate_repo_create", "graduate_repo_seed",
"graduate_repo_delete", "graduate_rollback"):
assert gone not in kinds, f"retired audit row present: {gone}"
def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea): def test_graduate_coexists_with_open_body_edit_pr(app_with_fake_gitea):
"""§9.8: an open meta-repo body-edit PR against rfcs/<slug>.md blocks """§9.8 (meta-only): an open meta-repo body-edit PR no longer blocks
graduation before the bot starts the sequence §13.3's rollback graduation the body is kept, so they coexist. /check stays
complexity does not grow.""" submittable and the flip succeeds."""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app import db from app import db
@@ -249,13 +237,11 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
provision_user_row(user_id=2, login="alice", role="contributor") provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM", seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"]) pitch=PITCH, owners=["ben"])
# v0.16.0 (item #12): ben is the RFC owner; alice needs a per-RFC
# contributor invitation to cut an edit branch on the super-draft.
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor") grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor") display_name="Alice", role="contributor")
# Cut an edit branch and open a body-edit PR (full Slice 4 path). # Cut an edit branch and open a body-edit PR.
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"] branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json() view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
thread_id = view["main_thread_id"] thread_id = view["main_thread_id"]
@@ -279,94 +265,31 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
f"/api/rfcs/ohm/branches/{branch}/open-pr", f"/api/rfcs/ohm/branches/{branch}/open-pr",
json={"title": "Add harm", "description": "Adds harm dimension."}, json={"title": "Add harm", "description": "Adds harm dimension."},
).json()["pr_number"] ).json()["pr_number"]
assert pr_number # PR is open
# /blocking-prs surfaces it. # /check stays submittable despite the open body-edit PR.
sign_in_as(client, user_id=1, gitea_login="ben", sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner") display_name="Ben", role="owner")
r = client.get("/api/rfcs/ohm/blocking-prs") d = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"}).json()
items = r.json()["items"] assert "blocking_prs" not in d
assert len(items) == 1 assert d["can_submit"] is True
assert items[0]["pr_number"] == pr_number
# /check refuses can_submit. # The flip succeeds — coexists with the open body-edit PR.
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( r = client.post(
"/api/rfcs/ohm/graduate?_sync=1", "/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm", json={"rfc_id": "RFC-0001", "owners": ["ben"]},
"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 assert r.status_code == 200, r.text
d = r.json() assert r.json()["succeeded"] is True
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( cached = db.conn().execute(
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'" "SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone() ).fetchone()
assert cached["state"] == "super-draft" assert cached["state"] == "active"
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): def test_graduate_open_pr_failure_leaves_super_draft(app_with_fake_gitea):
"""Step 3 (open PR) fails → the orchestrator rolls back steps 2 and """An open-PR failure creates nothing — the entry stays a super-draft
1 (deleting the repo, which reclaims the seed commits at the same with its body intact and no graduation PR on the meta repo."""
time). The meta-repo entry is untouched."""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app import db from app import db
from app.bot import Bot from app.bot import Bot
@@ -387,19 +310,92 @@ def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
try: try:
r = client.post( r = client.post(
"/api/rfcs/ohm/graduate?_sync=1", "/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0007", "repo_name": "rfc-0007-ohm", json={"rfc_id": "RFC-0007", "owners": ["ben"]},
"owners": ["ben"]},
) )
finally: finally:
Bot.open_graduation_pr = orig_open_pr Bot.open_graduation_pr = orig_open_pr
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert r.json()["succeeded"] is False assert r.json()["succeeded"] is False
# Repo torn down.
assert ("wiggleverse", "rfc-0007-ohm") not in fake.repos # Entry untouched: still super-draft, body intact on main.
# Meta entry's body still has the pitch (not stripped). 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
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"] meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
assert "Open Human Model is a framework" in meta_text assert "Open Human Model is a framework" in meta_text
kinds = [
row["action_kind"]
for row in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
assert "graduate_start" in kinds
assert "graduate_failed" in kinds
assert "graduate_complete" not in kinds
def test_graduate_merge_failure_cleans_up_pr(app_with_fake_gitea):
"""A merge failure leaves the flip PR open on its dash-suffixed
branch; the orchestrator closes the PR and deletes the branch so
failed attempts don't accumulate. The entry stays a super-draft —
the flip PR's commit was on a branch, not on main."""
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_merge = Bot.merge_graduation_pr
async def boom(self, *args, **kwargs):
raise GiteaError(502, "simulated merge failure")
Bot.merge_graduation_pr = boom
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0009", "owners": ["ben"]},
)
finally:
Bot.merge_graduation_pr = orig_merge
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is False
# Entry stays super-draft on main (the flip never merged).
cached = db.conn().execute(
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "super-draft"
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
assert "state: super-draft" in meta_text
# The dash-suffixed graduation branch was cleaned up.
grad_branches = [
name for (o, repo), branches in fake.branches.items()
if (o, repo) == ("wiggleverse", "meta")
for name in branches
if name.startswith("graduate-ohm-")
]
assert grad_branches == [], f"leftover graduation branch: {grad_branches}"
kinds = [
row["action_kind"]
for row in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
assert "graduate_pr_open" in kinds
assert "graduate_failed" in kinds
assert "graduate_complete" not in kinds
def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea): def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
"""A second graduation request for a slug already in-flight is refused.""" """A second graduation request for a slug already in-flight is refused."""
@@ -414,17 +410,14 @@ def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
sign_in_as(client, user_id=1, gitea_login="ben", sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner") display_name="Ben", role="owner")
# Seed a synthetic in-flight state so the registry refuses the second.
st = api_graduation._new_active( st = api_graduation._new_active(
"ohm", rfc_id="RFC-0001", repo_name="rfc-0001-ohm", "ohm", rfc_id="RFC-0001", owners=["ben"], arbiters=["ben"],
repo_full="wiggleverse/rfc-0001-ohm", owners=["ben"], arbiters=["ben"],
) )
st.finished = False st.finished = False
try: try:
r = client.post( r = client.post(
"/api/rfcs/ohm/graduate?_sync=1", "/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm", json={"rfc_id": "RFC-0001", "owners": ["ben"]},
"owners": ["ben"]},
) )
assert r.status_code == 409 assert r.status_code == 409
finally: finally:
@@ -432,11 +425,9 @@ def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_gitea): 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 """§13.4: chat threads on the entry's main view (`branch_name='main'`)
(`branch_name='main'`) are interpreted as the new RFC's main-thread stay put across the flip the rfc_slug is canonical per §2.3 so the
after graduation. The rows don't move — the rfc_slug is canonical same thread surfaces from /branches/main before and after graduation."""
per §2.3 so the same thread surfaces from both before and after
the graduation."""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app import db from app import db
@@ -448,9 +439,6 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git
sign_in_as(client, user_id=1, gitea_login="ben", sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner") 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( cur = db.conn().execute(
""" """
INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, created_by) INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, created_by)
@@ -466,32 +454,26 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git
(thread_id,), (thread_id,),
) )
# Graduate.
r = client.post( r = client.post(
"/api/rfcs/ohm/graduate?_sync=1", "/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm", json={"rfc_id": "RFC-0099", "owners": ["ben"]},
"owners": ["ben"]},
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
# The thread row's identity is unchanged.
row = db.conn().execute( row = db.conn().execute(
"SELECT id, branch_name FROM threads WHERE id = ?", (thread_id,), "SELECT id, branch_name FROM threads WHERE id = ?", (thread_id,),
).fetchone() ).fetchone()
assert row["branch_name"] == "main" 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") r = client.get("/api/rfcs/ohm/branches/main")
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert r.json()["main_thread_id"] == thread_id assert r.json()["main_thread_id"] == thread_id
def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea): def test_edit_branch_surfaces_normally_after_graduation(app_with_fake_gitea):
"""§9.8: after graduation, threads on meta-repo edit branches stay """§13.4 (meta-only): after graduation an edit branch is a *current*
attached to their original branch_name and surface from the new branch of the now-active RFC it surfaces in the normal `branches`
RFC's /main response under `pre_graduation_history`.""" list, and there is no separate `pre_graduation_history` set (that
affordance is legacy per-repo only)."""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app import db from app import db
@@ -501,10 +483,8 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
provision_user_row(user_id=2, login="alice", role="contributor") provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM", seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"]) pitch=PITCH, owners=["ben"])
# v0.16.0 (item #12): alice needs per-RFC contributor access.
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor") grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
# Alice cuts an edit branch and starts chatting on it.
sign_in_as(client, user_id=2, gitea_login="alice", sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor") display_name="Alice", role="contributor")
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"] branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
@@ -513,30 +493,25 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
db.conn().execute( db.conn().execute(
""" """
INSERT INTO thread_messages (thread_id, role, author_user_id, text) INSERT INTO thread_messages (thread_id, role, author_user_id, text)
VALUES (?, 'user', 2, 'pre-graduation note on an edit branch') VALUES (?, 'user', 2, 'note on an edit branch')
""", """,
(thread_id,), (thread_id,),
) )
# Ben graduates.
sign_in_as(client, user_id=1, gitea_login="ben", sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner") display_name="Ben", role="owner")
r = client.post( r = client.post(
"/api/rfcs/ohm/graduate?_sync=1", "/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0100", "repo_name": "rfc-0100-ohm", json={"rfc_id": "RFC-0100", "owners": ["ben"]},
"owners": ["ben"]},
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
# /main on the now-active RFC surfaces the pre-graduation history. d = client.get("/api/rfcs/ohm/main").json()
r = client.get("/api/rfcs/ohm/main")
d = r.json()
assert d["state"] == "active" assert d["state"] == "active"
hist = d["pre_graduation_history"] # The edit branch is a current branch; no pre-graduation hop.
assert len(hist) >= 1 assert d["pre_graduation_history"] == []
assert any(h["branch_name"] == branch for h in hist) assert any(b["name"] == branch for b in d["branches"]), \
target = next(h for h in hist if h["branch_name"] == branch) f"edit branch not in branches: {[b['name'] for b in d['branches']]}"
assert target["message_count"] >= 1
def test_claim_opens_meta_pr(app_with_fake_gitea): def test_claim_opens_meta_pr(app_with_fake_gitea):
@@ -560,12 +535,10 @@ def test_claim_opens_meta_pr(app_with_fake_gitea):
d = r.json() d = r.json()
assert d["branch_name"] == "claim/ohm" 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"] text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"]
ent = entry_mod.parse(text) ent = entry_mod.parse(text)
assert "alice" in ent.owners assert "alice" in ent.owners
# cached_prs records pr_kind='meta_claim' via refresh_meta_pulls.
row = db.conn().execute( row = db.conn().execute(
"SELECT pr_kind FROM cached_prs WHERE pr_number = ?", (d["pr_number"],), "SELECT pr_kind FROM cached_prs WHERE pr_number = ?", (d["pr_number"],),
).fetchone() ).fetchone()
+9 -10
View File
@@ -339,10 +339,10 @@ def test_hygiene_action_kinds_fire_no_notifications(app_with_fake_gitea):
def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea): def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea):
"""§19.2 candidate Slice 8 settles: when graduation rolls back """Meta-only (§13.3): when the flip's merge fails after the PR is
after step 3 (open_pr), the `graduate-<slug>-<6hex>` branch is open, the orchestrator closes the PR and deletes its
deleted alongside the PR close so failed-graduation branches `graduate-<slug>-<6hex>` branch so failed attempts don't accumulate
don't accumulate on the meta repo across retries.""" on the meta repo across retries."""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app import db from app import db
from app.bot import Bot from app.bot import Bot
@@ -359,24 +359,23 @@ def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea):
sign_in_as(client, user_id=1, gitea_login="ben", sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner") display_name="Ben", role="owner")
# Force a step-4 (merge_pr) failure so step 3 (open_pr) has # Force a merge_pr failure so the flip PR (open_pr) has already
# already landed and the rollback exercises the branch cleanup. # landed and the cleanup exercises the branch deletion.
orig_merge = Bot.merge_graduation_pr orig_merge = Bot.merge_graduation_pr
async def boom(self, *args, **kwargs): async def boom(self, *args, **kwargs):
raise GiteaError(502, "simulated merge failure for rollback test") raise GiteaError(502, "simulated merge failure for cleanup test")
Bot.merge_graduation_pr = boom Bot.merge_graduation_pr = boom
try: try:
r = client.post( r = client.post(
"/api/rfcs/ohm/graduate?_sync=1", "/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm", json={"rfc_id": "RFC-0099", "owners": ["ben"]},
"owners": ["ben"]},
) )
finally: finally:
Bot.merge_graduation_pr = orig_merge Bot.merge_graduation_pr = orig_merge
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert r.json()["succeeded"] is False assert r.json()["succeeded"] is False
# The dash-suffixed graduation branch was deleted on rollback. # The dash-suffixed graduation branch was deleted on cleanup.
meta_branches = fake.branches[("wiggleverse", "meta")] meta_branches = fake.branches[("wiggleverse", "meta")]
graduation_branches = [n for n in meta_branches if n.startswith("graduate-ohm-")] graduation_branches = [n for n in meta_branches if n.startswith("graduate-ohm-")]
assert graduation_branches == [], ( assert graduation_branches == [], (
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "rfc-app-frontend", "name": "rfc-app-frontend",
"private": true, "private": true,
"version": "0.30.1", "version": "0.31.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+67 -11
View File
@@ -189,9 +189,27 @@
padding: 32px 48px; padding: 32px 48px;
} }
.welcome { max-width: 640px; } /* `.main-pane` is the §8 flex shell and carries no padding (the bare
.welcome h1 { font-size: var(--text-2xl); font-weight: 600; margin: 0 0 16px; } `.main-pane` override below shadows the padded read-view rule), so the
.welcome p { line-height: 1.7; color: var(--c-gray-600); } welcome surface owns its own breathing room top offset, comfortable
side gutters, and a capped measure for readable line length. */
.welcome {
max-width: 680px;
padding: 56px 48px 64px;
}
.welcome h1 {
font-size: var(--text-3xl); font-weight: 600;
letter-spacing: -0.01em;
margin: 0 0 var(--space-8);
}
.welcome p {
font-size: var(--text-lg);
line-height: var(--leading-relaxed);
color: var(--c-gray-600);
margin: 0 0 var(--space-8);
}
.welcome p:last-child { margin-bottom: 0; }
.welcome strong { color: var(--c-gray-800); }
/* --- RFC / Proposal view (read-only for slice 1) --- */ /* --- RFC / Proposal view (read-only for slice 1) --- */
@@ -542,7 +560,10 @@
font-size: var(--text-md); font-weight: 600; text-decoration: none; font-size: var(--text-md); font-weight: 600; text-decoration: none;
} }
.beta-pending-actions .btn-primary:hover { background: var(--c-gray-700); } .beta-pending-actions .btn-primary:hover { background: var(--c-gray-700); }
.btn-link-quiet { color: var(--c-gray-500); text-decoration: none; font-size: var(--text-base); } .btn-link-quiet {
background: none; border: none; padding: 0; cursor: pointer;
color: var(--c-gray-500); text-decoration: none; font-size: var(--text-base);
}
.btn-link-quiet:hover { color: var(--c-ink); text-decoration: underline; } .btn-link-quiet:hover { color: var(--c-ink); text-decoration: underline; }
/* v0.8.0 thin "your beta access is in review" banner. Shown on every /* v0.8.0 thin "your beta access is in review" banner. Shown on every
@@ -1607,12 +1628,18 @@
/* ---- §15 / Slice 6: inbox, badge, toasts ---- */ /* ---- §15 / Slice 6: inbox, badge, toasts ---- */
/* Lives on the dark header so it speaks the nav-link vocabulary
(.header-about et al.): borderless, gray-300 icon brightening to white
on a faint translucent-white hover. The old light-gray border + gray-50
hover were styled for a light surface and rendered as a pale box that
went white-on-white (invisible icon) on hover. */
.inbox-trigger { .inbox-trigger {
position: relative; background: transparent; border: 1px solid var(--c-gray-200); position: relative; display: inline-flex; align-items: center; justify-content: center;
border-radius: var(--radius-md); padding: 4px 10px; cursor: pointer; font-size: var(--text-lg); background: transparent; border: none;
margin-right: 12px; color: var(--c-gray-300); cursor: pointer;
padding: 5px 8px; border-radius: var(--radius-sm);
} }
.inbox-trigger:hover { background: var(--c-gray-50); } .inbox-trigger:hover { color: var(--c-white); background: rgba(255,255,255,0.08); }
.inbox-trigger .badge { .inbox-trigger .badge {
position: absolute; top: -6px; right: -6px; position: absolute; top: -6px; right: -6px;
background: #dc2626; color: white; font-size: var(--text-2xs); background: #dc2626; color: white; font-size: var(--text-2xs);
@@ -1856,7 +1883,7 @@
} }
.settings-table th, .admin-table th { .settings-table th, .admin-table th {
text-align: left; padding: 6px 8px; text-align: left; padding: 6px 8px;
font-size: var(--text-xs); text-transform: uppercase; font-size: var(--text-xs); text-transform: uppercase; white-space: nowrap;
color: var(--c-gray-500); letter-spacing: 0.05em; font-weight: 600; color: var(--c-gray-500); letter-spacing: 0.05em; font-weight: 600;
border-bottom: 1px solid var(--c-gray-200); border-bottom: 1px solid var(--c-gray-200);
} }
@@ -1937,7 +1964,21 @@
.admin-tab-header h2 { .admin-tab-header h2 {
margin: 0 0 4px; font-size: var(--text-xl); font-weight: 700; margin: 0 0 4px; font-size: var(--text-xl); font-weight: 700;
} }
.admin-tab-header p { margin: 0 0 24px; font-size: var(--text-base); } .admin-tab-header p { margin: 0 0 24px; font-size: var(--text-base); max-width: 70ch; line-height: var(--leading-normal); }
/* Title row: heading on the left, primary action flush right. */
.admin-tab-heading {
display: flex; align-items: flex-start; justify-content: space-between;
gap: var(--space-7); margin-bottom: var(--space-2);
}
.admin-tab-heading h2 { margin: 0; }
.admin-tab-actions { flex-shrink: 0; }
/* Inline DB-column references in admin copy read as quiet chips, not raw
monospace runs jammed against the sans body. */
.admin-tab-header code {
font-family: var(--font-mono); font-size: var(--text-sm);
background: var(--c-gray-100); color: var(--c-gray-700);
padding: 1px 5px; border-radius: var(--radius-sm);
}
.admin-section-h { .admin-section-h {
font-size: var(--text-base); text-transform: uppercase; font-size: var(--text-base); text-transform: uppercase;
letter-spacing: 0.05em; color: var(--c-gray-500); letter-spacing: 0.05em; color: var(--c-gray-500);
@@ -1977,8 +2018,23 @@
.allowlist-add .btn-primary:hover:not(:disabled) { background: var(--c-gray-700); } .allowlist-add .btn-primary:hover:not(:disabled) { background: var(--c-gray-700); }
.allowlist-add .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } .allowlist-add .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.user-cell { display: flex; flex-direction: column; gap: 1px; } .user-cell { display: flex; flex-direction: column; gap: 2px; }
.user-cell-handle { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.user-handle { font-weight: 500; color: var(--c-gray-900); } .user-handle { font-weight: 500; color: var(--c-gray-900); }
/* "(pending invite)" an unclaimed admin-created row. A quiet amber pill
so the admin spots it at a glance without it shouting. */
.invite-badge {
font-size: var(--text-2xs); font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em;
padding: 1px 6px; border-radius: var(--radius-pill);
background: var(--c-warning-bg); color: var(--c-warning-fg);
white-space: nowrap;
}
/* Timestamps: an intentional date-over-time stack rather than a ragged
mid-value wrap. nowrap keeps each line whole. */
.user-when { white-space: nowrap; }
.user-when-date { display: block; color: var(--c-gray-700); }
.user-when-time { display: block; font-size: var(--text-xs); }
.mute-toggle { .mute-toggle {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; gap: 6px;
font-size: var(--text-base); cursor: pointer; font-size: var(--text-base); cursor: pointer;
+1 -1
View File
@@ -213,7 +213,7 @@ export default function App() {
wonders why a conversation is public can reach the answer wonders why a conversation is public can reach the answer
in two clicks. Anonymous viewers see it too. */} in two clicks. Anonymous viewers see it too. */}
<Link to="/philosophy" className="header-about" title="Why this exists (§14)"> <Link to="/philosophy" className="header-about" title="Why this exists (§14)">
Philosophy About
</Link> </Link>
<Link to="/docs" className="header-about" title="User guide"> <Link to="/docs" className="header-about" title="User guide">
Docs Docs
+5 -4
View File
@@ -530,18 +530,19 @@ export async function listBlockingPRs(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/blocking-prs`)) return jsonOrThrow(await fetch(`/api/rfcs/${slug}/blocking-prs`))
} }
export async function graduateCheck(slug, { id, repo }) { export async function graduateCheck(slug, { id }) {
// Meta-only topology (§13.2): two fields — integer id + owners. No
// repo name to validate.
const params = new URLSearchParams() const params = new URLSearchParams()
if (id != null) params.set('id', id) if (id != null) params.set('id', id)
if (repo != null) params.set('repo', repo)
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/graduate/check?${params}`)) return jsonOrThrow(await fetch(`/api/rfcs/${slug}/graduate/check?${params}`))
} }
export async function startGraduation(slug, { rfcId, repoName, owners }) { export async function startGraduation(slug, { rfcId, owners }) {
const res = await fetch(`/api/rfcs/${slug}/graduate`, { const res = await fetch(`/api/rfcs/${slug}/graduate`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rfc_id: rfcId, repo_name: repoName, owners }), body: JSON.stringify({ rfc_id: rfcId, owners }),
}) })
return jsonOrThrow(res) return jsonOrThrow(res)
} }
+45 -22
View File
@@ -181,7 +181,20 @@ function UsersTab() {
return ( return (
<div className="admin-tab"> <div className="admin-tab">
<header className="admin-tab-header"> <header className="admin-tab-header">
<h2>Users</h2> <div className="admin-tab-heading">
<h2>Users</h2>
{/* v0.17.0 roadmap item #16. The "Create user + invite"
affordance opens a modal that provisions a fresh users row
with the chosen role and sends an invite email with a
single-use claim link. */}
<div className="admin-tab-actions">
<button
type="button"
className="btn-primary"
onClick={() => setInviteModalOpen(true)}
>Create user + invite</button>
</div>
</div>
<p className="muted"> <p className="muted">
The pending bucket is the beta-access review queue (§6.1 / The pending bucket is the beta-access review queue (§6.1 /
v0.8.0). Grant or revoke writes to <code>permission_events</code> v0.8.0). Grant or revoke writes to <code>permission_events</code>
@@ -190,17 +203,6 @@ function UsersTab() {
retain their v0.7.0 semantics promote to admin to remove a retain their v0.7.0 semantics promote to admin to remove a
user's ability to write without silencing them. user's ability to write without silencing them.
</p> </p>
{/* v0.17.0 roadmap item #16. The "Create user + invite"
affordance opens a modal that provisions a fresh users row
with the chosen role and sends an invite email with a
single-use claim link. */}
<div className="admin-tab-actions">
<button
type="button"
className="btn-primary"
onClick={() => setInviteModalOpen(true)}
>Create user + invite</button>
</div>
</header> </header>
{error && <p className="settings-note warning">{error}</p>} {error && <p className="settings-note warning">{error}</p>}
{inviteModalOpen && ( {inviteModalOpen && (
@@ -270,21 +272,27 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
// the admin sees at a glance which rows are real users vs. unclaimed // the admin sees at a glance which rows are real users vs. unclaimed
// invites. // invites.
const pendingInvite = u.pending_invite const pendingInvite = u.pending_invite
// When there's no gitea_login the handle already IS the email, so the
// subline would otherwise repeat it. Only append the email when it adds
// something the handle doesn't already show.
const showEmail = u.email && u.email !== handle
return ( return (
<> <>
<tr> <tr>
<td> <td>
<div className="user-cell"> <div className="user-cell">
<span className="user-handle">{handle}</span> <div className="user-cell-handle">
{pendingInvite && ( <span className="user-handle">{handle}</span>
<span {pendingInvite && (
className="invite-badge" <span
title={`Admin-created invite; expires ${pendingInvite.expires_at}`} className="invite-badge"
>(pending invite)</span> title={`Admin-created invite; expires ${pendingInvite.expires_at}`}
)} >pending invite</span>
)}
</div>
<span className="muted"> <span className="muted">
{fullName || u.display_name} {fullName || u.display_name}
{u.email ? ` · ${u.email}` : ''} {showEmail ? ` · ${u.email}` : ''}
</span> </span>
</div> </div>
</td> </td>
@@ -317,8 +325,8 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
<span className="muted">N/A</span> <span className="muted">N/A</span>
)} )}
</td> </td>
<td className="muted">{u.created_at || '—'}</td> <TimeCell value={u.created_at} />
<td className="muted">{u.last_seen_at || '—'}</td> <TimeCell value={u.last_seen_at} />
</tr> </tr>
{state === 'pending' && u.beta_request_reason ? ( {state === 'pending' && u.beta_request_reason ? (
<tr className="user-row-reason"> <tr className="user-row-reason">
@@ -334,6 +342,21 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
) )
} }
// Render a "YYYY-MM-DD HH:MM:SS" timestamp as an intentional date-over-time
// stack (date prominent, time quiet below) rather than letting a narrow
// column wrap the value mid-string. Falls back to an em-dash when absent.
function TimeCell({ value }) {
if (!value) return <td className="muted"></td>
const [date, ...rest] = String(value).split(' ')
const time = rest.join(' ')
return (
<td className="user-when">
<span className="user-when-date">{date}</span>
{time && <span className="user-when-time muted">{time}</span>}
</td>
)
}
function PermissionCell({ user: u, busy, onFlipPermission }) { function PermissionCell({ user: u, busy, onFlipPermission }) {
const state = u.permission_state || 'granted' const state = u.permission_state || 'granted'
const decidedSuffix = u.permission_decided_at const decidedSuffix = u.permission_decided_at
+36 -118
View File
@@ -1,70 +1,55 @@
// GraduateDialog.jsx the §13.2 Graduate dialog and the §13.3 step stack. // GraduateDialog.jsx the §13.2 Graduate dialog and the §13.3 flip.
// //
// Renders three editable fields (integer ID, repo name, initial owners) // Meta-only topology (SPEC §1): graduation is an in-place state flip on
// with debounced server-side validation per §13.2 and a precondition // the meta entry no per-RFC repo is created and the body is kept. The
// popover backed by /blocking-prs for the §9.8 open-body-edit-PR gate. // dialog renders two editable fields (integer ID + initial owners) with
// // debounced server-side validation per §13.2. On confirm it opens the
// On confirm, opens the §13.3 SSE stream and renders the five named // §13.3 SSE stream and renders the two named steps (open the flip PR,
// steps with per-step states. On failure, the rollback step's events // merge it). There is no rollback step and no repo-name field.
// 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
graduateCheck, graduateCheck,
listBlockingPRs,
openGraduationProgress, openGraduationProgress,
startGraduation, startGraduation,
} from '../api' } from '../api'
const CHECK_DEBOUNCE_MS = 250 const CHECK_DEBOUNCE_MS = 250
const STEP_KEY_ORDER = ['create_repo', 'seed_files', 'open_pr', 'merge_pr', 'refresh_cache'] const STEP_KEY_ORDER = ['open_pr', 'merge_pr']
export default function GraduateDialog({ slug, entry, onClose, onCompleted }) { export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
// Suggest defaults from the catalog. // Suggest defaults from the catalog.
const suggestedId = useMemo(() => suggestNextRfcId(entry?.allKnownIds || []), [entry]) const suggestedId = useMemo(() => suggestNextRfcId(entry?.allKnownIds || []), [entry])
const [rfcId, setRfcId] = useState(suggestedId) const [rfcId, setRfcId] = useState(suggestedId)
const [repoName, setRepoName] = useState(`rfc-${stripPrefix(suggestedId)}-${slug}`)
const [owners, setOwners] = useState(entry?.owners?.length ? entry.owners : []) const [owners, setOwners] = useState(entry?.owners?.length ? entry.owners : [])
const [newOwner, setNewOwner] = useState('') const [newOwner, setNewOwner] = useState('')
const [checkResult, setCheckResult] = useState(null) const [checkResult, setCheckResult] = useState(null)
const [blockingPRs, setBlockingPRs] = useState([]) const [phase, setPhase] = useState('idle') // idle | running | done | failed | error
const [precondPopover, setPrecondPopover] = useState(false)
const [phase, setPhase] = useState('idle') // idle | running | done | rolled_back | error
const [streamState, setStreamState] = useState(null) const [streamState, setStreamState] = useState(null)
const [submitError, setSubmitError] = useState(null) const [submitError, setSubmitError] = useState(null)
const esRef = useRef(null) const esRef = useRef(null)
// Initial blocking-PRs probe + ongoing /check polling.
useEffect(() => {
listBlockingPRs(slug).then(({ items }) => setBlockingPRs(items || [])).catch(() => {})
}, [slug])
useEffect(() => { useEffect(() => {
const t = setTimeout(() => { const t = setTimeout(() => {
graduateCheck(slug, { id: rfcId, repo: repoName }) graduateCheck(slug, { id: rfcId })
.then(setCheckResult) .then(setCheckResult)
.catch(() => {}) .catch(() => {})
}, CHECK_DEBOUNCE_MS) }, CHECK_DEBOUNCE_MS)
return () => clearTimeout(t) return () => clearTimeout(t)
}, [slug, rfcId, repoName]) }, [slug, rfcId])
useEffect(() => () => { esRef.current?.close() }, []) useEffect(() => () => { esRef.current?.close() }, [])
const idError = checkResult?.id?.error || null const idError = checkResult?.id?.error || null
const repoError = checkResult?.repo?.error || null
const ownersOk = owners.length > 0 const ownersOk = owners.length > 0
const ownersError = ownersOk ? null : 'Add at least one initial owner' 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. // First-blocker tooltip text per §13.2.
const firstBlocker = idError || repoError || ownersError || blockingError const firstBlocker = idError || ownersError
const canSubmit = !firstBlocker && phase === 'idle' && checkResult?.id?.ok && checkResult?.repo?.ok const canSubmit = !firstBlocker && phase === 'idle' && checkResult?.id?.ok && ownersOk
const handleAddOwner = useCallback(() => { const handleAddOwner = useCallback(() => {
const v = newOwner.trim().toLowerCase() const v = newOwner.trim().toLowerCase()
@@ -81,7 +66,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
setSubmitError(null) setSubmitError(null)
setPhase('running') setPhase('running')
try { try {
await startGraduation(slug, { rfcId, repoName, owners }) await startGraduation(slug, { rfcId, owners })
} catch (err) { } catch (err) {
setPhase('idle') setPhase('idle')
setSubmitError(err.message) setSubmitError(err.message)
@@ -96,7 +81,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
// Short hold per §13.3, then dismiss. // Short hold per §13.3, then dismiss.
setTimeout(() => onCompleted?.(payload), 1500) setTimeout(() => onCompleted?.(payload), 1500)
} else { } else {
setPhase('rolled_back') setPhase('failed')
} }
} }
}, },
@@ -105,7 +90,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
setPhase('error') setPhase('error')
}, },
}) })
}, [slug, rfcId, repoName, owners, onCompleted]) }, [slug, rfcId, owners, onCompleted])
// ----- Render ----- // ----- Render -----
@@ -122,10 +107,10 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
{!showStack && ( {!showStack && (
<div className="modal-body"> <div className="modal-body">
<p className="modal-intro"> <p className="modal-intro">
§13: graduate the super-draft to its own repo. The meta-repo entry §13: graduate the super-draft to active. This is an in-place
becomes frontmatter-only; the canonical body moves to `RFC.md` in state flip the entry keeps its body and stays in the meta
the new repo. The sequence runs as five transactional steps with repo; only the frontmatter changes (state, integer ID, and the
rollback per §13.3. graduation stamps). No new repository is created.
</p> </p>
<div className="form-row"> <div className="form-row">
@@ -141,19 +126,6 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
{idError && <p className="field-error">{idError}</p>} {idError && <p className="field-error">{idError}</p>}
</div> </div>
<div className="form-row">
<label>Repo name</label>
<input
type="text"
value={repoName}
onChange={(e) => setRepoName(e.target.value.trim())}
placeholder="rfc-NNNN-slug"
disabled={phase !== 'idle'}
/>
<p className="field-help">Becomes `&lt;org&gt;/{repoName || 'rfc-…'}` on Gitea.</p>
{repoError && <p className="field-error">{repoError}</p>}
</div>
<div className="form-row"> <div className="form-row">
<label>Initial owners</label> <label>Initial owners</label>
<div className="owner-list"> <div className="owner-list">
@@ -189,68 +161,24 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
</div> </div>
{ownersError && <p className="field-error">{ownersError}</p>} {ownersError && <p className="field-error">{ownersError}</p>}
</div> </div>
{blockingPRs.length > 0 && (
<div className="precondition-block">
<button
type="button"
className="precondition-toggle"
onClick={() => setPrecondPopover(p => !p)}
>
{blockingPRs.length} open body-edit PR{blockingPRs.length === 1 ? '' : 's'} blocking graduation
&nbsp;{precondPopover ? '▾' : '▸'}
</button>
{precondPopover && (
<div className="precondition-popover">
{blockingPRs.map(pr => (
<div key={pr.pr_number} className="precondition-row">
<div className="precondition-row-main">
<strong>PR #{pr.pr_number}</strong> {pr.title || '(no title)'}
<div className="precondition-row-meta">
{pr.author ? `by @${pr.author}` : ''}
{pr.last_activity_at ? ` · ${pr.last_activity_at.slice(0, 10)}` : ''}
</div>
</div>
<div className="precondition-row-actions">
<a
className="btn-link"
href={`/rfc/${slug}/pr/${pr.pr_number}`}
target="_blank"
rel="noreferrer"
>Open </a>
</div>
</div>
))}
<p className="precondition-help">
§9.8: open body-edit PRs would attempt to re-introduce a
body to a frontmatter-only entry after step 3. Resolve
them (merge or withdraw) and re-open this dialog.
</p>
</div>
)}
</div>
)}
</div> </div>
)} )}
{showStack && ( {showStack && (
<div className="modal-body"> <div className="modal-body">
<StepStack <StepStack steps={streamState?.steps || []} />
steps={streamState?.steps || []} {phase === 'failed' && (
rollbackSteps={streamState?.rollback_steps || []}
/>
{phase === 'rolled_back' && (
<div className="what-happened"> <div className="what-happened">
<h3>What happened</h3> <h3>What happened</h3>
<p> <p>
The graduation could not complete. The app rolled back the The graduation could not complete. Because it is a single
steps that had already run; nothing was left half-applied on in-place flip, nothing was left half-applied `{slug}` stays
Gitea. Error: <code>{streamState?.error || 'unknown'}</code>. a super-draft. Error: <code>{streamState?.error || 'unknown'}</code>.
</p> </p>
<p> <p>
Read the failure detail next to the red step above. Resolve Read the failure detail next to the red step above, resolve
the underlying cause (a repo-name collision, a network flake, the underlying cause (a concurrent PR landing on{' '}
a concurrent PR landing on `rfcs/{slug}.md`) and try again. `rfcs/{slug}.md`, a network flake), and try again.
</p> </p>
</div> </div>
)} )}
@@ -258,9 +186,8 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
<div className="graduation-complete"> <div className="graduation-complete">
<h3>Graduation complete</h3> <h3>Graduation complete</h3>
<p> <p>
`{slug}` is now active as <strong>{streamState?.rfc_id}</strong>{' '} `{slug}` is now active as <strong>{streamState?.rfc_id}</strong>.
at <code>{streamState?.repo_full}</code>. The catalog and the The catalog and the RFC view reflect the new state.
RFC view reflect the new state.
</p> </p>
</div> </div>
)} )}
@@ -277,23 +204,23 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
disabled={!canSubmit} disabled={!canSubmit}
title={canSubmit ? '' : firstBlocker || ''} title={canSubmit ? '' : firstBlocker || ''}
> >
Graduate to RFC repo Graduate
</button> </button>
</> </>
)} )}
{phase === 'running' && ( {phase === 'running' && (
<span className="modal-progress-note">Running graduation sequence</span> <span className="modal-progress-note">Graduating</span>
)} )}
{(phase === 'rolled_back' || phase === 'error') && ( {(phase === 'failed' || phase === 'error') && (
<button className="btn-secondary" onClick={onClose}>Close</button> <button className="btn-secondary" onClick={onClose}>Close</button>
)} )}
{phase === 'done' && ( {phase === 'done' && (
<button className="btn-primary" onClick={() => onCompleted?.(streamState)}> <button className="btn-primary" onClick={() => onCompleted?.(streamState)}>
View the new RFC View the RFC
</button> </button>
)} )}
</div> </div>
{submitError && phase !== 'rolled_back' && ( {submitError && phase !== 'failed' && (
<div className="modal-error">Error: {submitError}</div> <div className="modal-error">Error: {submitError}</div>
)} )}
</div> </div>
@@ -302,14 +229,10 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
} }
function StepStack({ steps, rollbackSteps }) { function StepStack({ steps }) {
return ( return (
<div className="step-stack"> <div className="step-stack">
{steps.map(s => <StepRow key={s.key} step={s} />)} {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> </div>
) )
} }
@@ -350,8 +273,3 @@ function suggestNextRfcId(existing) {
const next = used.size === 0 ? 1 : (Math.max(...used) + 1) const next = used.size === 0 ? 1 : (Math.max(...used) + 1)
return `RFC-${String(next).padStart(4, '0')}` return `RFC-${String(next).padStart(4, '0')}`
} }
function stripPrefix(rfcId) {
return rfcId?.startsWith('RFC-') ? rfcId.slice(4) : rfcId
}