Compare commits

..

5 Commits

Author SHA1 Message Date
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
Ben Stull 732b23b156 v0.30.1: fix phantom pending-idea after merge-with-branch-delete
refresh_meta_pulls / refresh_rfc_repo recover a PR's slug from its
Gitea head.ref, which collapses to the refs/pull/<N>/head sentinel
once a merged PR's branch is deleted. The slug then parsed to None,
the row was skipped, and cached_prs.state froze at 'open' — so the
entry showed as both a super-draft and a pending idea. Recover the
real branch name from the stored cached_prs row when Gitea reports an
empty or sentinel ref.

Surfaced via the ROADMAP #35 operator authoring lane (CLI merge with
--delete-branch); the web UX leaves branches in place so it never hit
this. Regression test added; full suite 375 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 05:26:00 -07:00
Ben Stull 1558cc3a8b Release v0.30.0: sync user guide (DOCS.md) with the shipped app
Documentation-only minor. The guide had drifted since it was first
written; brought back in sync with v0.7.0–v0.29.0:

- "Signing in" rewritten: email + one-time-code, optional passcode,
  trust-device 30d, optional Turnstile, and the beta-request → pending
  → admin-grant gate, plus admin-create + invite-claim. The vestigial
  email allowlist is no longer described as the gate.
- "Proposing a new RFC": four → five fields (optional use-case #26) +
  AI tag-suggestion disclosure (#27).
- "Roles & permissions": documents the pending state.
- New "Invitations, cross-references, and contribution requests"
  section (#12 owner invites; #28 auto-link / create-RFC / ask-to-
  contribute).
- New "Privacy and cookies" section (#11/#13).

No code/schema/API/config/overlay/secret change — DOCS.md is served
verbatim by /api/docs. VERSION + frontend/package.json bumped to 0.30.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 02:12:18 -07:00
20 changed files with 1172 additions and 1120 deletions
+176
View File
@@ -23,6 +23,182 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond
that.
## 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
**Patch — bug fix: a merged idea-PR whose branch was deleted no longer
lingers as a phantom "pending idea." No schema, API, config, overlay,
or secret change — a plain code deploy applies it, and the running
reconciler clears any existing ghost on the next sweep (≤5 min) once
deployed. Shipped from driver session 0040.0.**
`refresh_meta_pulls` (and `refresh_rfc_repo`) recover a PR's slug/kind
by parsing its Gitea `head.ref`. When a PR is merged **and its branch
deleted**, Gitea stops reporting the real branch name and returns the
synthetic `refs/pull/<N>/head` sentinel instead. The slug then parsed
to `None`, the reconcile loop skipped the row, and `cached_prs.state`
stayed frozen at `open` forever — so the entry showed as **both** a
super-draft (the `cached_rfcs` push-event reconcile succeeded) **and** a
pending idea (the `cached_prs` PR-close reconcile never landed). The fix
recovers the original branch name from the already-stored `cached_prs`
row (which retains the real `head_branch` from when the PR was open;
migration 002) whenever Gitea reports an empty or `refs/pull/` sentinel
ref. Regression test added in `test_propose_vertical.py`
(`test_merged_idea_pr_with_deleted_branch_clears_proposal`).
Surfaced through the ROADMAP #35 operator authoring lane, which merges
idea PRs from the CLI with branch-deletion enabled — a path the web UX
never exercises (it leaves branches in place, so
`default_delete_branch_after_merge` stays false). The framework should
not depend on branches outliving their merge, hence the framework-level
fix rather than a tooling workaround.
Upgrade steps: none. **SHOULD** deploy as a normal code deploy; the
periodic reconciler self-heals any existing phantom on its next sweep.
## 0.30.0 — 2026-05-29
**Minor — documentation: the user guide (`DOCS.md`, served at
`/docs/user-guide` via `/api/docs`) brought back in sync with the
shipped app. No code, schema, API, config, overlay, or secret change —
a plain code deploy serves the updated guide. Shipped from driver
session 0037.0.**
The guide had drifted since it was first written: it still described
the pre-OTC email *allowlist* sign-in, listed four propose-RFC fields,
and predated several shipped surfaces. Updated to match v0.7.0v0.29.0:
- **Signing in** rewritten for the email + one-time-code flow (v0.7.0),
optional passcode (v0.10.0), trust-this-device for 30 days (v0.11.0),
optional Cloudflare Turnstile (v0.12.0), and the beta-access request →
`pending` → admin-`granted` gate (v0.8.0 / #6), plus the admin-create
+ invite-claim path (v0.17.0 / #16). The vestigial allowlist is no
longer described as the gate.
- **Proposing a new RFC** now lists five fields, adding the optional
"What will you be using this RFC for?" use-case field (#26) and noting
the AI tag-suggestion disclosure (#27).
- **Roles & permissions** documents the `pending` state.
- New **Invitations, cross-references, and contribution requests**
section covers owner invitations (#12) and the RFC auto-link /
create-RFC / ask-to-contribute affordances (#28).
- New **Privacy and cookies** section covers the consent banner and
consent-gated analytics (#11 / #13).
Upgrade steps: none — documentation-only; the change is the `DOCS.md`
file served verbatim by `/api/docs`. A plain code deploy at this tag
serves it. A deployment that overrides the guide via `DOCS_PATH`
supplies its own copy and is unaffected.
## 0.29.0 — 2026-05-28
**Minor — roadmap #28 Parts 2 + 3: offer-to-create-an-RFC for strong-
+105 -12
View File
@@ -39,23 +39,44 @@ thread. Every write affordance is replaced with a sign-in prompt.
## Signing in
While the framework is in private beta, only invited email addresses
can complete sign-in. If your email is on the allowlist, the
"Sign in" button in the header completes the flow and lands you on
the catalog with full read and write access. If your email is not on
the allowlist, you'll be sent to a short "pending" page explaining
the gate.
Anyone can start the sign-in flow with their own email address — there
is no invite-only allowlist. Sign-in is passwordless:
Once you have an account, you're a **contributor** by default — the
role that grants every write affordance the app exposes, scoped by
the per-RFC and per-branch rules described below.
1. **Enter your email.** If the deployment has human verification
enabled (a Cloudflare Turnstile challenge), you complete it here.
2. **Enter the one-time code.** The app emails you a short numeric
code; entering it signs you in. Codes expire after a few minutes,
and repeated wrong entries briefly lock the email.
3. **Set a passcode (optional).** After your first code sign-in you
can set a passcode. On later visits you sign in with email +
passcode, with the one-time code as the forgot-passcode fallback.
4. **Trust this device (optional).** You can mark a device trusted for
30 days to skip the code/passcode step on it. Trusted devices are
listed in your settings and can be revoked individually or all at
once.
### Getting write access
Signing in gives you an account, but write access is gated. The first
time you sign in you're asked for your first name, last name, and a
short note on why you'd like access; you then land on a "request in
review" page. While your account is **pending**, you can read
everything an anonymous visitor can but cannot write — no chat,
propose, branch, PR, or discussion post. Once an admin **grants** your
account you become a **contributor**, the role that carries every
write affordance the app exposes, scoped by the per-RFC and per-branch
rules described below.
An admin can also create your account ahead of time and email you an
invite link. Clicking it claims the account and signs you in with the
role the admin assigned, skipping the one-time-code step.
---
## Proposing a new RFC
A new RFC begins as a proposal. The "+ Propose new RFC" button at
the bottom of the catalog opens a small modal that collects four
the bottom of the catalog opens a small modal that collects five
things:
- **Title.** The word, concept, or topic this RFC would define.
@@ -65,8 +86,13 @@ things:
inline.
- **Pitch.** One or two paragraphs answering *why this RFC is
needed*. This becomes the body of the entry.
- **Tags.** Optional. The AI suggests tags from the pitch; you can
accept, dismiss, or type your own.
- **Use case.** Optional. *What will you be using this RFC for?*
the concrete application driving the proposal, as distinct from the
abstract case for it. Leaving it blank is fine.
- **Tags.** Optional. If the deployment has AI tag suggestion
enabled, suggested tags appear as you fill the form (with an inline
note that the text you've entered is sent to the model that
generates them); you can accept, dismiss, or type your own.
Submitting the modal does one concrete thing: it opens a pull
request against the framework's meta repository, adding one new
@@ -167,6 +193,51 @@ either requires a contributor account.
---
## Invitations, cross-references, and contribution requests
Three connected surfaces help the right people find and join the
right RFC.
### Owner invitations
An RFC's owner (or an app-wide admin or owner) can invite a specific
person to that RFC from the "Invitations" control in the RFC header.
The invite names an email and a role for *this RFC*:
- **contributor** — can open PRs and join the discussion;
- **discussant** — can join the discussion only.
The invitee gets an email with an accept link; accepting adds them as
a collaborator on that RFC. The invitations panel lists every invite
with its status (pending / accepted / expired / revoked); pending
invites can be revoked. An invitation is per-RFC — it does not change
the invitee's app-wide role, and it cannot lift the pending gate: the
invitee still needs a granted account to write.
### RFC cross-links in PRs and comments
When a PR description or a comment mentions an existing active RFC —
by its ID, its multi-word title, or its slug — the framework renders
that mention as a link to the RFC. The matching is conservative by
design (single common words are never auto-linked), and the links are
computed at read time, so nothing is rewritten in what you typed.
### "Create" and "ask to contribute" offers
The same scan surfaces two affordances inline:
- If a term looks like it should have an RFC but none exists yet, a
reader who has create rights sees a **"create RFC for '<term>'"**
link that opens the propose modal with the title pre-filled.
- If a term matches a *pending* RFC (a super-draft someone already
owns), a signed-in reader sees an **"ask to contribute"** offer
naming the owner. It opens a short request form — who you are, why
you're asking, and optionally what you'd use the RFC for. The
request lands in the owner's inbox; the owner can **accept** (which
sends you an owner invitation) or **decline** (which notifies you).
---
## Working on a branch
Contribute mode flips one branch into edit-enabled. The centre
@@ -505,6 +576,14 @@ Each role is a strict superset of the one below it.
entirely. The framework names a single "owner zero" at
bootstrap.
Between anonymous and contributor sits one transient state:
**pending**. A freshly signed-in account that hasn't been granted
access yet (see [Signing in](#signing-in)) reads everything an
anonymous visitor can, but no write affordance unlocks until an admin
grants it. Granting promotes the account to contributor; an admin can
also revoke a granted account back to a no-write state. These
transitions are recorded in the `permission_events` log.
The practical difference between admin and owner is narrow but
load-bearing: admin is the operational tier — it does the day-to-
day moderation and stewardship work; owner is the tier that
@@ -594,6 +673,20 @@ review.
---
## Privacy and cookies
A consent banner appears on your first visit and lets you choose
which cookie categories to allow — essential always, with analytics
and other categories opt-in. The choice is remembered and can be
changed any time from the privacy/cookies controls in settings.
Analytics only load if you opt in: the framework defers the analytics
SDK behind your consent, so declining means it is never initialized.
The `/privacy` and `/cookies` pages describe what's collected and
why; a deployment can point those pages at its own fuller policy.
---
## Where to learn more
- The framework's *why* lives in [the philosophy
+208 -179
View File
@@ -30,13 +30,44 @@ providers (Anthropic, Google, OpenAI / GitHub Copilot).
## 1. Repository topology
Each RFC is its own Gitea repository. There is in addition exactly one **meta
repository** that serves as the authoritative directory of all RFCs in the
system — drafts, active work, and retired entries alike.
There is exactly one **meta repository** that holds every RFC in the
system as a single markdown entry under `rfcs/` — drafts, active work,
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
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
merger of every merge. Authorization decisions are made by the app, in app
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
below the header is regenerated by CI on every merge to main** and lists
active RFCs, super-drafts, and (eventually) retired entries with links
into the corresponding entry files and, when present, the RFC's own
repository.
into the corresponding entry files. (There is no per-RFC repository to
link to under the meta-only topology, §1.)
- `CONTRIBUTING.md` — explains how to propose, claim, and contribute.
- A workflow file (Gitea Actions) that regenerates the README index.
@@ -84,7 +115,9 @@ slug: human
title: Human
state: super-draft # super-draft | active | withdrawn
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_at: 2026-05-22
graduated_at: null
@@ -103,8 +136,9 @@ tags: [identity, schema]
## Why this RFC is needed
(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
graduation, this body migrates to RFC.md in the new repo; see §13.)
super-draft, the body grows into the actual draft document. The body
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
@@ -129,11 +163,14 @@ an idea costs nothing in identifier space.
There are three canonical states stored in entry frontmatter:
- **`super-draft`** — the entry exists in the meta repo's `rfcs/`
directory. No dedicated repo yet. Anyone signed in can chat on it; anyone
can claim ownership; an owner is required before graduation.
- **`active`** — the entry has been graduated. A dedicated RFC repo
exists, `repo:` points to it, and real branches/PRs/conversation happen
there.
directory. Anyone signed in can chat on it; anyone can claim ownership;
an owner is required before graduation.
- **`active`** — the entry has been graduated: it carries an integer
`id` (`RFC-NNNN`) and `graduated_at`/`graduated_by`. It lives in the
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
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
For now, changing state in the meta repo entry is the *only* required
operation for a state transition. Graduation has additional side effects
(creating the new repo, seeding it); those are covered in §13. We
deliberately do not 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.
Changing state in the meta repo entry is the *only* required operation
for a state transition — graduation included. Graduation additionally
assigns the integer `id` and stamps `graduated_at`/`graduated_by` in the
same commit (§13); it has no other side effects under the meta-only
topology (no repo to create, nothing to seed). We deliberately do not
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
Gitea remains the source of truth for everything Git-shaped: meta repo
content, RFC repo content, branches, PRs, commits. Nothing in this system
overrides Gitea on those concerns.
content, branches, PRs, commits — all of which live on the single meta
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
process, that serves three purposes:
@@ -187,10 +227,11 @@ process, that serves three purposes:
assignments, per-branch grants, branch visibility settings, chat
history, audit logs. This data is canonical; it is not cached, it
is owned by the app.
3. **Cached bodies** — the main-branch body of each RFC's `RFC.md` (and
each super-draft's entry body) is cached for left-pane previews and
read-without-roundtrip. Branch bodies are *not* cached; the editor
fetches them live from Gitea when opened.
3. **Cached bodies** — the main-branch body of each RFC, read from its
`rfcs/<slug>.md` meta entry (the same source for super-drafts and
active RFCs alike under the meta-only topology), is cached for
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
@@ -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.
Typical latency: sub-second.
- **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
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
§13's graduation sequence was written before this section's
machinery existed. The mechanics §13 needs to absorb fold inline
into §13.2 and §13.4 in their respective sections; the substantive
additions are captured here for cross-reference:
> **Meta-only update (v0.31.0).** This section originally reconciled
> §13's per-RFC-repo graduation transaction with the §9-era editing
> machinery. Under the meta-only topology (§1, §13) the entry never
> 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
post-graduation would attempt to re-introduce a body to a
frontmatter-only entry. The Graduate dialog disables the confirm
button if any meta-repo PR is open against `rfcs/<slug>.md`. The
precondition is enforced before the bot starts §13.3's sequence,
so §13.3's rollback complexity does not grow.
- **Bare edit branches survive graduation.** Edit branches without
an open PR are not blocked. They remain on the meta repo subject
to §12's hygiene timers. The contributor can re-cut against the
new RFC repo's main if they still want the work. The branch chat
persists per §8.4 as historical record even after auto-close, so
the argument that produced the work is preserved regardless of
whether the work itself merges.
- **Chat migration includes range and paragraph sub-threads.**
§13.4's chat-follows-the-work rule covers the whole-doc main
thread; it extends to range and paragraph sub-threads on the
super-draft's main view, which migrate as part of the same
movement. Anchors re-resolve against `RFC.md` on the new repo;
since §13.3's step 2 seeds `RFC.md` from the super-draft body
verbatim, anchors typically locate the same content. Where they
do not, §8.12's stale mechanic engages.
- **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.
frontmatter-only entry.~~ **No longer applies** — the body is kept,
so an open body-edit PR coexists with graduation. Graduation touches
only the frontmatter; a body-edit PR that merges after graduation
edits the same entry's body just as it would have before. The
Graduate dialog no longer gates on open body-edit PRs (§13.2).
- ~~**Bare edit branches survive graduation.**~~ Trivially true now —
no repo boundary is crossed, so every edit branch simply remains a
meta-repo branch on the same slug, subject to §12's hygiene timers,
with no "re-cut against the new repo" step.
- ~~**Chat migration includes range and paragraph sub-threads.**~~ No
migration occurs: whole-doc, range, and paragraph threads stay on
their `(rfc_slug, branch_name)` rows. Their anchors resolve against
the same entry body, which did not move, so §8.12's stale mechanic is
not provoked by graduation.
- ~~**Pre-graduation history surfaces from the new RFC view.**~~ There
is no "new RFC view" distinct from the entry's own view, so there is
no pre-graduation hop to bridge. Edit-branch threads, flags, and
`changes` rows surface on the active RFC the same way they did on the
super-draft — same slug, same surface.
---
@@ -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
repo" on a super-draft's page. The button is disabled with a tooltip
when the super-draft has no owners (see §13.1) or when any meta-repo
body-edit PR is open against `rfcs/<slug>.md` (see §9.8 — open
body-edit PRs would attempt to re-introduce a body to a frontmatter-
only entry after step 3 of §13.3). Bare edit branches without an open
PR do not block graduation; they remain on the meta repo subject to
§12's hygiene timers.
Graduation is initiated by an owner or admin clicking "Graduate" on a
super-draft's page. The button is disabled with a tooltip when the
super-draft has no owners (see §13.1). Open meta-repo body-edit PRs no
longer block graduation: under the meta-only topology (§1) the body is
kept in the entry, so graduation touches only frontmatter and coexists
with body edits (see §9.8). Bare edit branches are likewise unaffected;
they remain on the meta repo subject to §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)
@@ -1983,137 +2032,117 @@ broadening rather than a precondition for the proposer's own RFC.)
### 13.2 The Graduate dialog
Clicking "Graduate to RFC repo" opens a small dialog with three
editable fields:
Clicking "Graduate" opens a small dialog with two editable fields:
- **Integer ID** — pre-filled as `max(existing integer IDs) + 1`,
formatted as `RFC-NNNN`. Editable to allow gap reservations but the
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
"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
debounce, against the catalog cache and a regex — integer-ID
collision against existing IDs, repo-name pattern against valid
Gitea name rules, the at-least-one-owner constraint on the picker.
Errors render as a short line of text beneath the offending field.
The repo-name collision check is re-issued atomically server-side
on confirm, since a concurrent graduation could land between
dialog-open and submit. While any field is invalid, the confirm
button is disabled and its tooltip names the first blocker
specifically — "Integer ID 42 is already taken," "Repo name must be
lowercase letters, digits, and dashes," "Add at least one initial
owner" — the same grammar the precondition popover below uses, so
the dialog and the gate read as one surface rather than two
competing styles.
collision against existing IDs, the at-least-one-owner constraint on
the picker. Errors render as a short line of text beneath the
offending field. The integer-ID collision check is re-issued
atomically server-side on confirm, since a concurrent graduation
could land between dialog-open and submit. While any field is
invalid, the confirm button is disabled and its tooltip names the
first blocker specifically — "Integer ID 42 is already taken," "Add
at least one initial owner" — the same grammar the precondition
popover below uses, so 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
from §13's opening paragraph fail — no owners on the entry, or any
open meta-repo PR against `rfcs/<slug>.md`. The disabled button
opens a small popover on hover or click that lists each failing
precondition as its own line item with an inline remediation
affordance per item. "No owners claimed yet" surfaces a "Copy share
link" affordance for surfacing the super-draft to a would-be
claimer, plus a secondary "Claim ownership yourself" — admins are
contributors per §6.1, so they can claim if they intend to graduate
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 dialog's confirm button is also disabled when the entry has no
owners. The disabled button opens a small popover on hover or click
listing the failing precondition with an inline remediation
affordance: "No owners claimed yet" surfaces a "Copy share link"
affordance for surfacing the super-draft to a would-be claimer, plus
a secondary "Claim ownership yourself" — admins are contributors per
§6.1, so they can claim if they intend to graduate solo. Open
body-edit PRs are **not** a precondition anymore (§9.8): the body is
kept, so they coexist with graduation.
The preconditions are enforced before the bot starts §13.3's
sequence, so §13.3's rollback complexity is unchanged.
### 13.3 The flip
### 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.
2. Seed it with an initial commit on `main` containing:
- `README.md` (header pointing at the meta-repo entry, plus the
super-draft's pitch body migrated over).
- `RFC.md` (the actual document, starting from the super-draft body
or a template if the body is empty).
- `.rfc/metadata.yaml` — mirror of the meta-repo frontmatter for
future tooling.
3. Open a PR against the meta repo updating the entry: `state: active`,
`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.
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**. If opening or merging the flip PR fails, the entry simply
stays a super-draft and the admin sees the error — nothing partial was
created that needs cleaning up. The dialog reports a single in-flight
"Graduating…" state that resolves to success (the PR merged) or a
plain error (the PR could not be opened or merged), rather than the
old five-step stack. On success, a brief "Graduation complete" frame
holds for a moment before the dialog closes.
The dialog renders the sequence in flight as a stack of the five
named steps with per-step states — `pending`, `running`, `done`,
`failed`, `not reached` — and a one-line caption beneath the current
step naming the concrete operation ("Creating repository
wiggleverse/rfc-0042-human…"). The stack streams from
the server via the SSE surface in §17, one event per step
transition. On success, a brief "Graduation complete" frame holds
for a moment before the dialog closes and the catalog row
transitions per §7.2.
> ~~**The old transactional sequence (per-RFC-repo model, retired).**
> Confirming created a new Gitea repo; seeded it with `README.md`,
> `RFC.md` (body migrated), and `.rfc/metadata.yaml`; opened a meta-repo
> PR flipping `state`/`id`/`repo`/`graduated_*` **and stripping the
> entry body** to frontmatter-only with a "see the full RFC at <repo>"
> link; auto-merged it; refreshed the cache. A five-step SSE stack
> rendered progress, and any mid-sequence failure rolled back (delete
> the half-created repo, abandon the PR). All of that machinery is
> removed — the body strip was the only reason most of it existed.~~
If any step fails partway, the app rolls back: deletes the
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, branches, and history stay put
### 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
main-branch chat at graduation. This covers both the whole-doc main
thread per §8.4 and any range or paragraph sub-threads per §8.12
anchored to the super-draft's main view; anchors re-resolve against
`RFC.md` on the new repo and, where they fail, §8.12's stale
mechanic engages. The meta-repo entry retains a generated link
"Conversation continues at <repo URL>." The chat is about the RFC,
not the meta-repo entry, and it should travel with the work.
Because there is no repo boundary to cross, there is no
"pre-graduation history" hop: the active RFC's view is the same view
the super-draft had, listing the same `main`, open branches, and open
PRs in the §8.1 breadcrumb dropdown. Edit branches that closed during
the super-draft phase surface through the ordinary "Show closed
branches" filter — there is no separate "lived on the meta repo before
the repo existed" set to distinguish, because the repo never existed.
Meta-repo edit-branch chats, flag threads, and `changes` rows from
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.
### 13.5 Reversing graduation
The affordance renders as a section in the §8.1 breadcrumb dropdown
on the new RFC view, alongside `main`, open branches, and open PRs,
headed "Pre-graduation history (N)" with each pre-graduation edit
branch listed as its own row. Selecting a row swaps the center
column to a read-only render of that branch's body at its last
commit and the right column to that branch's chat, with associated
change-cards and flags inline — the same machinery a closed branch
on an active RFC uses per §10.7 and §11.5. Anchors on pre-graduation
threads resolve against the pre-graduation body, not against
`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.
The canonical forward path from `active` is still `withdrawn` (§3.1),
and `withdrawn → super-draft` reopens an entry. Under the meta-only
topology, reversing a graduation is no longer operationally messy —
there are no repo commits to orphan, only a frontmatter flip — but the
state graph in §3.1 remains the authority: `active → withdrawn →
super-draft` is the supported route, and the integer `id`, once
assigned, is not reclaimed (gap-free allocation per §2.3 tolerates
gaps from withdrawals). A direct `active → super-draft` un-graduate is
not exposed in v1; withdraw-and-reopen covers the need.
### 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
`withdrawn`, not back to `super-draft`. Reversing graduation cleanly
is operationally messy (existing commits in the new repo, etc.) and
the cost of not having it is low — withdraw and re-graduate as a
fresh idea if needed.
RFC-0001 `human` was graduated under the original per-RFC-repo model
(2026-05-26) into `wiggleverse/rfc-0001-human`, with its body in that
repo's `RFC.md` and its meta entry stripped to frontmatter. When the
meta-only topology landed (v0.31.0, OHM ROADMAP #36, driver session
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.29.0
0.31.1
+59 -37
View File
@@ -150,7 +150,7 @@ def make_router(
# `refresh_meta_branches` writes is internal scaffolding for the
# §10.1 has-commits-ahead check — the §9.4 dropdown's first
# position is rendered separately as 'canonical body'.
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
branch_rows = db.conn().execute(
"""
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
# PRs on the meta repo. Same shape either way — the §9.4 dropdown
# treats both as "open work against this entry."
pr_kinds = ("meta_body_edit", "meta_metadata") if _is_super_draft(rfc) else ("rfc_branch",)
pr_kinds = ("meta_body_edit", "meta_metadata") if _is_meta_resident(rfc) else ("rfc_branch",)
placeholders = ",".join("?" * len(pr_kinds))
pr_rows = db.conn().execute(
f"""
@@ -204,17 +204,18 @@ def make_router(
for r in pr_rows
]
# For super-drafts the cached body is entry.body already (see
# cache._upsert_cached_rfc), so no extraction is needed.
# §9.8 / §13.4 pre-graduation history: for active RFCs, surface
# any `threads` or `changes` rows whose `branch_name` starts with
# `edit-<slug>-` so the breadcrumb dropdown can render the
# affordance as a distinct disclosure alongside main, open
# branches, and open PRs. The slug is the canonical key per §2.3
# before and after graduation, so the query is a straightforward
# lookup — no data movement.
# For meta-resident entries the cached body is entry.body already
# (see cache._upsert_cached_rfc), so no extraction is needed.
# Pre-graduation history is a LEGACY-only affordance: under the
# meta-only topology (§1, §13.4) graduation moves nothing, so an
# active RFC's edit branches are its *current* branches and already
# surface in `branches` above — there is no separate pre-graduation
# set. The disclosure is therefore computed only for a legacy
# per-RFC-repo active entry (`repo` set), where edit branches on the
# 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]] = []
if rfc["state"] == "active":
if rfc["state"] == "active" and rfc["repo"]:
pre_grad_rows = db.conn().execute(
"""
SELECT t.branch_name,
@@ -292,8 +293,20 @@ def make_router(
owner, repo = _repo_for(rfc)
new_branch = (body.branch_name or "").strip()
if not new_branch:
new_branch = _auto_branch_name(viewer.gitea_login)
_validate_branch_name(new_branch)
# Meta-only topology (§1): an active RFC's branches live on the
# 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:
await bot.cut_branch_from_main(
viewer.as_actor(),
@@ -326,8 +339,10 @@ def make_router(
_ensure_branch_vis(slug, new_branch, creator_user_id=viewer.user_id)
# Make the cache aware immediately so the breadcrumb reflects
# the new branch without waiting for the webhook hop.
await cache.refresh_rfc_repo(config, gitea, slug)
# the new branch without waiting for the webhook hop. Meta-resident
# 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}
@@ -1081,14 +1096,14 @@ def make_router(
return row
def _require_rfc_with_repo(slug: str):
"""Used by every branch-scoped endpoint. For active RFCs, a repo is
required. For super-drafts, the meta repo is the implicit target
no per-RFC repo check needed."""
"""Used by every branch-scoped endpoint. Under the meta-only
topology (§1) the meta repo is the implicit target for every
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)
if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn")
if row["state"] == "active" and not row["repo"]:
raise HTTPException(409, "RFC has no repo")
return row
def _require_active_rfc(slug: str):
@@ -1106,22 +1121,28 @@ def make_router(
def _is_super_draft(rfc) -> bool:
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:
"""A branch name shaped like one of the bot's meta-repo prefixes.
§9.8's pre-graduation history affordance points the new RFC view
at branches matching `edit-<slug>-...` even after the entry is
active; treating those names as meta-repo targets lets the read
path dispatch correctly without a separate endpoint."""
Retained for the legacy per-RFC-repo read path; under meta-only
every entry is already a meta target via `_is_meta_resident`."""
return name != "main" and name.startswith((
"edit-", "edit/", "metadata-", "metadata/", "claim/", "propose/",
"graduate-",
))
def _is_meta_target(rfc, branch: str) -> bool:
"""Either a super-draft branch (active edit branch or the
canonical body) or an active RFC's pre-graduation meta-repo
branch surfaced through the §9.8 history affordance."""
if _is_super_draft(rfc):
"""A meta-resident entry (super-draft or active-in-place, §1)
targets the meta repo for every branch. The branch-name fallback
covers the retired per-RFC-repo case for any legacy entry that
still carries a `repo:`."""
if _is_meta_resident(rfc):
return True
return _is_meta_branch_name(branch)
@@ -1161,7 +1182,7 @@ def make_router(
return entry_mod.serialize(entry)
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_branches(config, gitea)
else:
@@ -1270,12 +1291,13 @@ def make_router(
return False
if branch == "main":
return False
# §9.8: pre-graduation history branches are read-only on the
# post-graduation surface. The contributor can re-cut against the
# new repo's main if they still want the work, but the meta-repo
# branches that lived on the super-draft are not editable from
# the active-RFC view.
if rfc["state"] == "active" and _is_meta_branch_name(branch):
# §9.8 (LEGACY per-repo only): pre-graduation history branches are
# read-only on the post-graduation surface of a per-RFC-repo active
# entry. Under the meta-only topology (§1, §13.4) an active RFC's
# `edit-<slug>-…` branches are its *current* editable branches, not
# a frozen pre-graduation set, so this guard applies only when a
# 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
if viewer.role in ("owner", "admin"):
return True
@@ -1302,7 +1324,7 @@ def make_router(
def _require_can_contribute(slug: str, branch: str, viewer) -> None:
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,),
).fetchone()
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
in-process orchestrator that runs the §13.3 transactional sequence with
rollback.
"""§13 graduation flow — the meta-only in-place state flip.
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)
- 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>/blocking-prs (informational; no longer a
graduation precondition)
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim), which is
graduation's prerequisite for non-admins per §13.1.
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim).
The orchestrator runs in-process each in-flight graduation lives in a
small `GraduationState` keyed by slug, with an asyncio.Queue feeding the
SSE handler. Per the §13.3 transactional contract, every forward step is
paired with an undo; rollback runs the undos in reverse order from the
last step that completed. §13.4's chat migration is a database semantic
no-op (the threads' `(rfc_slug, branch_name='main')` rows are interpreted
as super-draft canonical-body before graduation and as new-RFC main
afterwards same shape, different meaning), so the only DB work the
sequence does is the audit-log rows the bot's `_log` writes per step.
SSE handler. §13.4's chat/branch/history are a database no-op: every row
is keyed by the slug per §2.3 and stays put across the flip.
"""
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 = (
"create_repo",
"seed_files",
"open_pr",
"merge_pr",
"refresh_cache",
)
STEP_LABELS = {
"create_repo": "Create per-RFC repository",
"seed_files": "Seed RFC.md, README.md, and .rfc/metadata.yaml",
"open_pr": "Open meta-repo graduation PR",
"open_pr": "Open graduation PR (flip state to active)",
"merge_pr": "Merge graduation PR",
"refresh_cache": "Refresh catalog and views",
}
@@ -77,8 +75,6 @@ class StepState:
class GraduationState:
slug: str
rfc_id: str
repo_name: str
repo_full: str
owners: list[str]
arbiters: list[str]
steps: list[StepState]
@@ -86,8 +82,6 @@ class GraduationState:
finished: bool = False
succeeded: bool = False
error: str | None = None
rollback_started: bool = False
rollback_steps: list[StepState] = field(default_factory=list)
new_pr_number: int | None = None
graduation_branch: str | None = None
@@ -95,12 +89,9 @@ class GraduationState:
return {
"slug": self.slug,
"rfc_id": self.rfc_id,
"repo_full": self.repo_full,
"steps": [_step_payload(s) for s in self.steps],
"rollback_steps": [_step_payload(s) for s in self.rollback_steps],
"finished": self.finished,
"succeeded": self.succeeded,
"rolled_back": self.rollback_started,
"error": self.error,
"pr_number": self.new_pr_number,
}
@@ -114,7 +105,7 @@ def _step_payload(s: StepState) -> dict:
# is fine; the registry is keyed by slug to refuse concurrent graduations
# of the same entry (the §13.2 atomic re-check is a separate defense
# against a concurrent attempt of a DIFFERENT slug claiming the same
# integer ID or repo name).
# integer ID).
_active: dict[str, GraduationState] = {}
@@ -122,10 +113,10 @@ def _get_active(slug: str) -> GraduationState | None:
return _active.get(slug)
def _new_active(slug: str, *, rfc_id: str, repo_name: str, repo_full: str,
def _new_active(slug: str, *, rfc_id: str,
owners: list[str], arbiters: list[str]) -> 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,
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,}$")
def _is_valid_repo_name(name: str) -> bool:
return bool(_REPO_NAME_RE.match(name)) and ".." not in name
def _is_valid_rfc_id(rfc_id: str) -> bool:
return bool(_RFC_ID_RE.match(rfc_id))
@@ -167,13 +150,6 @@ def _suggest_next_rfc_id() -> str:
return f"RFC-{nxt:04d}"
def _suggest_repo_name(slug: str, rfc_id: str) -> str:
# rfc-NNNN-<slug> per §13.2's default. Strip the 'RFC-' prefix and
# lowercase the number-pad.
num = rfc_id.split("-", 1)[1] if "-" in rfc_id else "0001"
return f"rfc-{num}-{slug}"
def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool:
row = db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE rfc_id = ? AND slug != ?",
@@ -189,7 +165,6 @@ def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool:
class GraduateBody(BaseModel):
rfc_id: str = Field(min_length=5, max_length=40)
repo_name: str = Field(min_length=1, max_length=100)
owners: list[str] = Field(min_length=1)
@@ -206,19 +181,17 @@ def make_router(
router = APIRouter()
# -------------------------------------------------------------------
# §13.2: GET /api/rfcs/<slug>/blocking-prs
# Lists open meta-repo PRs against rfcs/<slug>.md per the precondition
# popover. Returns PR number, title, author, last-activity timestamp,
# and the viewer's available actions (merge, withdraw, open-in-new-tab).
# GET /api/rfcs/<slug>/blocking-prs
# Lists open meta-repo body-edit PRs against rfcs/<slug>.md. Under the
# meta-only topology (§9.8) these no longer block graduation — the body
# 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")
async def list_blocking_prs(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_super_draft(slug)
# §13's opening paragraph: only body-edit PRs block graduation.
# Bare edit branches without an open PR do not block. The query
# filters cached_prs to open meta_body_edit kinds for this slug.
rows = db.conn().execute(
"""
SELECT pr_number, title, opened_by, opened_at, head_branch, pr_kind
@@ -261,14 +234,15 @@ def make_router(
"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
# client; the dialog calls this as the admin types. Returns per-field
# collision/validity from the catalog cache plus a server-authoritative
# repo-name collision check.
# client. Two fields under meta-only: the integer ID and the owners
# precondition. There is no repo name to validate (§13.2).
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/graduate/check")
@@ -281,16 +255,7 @@ def make_router(
# admins/owners, but the check itself is read-only.
candidate_id = (request.query_params.get("id") or "").strip()
candidate_repo = (request.query_params.get("repo") or "").strip()
owners = json.loads(rfc["owners_json"] or "[]")
blocking_count = db.conn().execute(
"""
SELECT COUNT(*) AS n FROM cached_prs
WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit'
""",
(slug,),
).fetchone()["n"]
# ID field
id_payload: dict[str, Any] = {"value": candidate_id, "ok": True, "error": None}
@@ -304,34 +269,6 @@ def make_router(
id_payload["ok"] = False
id_payload["error"] = f"Integer ID {candidate_id} is already taken"
# Repo field — validate pattern then probe Gitea for an existing
# repo of that name under our org. The repo lookup is a single GET
# so it's cheap to call on every keystroke (debounced from the
# client per §13.2).
repo_payload: dict[str, Any] = {"value": candidate_repo, "ok": True, "error": None}
if not candidate_repo:
repo_payload["ok"] = False
repo_payload["error"] = "Repo name is required"
elif not _is_valid_repo_name(candidate_repo):
repo_payload["ok"] = False
repo_payload["error"] = (
"Repo name must be alphanumerics, dashes, dots, or underscores "
"(start with alphanumeric)"
)
else:
try:
existing = await gitea.get_repo(config.gitea_org, candidate_repo)
except GiteaError as e:
# Network/auth flake — surface as a non-fatal hint; the
# atomic server-side check at POST time is the authority.
existing = None
log.warning("graduate_check: Gitea get_repo error: %s", e)
if existing is not None:
repo_payload["ok"] = False
repo_payload["error"] = (
f"Repo `{config.gitea_org}/{candidate_repo}` already exists"
)
# Owners precondition — §13's opening paragraph.
owners_payload: dict[str, Any] = {
"ok": len(owners) > 0,
@@ -340,28 +277,13 @@ def make_router(
"error": None if len(owners) > 0 else "No owners claimed yet",
}
# Blocking PR precondition — §9.8 / §13's opening paragraph.
prs_payload: dict[str, Any] = {
"ok": blocking_count == 0,
"count": blocking_count,
"error": (
None if blocking_count == 0
else f"{blocking_count} open body-edit PR{'' if blocking_count == 1 else 's'} blocking graduation"
),
}
in_flight = _get_active(slug)
any_invalid = not (
id_payload["ok"] and repo_payload["ok"]
and owners_payload["ok"] and prs_payload["ok"]
)
any_invalid = not (id_payload["ok"] and owners_payload["ok"])
return {
"slug": slug,
"id": id_payload,
"repo": repo_payload,
"owners": owners_payload,
"blocking_prs": prs_payload,
"can_submit": (not any_invalid) and (in_flight is None or in_flight.finished),
"in_flight": (
None if in_flight is None
@@ -370,8 +292,8 @@ def make_router(
}
# -------------------------------------------------------------------
# §13.3: POST /api/rfcs/<slug>/graduate
# Atomic re-validation, then kicks off the sequence as an async task.
# POST /api/rfcs/<slug>/graduate
# Atomic re-validation, then kicks off the flip as an async task.
# 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
# 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()
repo_name = body.repo_name.strip()
owners = [o.strip() for o in body.owners if o.strip()]
if not owners:
raise HTTPException(422, "Add at least one initial owner")
@@ -402,35 +323,10 @@ def make_router(
raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)")
if _rfc_id_taken(rfc_id, excluding_slug=slug):
raise HTTPException(409, f"Integer ID {rfc_id} is already taken")
if not _is_valid_repo_name(repo_name):
raise HTTPException(422, "Repo name must be alphanumerics, dashes, dots, or underscores")
try:
existing_repo = await gitea.get_repo(config.gitea_org, repo_name)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
if existing_repo is not None:
raise HTTPException(409, f"Repo `{config.gitea_org}/{repo_name}` already exists")
# §9.8 precondition gate — enforced before the bot starts the
# sequence so the §13.3 rollback complexity does not grow. An
# open body-edit PR against rfcs/<slug>.md would attempt to
# re-introduce a body to a frontmatter-only entry after step 3.
blocking = db.conn().execute(
"""
SELECT COUNT(*) AS n FROM cached_prs
WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit'
""",
(slug,),
).fetchone()["n"]
if blocking > 0:
raise HTTPException(
409,
f"{blocking} open body-edit PR{'' if blocking == 1 else 's'} block graduation",
)
# Read the meta-repo entry once — we need the file's sha for the
# graduation PR's update_file call and the original body so the
# bot can seed RFC.md on the new repo with the migrated body.
# graduation PR's update_file call and the body to carry through
# unchanged (meta-only keeps the body in the entry, §13.3).
fetched = await gitea.read_file(
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
)
@@ -442,19 +338,17 @@ def make_router(
except Exception as e:
raise HTTPException(500, f"Meta entry malformed: {e}")
repo_full = f"{config.gitea_org}/{repo_name}"
arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1]
# Compose the graduated frontmatter — body stripped, graduation
# fields filled. The serializer is run now so the PR-open step
# has the contents pre-rendered (single source of truth for the
# body migration vs. the meta-entry update).
# Compose the graduated frontmatter — body KEPT, graduation fields
# filled, repo left null (§1). Serialized now so the PR-open step
# has the contents pre-rendered.
graduated_entry = entry_mod.Entry(
slug=slug,
title=super_draft_entry.title,
state="active",
id=rfc_id,
repo=repo_full,
repo=None,
proposed_by=super_draft_entry.proposed_by,
proposed_at=super_draft_entry.proposed_at,
graduated_at=entry_mod.today(),
@@ -462,37 +356,30 @@ def make_router(
owners=owners,
arbiters=arbiters,
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)
state = _new_active(
slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full,
owners=owners, arbiters=arbiters,
slug, rfc_id=rfc_id, owners=owners, arbiters=arbiters,
)
# Audit: graduation started. The terminal `graduate_complete` /
# `graduate_rollback` rows below close the linkable sequence.
# `graduate_failed` rows below close the linkable sequence.
_audit(
viewer.user_id, viewer.gitea_login, "graduate_start",
rfc_slug=slug,
details={
"rfc_id": rfc_id, "repo": repo_full, "owners": owners,
"blocking_prs": blocking,
},
details={"rfc_id": rfc_id, "owners": owners},
)
# Test seam: `?_sync=1` awaits the orchestrator inline so
# integration tests can assert post-conditions without driving
# the SSE. Production clients use the spec-described shape —
# POST returns immediately, the client subscribes to the
# progress SSE.
# the SSE. Production clients POST then subscribe to the SSE.
coro = _orchestrate(
config=config, gitea=gitea, bot=bot,
actor=viewer.as_actor(), state=state,
super_draft_body=super_draft_entry.body,
super_draft_title=super_draft_entry.title,
super_draft_tags=list(super_draft_entry.tags),
graduated_contents=graduated_contents,
meta_file_sha=meta_sha,
)
@@ -505,38 +392,31 @@ def make_router(
"ok": True,
"slug": slug,
"rfc_id": rfc_id,
"repo": repo_full,
"stream_url": f"/api/rfcs/{slug}/graduate/progress",
"finished": state.finished,
"succeeded": state.succeeded,
}
# -------------------------------------------------------------------
# §13.3: GET /api/rfcs/<slug>/graduate/progress
# SSE stream of the step transitions. One event per step transition
# (pending → running → done / failed), plus the trailing rollback
# step's events if any earlier step fails.
# GET /api/rfcs/<slug>/graduate/progress
# SSE stream of the flip's step transitions (open_pr, merge_pr).
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/graduate/progress")
async def graduate_progress(slug: str, request: Request):
# v0.6.0 (item #4): the progress SSE surfaces admin-internal step
# detail (repo name, PR number, rollback steps) that isn't part of
# the v0.3.0 anonymous-read contract for catalog/RFC bodies. The
# corresponding POST /graduate is gated to RFC owners/arbiters and
# app admins/owners via `_can_graduate`; the read SSE shares that
# operator-visible surface, so it requires at least an
# authenticated viewer. We keep the floor at require_user (not
# require_contributor) so a write-muted operator can still observe
# the progress of a graduation they kicked off before being muted.
# The progress SSE surfaces admin-internal step detail (PR number)
# that isn't part of the anonymous-read contract. POST /graduate is
# gated to RFC owners/arbiters and app admins/owners; the read SSE
# shares that operator-visible surface and requires an authenticated
# viewer. We keep the floor at require_user (not require_contributor)
# so a write-muted operator can still observe a graduation they
# kicked off before being muted.
auth.require_user(request)
state = _get_active(slug)
if state is None:
raise HTTPException(404, "No graduation in flight for this slug")
async def event_stream():
# Emit the current snapshot first so a late subscriber sees
# the steps already completed.
yield _sse_event("snapshot", state.to_payload())
if state.finished:
yield _sse_event("done", state.to_payload())
@@ -555,22 +435,16 @@ def make_router(
# §13.1: POST /api/rfcs/<slug>/claim
# Opens a meta-repo PR adding the actor's gitea_login to the entry's
# owners list. Anyone signed in may claim — the merge is gated to
# owners/admins per §13.1 (which collapses to admins for unclaimed
# entries since `owners` is empty).
# owners/admins per §13.1.
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/claim")
async def claim_ownership(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug)
# Refuse if the actor is already in owners — no-op claim.
existing_owners = json.loads(rfc["owners_json"] or "[]")
if viewer.gitea_login in existing_owners:
return {"ok": True, "noop": True}
# Refuse if a claim PR for this actor is already open. The branch
# name `claim/<slug>` collides per actor implicitly since Gitea
# refuses duplicate branch creation; we surface a clean 409 here
# so the client doesn't see a 502.
already = db.conn().execute(
"""
SELECT pr_number FROM cached_prs
@@ -581,8 +455,6 @@ def make_router(
if already:
raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}")
# Compose the new entry contents — owners list with the claimant
# appended.
fetched = await gitea.read_file(
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
)
@@ -626,7 +498,7 @@ def make_router(
# ---------------------------------------------------------------------------
# Orchestrator
# Orchestrator — the §13.3 in-place flip
# ---------------------------------------------------------------------------
@@ -637,57 +509,21 @@ async def _orchestrate(
bot: Bot,
actor: Actor,
state: GraduationState,
super_draft_body: str,
super_draft_title: str,
super_draft_tags: list[str],
graduated_contents: str,
meta_file_sha: str,
) -> None:
"""Run §13.3 step by step. Each step:
"""Open the flip PR, then merge it. Two steps, no transaction:
- marks itself `running` and pushes an event
- calls the bot method (which writes to Gitea + audit log)
- marks itself `done` (or `failed`) and pushes another event
- open_pr fails nothing was created; the entry stays a super-draft.
- merge_pr fails close the open PR and delete its branch (the only
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
`_rollback` runs undoes in reverse from N-1 to 1.
There is no rollback of a *merged* flip once the meta-repo merge has
landed, the path forward is §3's `withdraw` (§13.5).
"""
try:
# ----- Step 1: create per-RFC repo -----
await _start(state, "create_repo", f"Creating `{state.repo_full}`…")
try:
await bot.create_rfc_repo_for_graduation(
actor, org=config.gitea_org, repo_name=state.repo_name,
slug=state.slug, title=super_draft_title,
)
except GiteaError as e:
await _fail(state, "create_repo", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at="create_repo")
return
await _done(state, "create_repo", state.repo_full)
# ----- Step 2: seed RFC.md, README.md, .rfc/metadata.yaml -----
await _start(state, "seed_files", "Writing initial commit on main…")
try:
await bot.seed_graduated_rfc(
actor,
org=config.gitea_org, repo_name=state.repo_name,
slug=state.slug, title=super_draft_title,
rfc_body=super_draft_body, rfc_id=state.rfc_id,
meta_full=config.meta_repo_full,
meta_path=f"rfcs/{state.slug}.md",
owners=state.owners, arbiters=state.arbiters,
tags=super_draft_tags,
)
except GiteaError as e:
await _fail(state, "seed_files", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at="seed_files")
return
await _done(state, "seed_files", "RFC.md, README.md, .rfc/metadata.yaml")
# ----- Step 3: open graduation PR -----
# ----- Step 1: open the graduation PR (flip frontmatter) -----
await _start(state, "open_pr", "Opening graduation PR…")
try:
pr = await bot.open_graduation_pr(
@@ -696,19 +532,18 @@ async def _orchestrate(
slug=state.slug,
new_file_contents=graduated_contents,
prior_sha=meta_file_sha,
rfc_id=state.rfc_id, repo_full=state.repo_full,
rfc_id=state.rfc_id,
owners=state.owners,
)
except GiteaError as e:
await _fail(state, "open_pr", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at="open_pr")
await _finish_failed(state, failed_at="open_pr", on_behalf_of=actor.gitea_login)
return
state.new_pr_number = pr["number"]
state.graduation_branch = pr["head"]["ref"]
await _done(state, "open_pr", f"PR #{state.new_pr_number}")
# ----- Step 4: merge the graduation PR -----
# ----- Step 2: merge the graduation PR -----
await _start(state, "merge_pr", f"Merging PR #{state.new_pr_number}")
try:
await bot.merge_graduation_pr(
@@ -720,35 +555,28 @@ async def _orchestrate(
)
except GiteaError as e:
await _fail(state, "merge_pr", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at="merge_pr")
await _cleanup_unmerged(config=config, bot=bot, actor=actor, state=state)
await _finish_failed(state, failed_at="merge_pr", on_behalf_of=actor.gitea_login)
return
await _done(state, "merge_pr", f"PR #{state.new_pr_number} merged")
# ----- Step 5: refresh the cache so the catalog flips immediately.
# Per §13.3 step 5 the webhook flow is the steady-state path, but
# we refresh inline so the dialog can transition to "graduation
# complete" with the catalog row already showing `active`. A
# cache-refresh failure does not unwind Git state — the
# reconciler will catch up per §4.1.
await _start(state, "refresh_cache", "Refreshing catalog and views…")
# Refresh the cache so the catalog flips immediately. The webhook
# flow is the steady-state path (§13.3); we refresh inline so the
# dialog can transition to "graduation complete" with the catalog
# row already showing `active`. A refresh failure does not unwind
# the merge — the reconciler catches up per §4.1.
try:
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
await cache.refresh_rfc_repo(config, gitea, state.slug)
except Exception as e:
log.warning("graduate refresh_cache failed for %s: %s", state.slug, e)
await _done(state, "refresh_cache", f"Cache will catch up via reconciler ({e})")
else:
await _done(state, "refresh_cache", "Catalog and main view updated")
log.warning("graduate cache refresh failed for %s: %s", state.slug, e)
# Terminal success row in the audit log.
_audit(
None, actor.gitea_login, "graduate_complete",
rfc_slug=state.slug,
details={
"rfc_id": state.rfc_id, "repo": state.repo_full,
"rfc_id": state.rfc_id,
"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()})
except Exception as e:
log.exception("graduate: unexpected error for %s", state.slug)
# Best-effort: mark the in-flight step failed, then roll back.
running = next((s for s in state.steps if s.status == "running"), None)
if running is not None:
await _fail(state, running.key, f"unexpected: {e}")
await _rollback(
config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at=running.key if running else "unknown",
await _finish_failed(
state, failed_at=running.key if running else "unknown",
on_behalf_of=actor.gitea_login,
)
finally:
# Push the sentinel so any open SSE handler returns.
await state.queue.put(None)
async def _rollback(
*,
config: Config, gitea: Gitea, bot: Bot, actor: Actor,
state: GraduationState, failed_at: str,
async def _cleanup_unmerged(
*, config: Config, bot: Bot, actor: Actor, state: GraduationState,
) -> None:
"""Run undoes in reverse order from the last completed step. Each
undo emits its own rollback-step event so the dialog can render the
cleanup as a visible step appended to the stack per §13.3."""
state.rollback_started = True
# Mark every step after the failed one as not-reached so the rendered
# stack is honest about what didn't run.
seen_failure = False
for s in state.steps:
if s.status == "failed":
seen_failure = True
continue
if seen_failure and s.status == "pending":
s.status = "not-reached"
# Walk completed steps in reverse and run their inverses.
for s in reversed(state.steps):
if s.status != "done":
continue
undo = _UNDO_BY_STEP.get(s.key)
if undo is None:
continue
rb = StepState(key=f"undo:{s.key}", label=f"Undo: {s.label}",
status="running", detail="")
state.rollback_steps.append(rb)
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
try:
detail = await undo(
config=config, gitea=gitea, bot=bot, actor=actor, state=state,
)
except Exception as e:
rb.status = "failed"
rb.detail = f"{e}"
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
continue
rb.status = "done"
rb.detail = detail or ""
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
_audit(
None, actor.gitea_login, "graduate_rollback",
rfc_slug=state.slug,
details={
"failed_at": failed_at,
"error": state.error,
"rfc_id": state.rfc_id,
"repo": state.repo_full,
"undone": [s.key for s in state.rollback_steps if s.status == "done"],
},
)
state.finished = True
state.succeeded = False
await state.queue.put({"event": "rolled_back", "payload": state.to_payload()})
async def _undo_create_repo(*, config, gitea, bot, actor, state) -> str:
await bot.delete_rfc_repo(
actor, org=config.gitea_org, repo_name=state.repo_name,
slug=state.slug, reason="graduation rollback",
)
return f"Deleted `{state.repo_full}`"
async def _undo_seed_files(*, config, gitea, bot, actor, state) -> str:
# The seed commits live inside the per-RFC repo created in step 1;
# deleting the repo (step 1's undo) reclaims them at the same time.
# We surface a separate rollback step here so the rendered stack
# mirrors the forward steps, but the work is folded into _undo_create_repo.
return "Folded into repo deletion"
async def _undo_open_pr(*, config, gitea, bot, actor, state) -> str:
"""A merge failure leaves the flip PR open on its `graduate-<slug>-<hex>`
branch. Close the PR and delete the branch so failed attempts don't
accumulate on the meta repo. Best-effort failures here are logged,
not surfaced as a separate step (the entry already stays a super-draft).
"""
if state.new_pr_number is None:
return "No PR opened"
await bot.close_graduation_pr(
actor,
org=config.gitea_org, meta_repo=config.meta_repo,
pr_number=state.new_pr_number,
head_branch=state.graduation_branch or "",
slug=state.slug, reason="graduation rollback",
)
# Per the §19.2 "graduation rollback's branch cleanup" candidate
# that Slice 8 settles: delete the dash-suffixed branch on rollback
# so failed-graduation branches don't accumulate on the meta repo.
# The §12 hygiene sweep would catch this eventually, but closing
# the loop here removes the chance of pile-up across retries.
return
try:
await bot.close_graduation_pr(
actor,
org=config.gitea_org, meta_repo=config.meta_repo,
pr_number=state.new_pr_number,
head_branch=state.graduation_branch or "",
slug=state.slug, reason="graduation merge failed",
)
except Exception:
log.exception("graduate cleanup: close PR #%s failed", state.new_pr_number)
branch_name = state.graduation_branch or ""
if branch_name:
try:
@@ -870,24 +627,35 @@ async def _undo_open_pr(*, config, gitea, bot, actor, state) -> str:
branch=branch_name,
slug=state.slug,
action_kind="delete_post_merge_branch",
reason="graduation rollback",
reason="graduation merge failed",
)
except Exception:
log.exception("rollback: delete_branch failed for %s", branch_name)
return f"Closed PR #{state.new_pr_number}"
log.exception("graduate cleanup: delete_branch %s failed", branch_name)
# merge_pr's undo is intentionally absent — once the meta-repo merge has
# landed, graduation is irreversible per §13.5. If we ever reach a merged
# state and a later step fails (which can't happen — refresh_cache failures
# fold into success), there is no clean undo path; the user transitions
# via §3's `withdraw` instead.
_UNDO_BY_STEP = {
"create_repo": _undo_create_repo,
"seed_files": _undo_seed_files,
"open_pr": _undo_open_pr,
}
async def _finish_failed(state: GraduationState, *, failed_at: str, on_behalf_of: str) -> None:
"""Mark any step after the failure as not-reached, write the audit
row, and emit the terminal failed event."""
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"
_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(
actor_user_id: int | None,
on_behalf_of: str,
on_behalf_of: str | None,
action_kind: str,
*,
rfc_slug: str | None = None,
@@ -918,7 +686,7 @@ def _audit(
"""Direct audit-log write for graduation lifecycle events that don't
correspond to a single Gitea write. The per-step Gitea writes log
themselves via the bot's `_log`; this is for the bracketing
`graduate_start` / `graduate_complete` / `graduate_rollback` rows."""
`graduate_start` / `graduate_complete` / `graduate_failed` rows."""
db.conn().execute(
"""
INSERT INTO actions
@@ -935,8 +703,7 @@ def _audit(
json.dumps(details) if details else None,
),
)
# §15 chokepoint per Slice 6: the bracket rows (graduate_start,
# graduate_complete) drive their own notifications per §15.1.
# §15 chokepoint: the bracket rows drive their own notifications.
from . import notify
notify.fan_out_from_action(
actor_user_id=actor_user_id,
+15 -11
View File
@@ -603,7 +603,7 @@ def make_router(
repo=repo,
slug=slug,
file_path=_file_path_for(rfc),
is_super_draft=_is_super_draft(rfc),
is_super_draft=_is_meta_resident(rfc),
original_branch=original_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-
collapse rule, a super-draft RFC also routes here its body-edit
PRs are meta-repo PRs with pr_kind='meta_body_edit', but the API
surface is identical."""
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)
if row["state"] not in ("active", "super-draft"):
raise HTTPException(409, f"RFC is {row['state']}")
if row["state"] == "active" and not row["repo"]:
raise HTTPException(409, "RFC has no repo")
return row
def _is_super_draft(rfc) -> bool:
return rfc["state"] == "super-draft"
def _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). Drives the body/path/repo dispatch below."""
return not rfc["repo"]
def _owner_repo(rfc) -> tuple[str, str]:
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
return config.gitea_org, config.meta_repo
owner, repo = rfc["repo"].split("/", 1)
return owner, repo
def _file_path_for(rfc) -> str:
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
return f"rfcs/{rfc['slug']}.md"
return RFC_FILE_PATH
def _extract_body(rfc, file_contents: str) -> str:
"""For super-draft entries the file on disk is the full
"""For meta-resident entries the file on disk is the full
frontmatter+body envelope; the editable body is entry.body."""
if not _is_super_draft(rfc):
if not _is_meta_resident(rfc):
return file_contents
try:
entry = entry_mod.parse(file_contents)
@@ -760,7 +764,7 @@ def make_router(
return row["original_pr_number"] if row else 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_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
+13 -136
View File
@@ -695,111 +695,7 @@ class Bot:
)
return sha
# ----- §13 graduation: per-step primitives and rollback inverses -----
async def create_rfc_repo_for_graduation(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
title: str,
) -> dict:
"""§13.3 step 1: create the per-RFC repo.
Empty repo (no auto-init) `seed_graduated_rfc` writes the first
commit on `main`. Returns the Gitea repo payload."""
repo = await self._gitea.create_org_repo(
org, repo_name, description=f"RFC: {title}"
)
_log(
actor,
"graduate_repo_create",
rfc_slug=slug,
details={"repo": f"{org}/{repo_name}", "title": title},
)
return repo
async def seed_graduated_rfc(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
title: str,
rfc_body: str,
rfc_id: str,
meta_full: str,
meta_path: str,
owners: list[str],
arbiters: list[str],
tags: list[str],
) -> str:
"""§13.3 step 2: seed RFC.md, README.md, .rfc/metadata.yaml on the
new repo's `main`. Three create_file calls; one audit row.
Returns the final commit sha on main.
"""
import yaml as _yaml
ae = actor.email or f"{actor.gitea_login}@users.noreply"
# 2a) RFC.md — the document. The super-draft's body is migrated
# verbatim per §13.3; if the body is empty we seed a minimal
# placeholder so the editor has something to render on first open.
body = rfc_body.strip() + "\n" if rfc_body.strip() else (
f"# {title}\n\n*RFC.md to be filled in — the super-draft graduated with an empty body.*\n"
)
rfc_msg = _stamp_single(f"Seed RFC.md from super-draft {slug}", actor)
rfc_result = await self._gitea.create_file(
org, repo_name, "RFC.md",
content=body, message=rfc_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
# 2b) README.md — header pointing back at the meta-repo entry.
readme = (
f"# {rfc_id}{title}\n\n"
f"This repository carries the canonical text of {rfc_id}.\n"
f"The meta-repo entry is `{meta_path}` in `{meta_full}`.\n\n"
f"The RFC body is in `RFC.md`. Contributions go through the\n"
f"app's §8 RFC view — open a branch, propose changes, land a PR.\n"
)
readme_msg = _stamp_single(f"Seed README.md for {rfc_id}", actor)
await self._gitea.create_file(
org, repo_name, "README.md",
content=readme, message=readme_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
# 2c) .rfc/metadata.yaml — mirror of meta-repo frontmatter for
# future tooling (linting, automation, CI lookups).
meta_yaml = _yaml.safe_dump(
{
"slug": slug, "title": title, "id": rfc_id,
"owners": owners, "arbiters": arbiters, "tags": list(tags),
},
sort_keys=False,
)
meta_msg = _stamp_single(f"Seed .rfc/metadata.yaml for {rfc_id}", actor)
meta_result = await self._gitea.create_file(
org, repo_name, ".rfc/metadata.yaml",
content=meta_yaml, message=meta_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
last_sha = (
meta_result.get("commit", {}).get("sha")
or rfc_result.get("commit", {}).get("sha")
or ""
)
_log(
actor,
"graduate_repo_seed",
rfc_slug=slug,
branch_name="main",
bot_commit_sha=last_sha,
details={"repo": f"{org}/{repo_name}", "rfc_id": rfc_id},
)
return last_sha
# ----- §13 graduation (meta-only): open + merge the flip PR -----
async def open_graduation_pr(
self,
@@ -811,13 +707,14 @@ class Bot:
new_file_contents: str,
prior_sha: str,
rfc_id: str,
repo_full: str,
owners: list[str],
) -> dict:
"""§13.3 step 3: open a PR against the meta repo that strips the
super-draft body and fills graduation frontmatter fields. Branch
name uses the `graduate-<slug>-<6hex>` shape dash-separated like
the other meta-repo branches per the §19.2 path-routing candidate.
"""§13.3 (meta-only): open a PR against the meta repo that flips the
entry's frontmatter to `state: active` with the integer `id` and
graduation stamps **keeping the body unchanged** (§1 meta-only
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
@@ -844,11 +741,11 @@ class Bot:
pr_body_text = (
f"Graduates super-draft `{slug}` to active.\n\n"
f"- ID: `{rfc_id}`\n"
f"- Repo: `{repo_full}`\n"
f"- Owners: {owners_str}\n\n"
f"The meta-repo entry becomes frontmatter-only; the canonical body\n"
f"moves to `RFC.md` in the new repo. The graduation sequence is\n"
f"transactional per §13.3."
f"This is an in-place state flip per the meta-only topology\n"
f"(SPEC §1, §13.3): the entry `rfcs/{slug}.md` keeps its body and\n"
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)
pr = await self._gitea.create_pull(
@@ -862,7 +759,7 @@ class Bot:
branch_name=branch,
pr_number=pr["number"],
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
@@ -912,27 +809,7 @@ class Bot:
details={"rfc_id": rfc_id},
)
# ----- §13.3 rollback inverses -----
async def delete_rfc_repo(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
reason: str,
) -> None:
"""Undo of `create_rfc_repo_for_graduation`. Records `graduate_repo_delete`
in the audit log with the rollback reason so the §13.3 stack's
rendered failure surface can be reconstructed from `actions`."""
await self._gitea.delete_repo(org, repo_name)
_log(
actor,
"graduate_repo_delete",
rfc_slug=slug,
details={"repo": f"{org}/{repo_name}", "reason": reason},
)
# ----- §13.3 (meta-only): cleanup of an unmerged flip PR -----
async def close_graduation_pr(
self,
+42 -4
View File
@@ -219,6 +219,19 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
open_pulls, closed_pulls = [], []
for pull in open_pulls + closed_pulls:
head_branch = pull.get("head", {}).get("ref", "")
# Same deleted-branch recovery as refresh_meta_pulls: a merged-and-
# deleted PR's `head.ref` collapses to `refs/pull/<N>/head`. Here
# the slug is known (param), so state still updates correctly and
# no ghost forms — but blindly storing the sentinel would clobber
# the real branch name api_prs.py relies on as a fallback ref when
# the merge commit is gone. Recover it from the stored row.
if not head_branch or head_branch.startswith("refs/pull/"):
prior = db.conn().execute(
"SELECT head_branch FROM cached_prs WHERE repo = ? AND pr_number = ?",
(repo_full, pull["number"]),
).fetchone()
if prior and prior["head_branch"]:
head_branch = prior["head_branch"]
state = _state_from_pull(pull)
gitea_opener = (pull.get("user") or {}).get("login") or ""
opened_by = _resolve_actor(
@@ -329,9 +342,13 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
if not slug:
continue
rfc = db.conn().execute(
"SELECT state FROM cached_rfcs WHERE slug = ?", (slug,)
"SELECT state, repo FROM cached_rfcs WHERE slug = ?", (slug,)
).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
edit_keys_seen.add((slug, name))
db.conn().execute(
@@ -352,7 +369,8 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
# diverges from this single point.
if meta_main_sha:
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()
for r in super_drafts:
db.conn().execute(
@@ -374,7 +392,8 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
SELECT b.rfc_slug, b.branch_name
FROM cached_branches b
JOIN cached_rfcs r ON r.slug = b.rfc_slug
WHERE r.state = 'super-draft'
WHERE r.repo IS NULL
AND r.state IN ('super-draft', 'active')
AND b.state != 'deleted'
AND b.branch_name != 'main'
"""
@@ -431,6 +450,25 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
for pull in open_pulls + closed_pulls:
head_branch = pull.get("head", {}).get("ref", "")
# A merged-and-deleted PR's branch is no longer reported by Gitea
# as its real name — the `head.ref` collapses to the synthetic
# `refs/pull/<N>/head` sentinel (or empty). The slug + kind both
# derive from the branch name, so a deleted branch would parse to
# slug=None and the row would be skipped forever, freezing the
# cached_prs row at its last-seen `state='open'` — a permanent
# ghost "pending idea" for an entry that has actually merged
# (caught when the operator authoring lane in ROADMAP #35 merged
# an idea PR with the branch deleted; the web UX leaves branches
# in place so it never tripped this). Recover the original branch
# from the row we already stored when the PR was open — that row
# retains the real `head_branch` (migration 002).
if not head_branch or head_branch.startswith("refs/pull/"):
prior = db.conn().execute(
"SELECT head_branch FROM cached_prs WHERE repo = ? AND pr_number = ?",
(repo_full, pull["number"]),
).fetchone()
if prior and prior["head_branch"]:
head_branch = prior["head_branch"]
slug = _slug_from_head_branch(head_branch)
if slug is None:
continue
+8 -6
View File
@@ -284,9 +284,10 @@ async def _delete_branch_via_bot(
reason: str,
) -> bool:
"""Call `bot.delete_branch` with the system actor. Resolves the
`(org, repo)` pair from the slug: super-draft edit branches and
graduation branches live on the meta repo; active-RFC branches
live on the per-RFC repo named by `cached_rfcs.repo`.
`(org, repo)` pair from the slug: under the meta-only topology (§1)
every meta-resident entry's edit branches and graduation branches
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
(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:
log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch)
return False
if rfc["state"] == "super-draft":
if not rfc["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)
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
try:
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")
assert r.status_code == 200, r.text
# --- 7. Graduate the super-draft. ---
# --- 7. Graduate the super-draft (in-place flip, §13). ---
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is True
d = client.get("/api/rfcs/ohm").json()
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
# per-RFC contributor invitation to cut a branch. In the
# 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
# The branch is gone from FakeGitea + cached row flipped.
assert active_branch not in fake.branches[("wiggleverse", "rfc-0001-ohm")]
# The branch is gone from FakeGitea (meta repo) + cached row flipped.
assert active_branch not in fake.branches[("wiggleverse", "meta")]
cached = db.conn().execute(
"SELECT state FROM cached_branches WHERE rfc_slug = 'ohm' AND branch_name = ?",
(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
FakeGitea from test_propose_vertical.py:
Graduation is an in-place state flip on the meta entry no per-RFC repo
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
round-trips already proven by Slice 1 and exercised in
test_claim_opens_meta_pr below for the §13.1 surface itself).
* Seed an owned super-draft (the §13.1 claim flow is exercised
separately in test_claim_opens_meta_pr).
* GET /api/rfcs/<slug>/graduate/check returns per-field validity for
the dialog.
* GET /api/rfcs/<slug>/blocking-prs returns the §9.8 precondition list.
* POST /api/rfcs/<slug>/graduate?_sync=1 runs the five-step sequence
inline. On success: per-RFC repo exists with RFC.md / README.md /
.rfc/metadata.yaml, meta-entry body is stripped, frontmatter is
graduated, cached_rfcs.state is 'active'.
* §9.8 precondition gate refuses the start when a body-edit PR is open.
* Rollback on a mid-sequence failure unwinds repo creation cleanly.
* §13.4 chat migration: whole-doc threads under (slug, 'main') survive
graduation unchanged the rfc_slug is the canonical key per §2.3,
so no data movement is needed.
* §9.8 pre-graduation history: the new RFC's /main response surfaces
edit-branch threads under `pre_graduation_history`.
the two-field dialog (integer id + owners; no repo name).
* POST /api/rfcs/<slug>/graduate?_sync=1 opens + merges the flip PR
inline. On success: NO per-RFC repo, the meta entry is `state:
active` with the body KEPT and `repo` null, cached_rfcs.state flips
to 'active'.
* An open body-edit PR no longer blocks graduation (§9.8) they
coexist.
* An open-PR failure leaves the entry a super-draft (nothing created);
a merge failure cleans up the half-open PR/branch and leaves the
entry a super-draft.
* §13.4: chat threads + edit branches stay put the slug is the
canonical key per §2.3, so nothing moves at the flip.
The orchestrator's `?_sync=1` seam awaits the sequence inline so the
test can assert post-conditions on the same event loop tick. Production
clients use the spec-described SSE shape via `/graduate/progress`.
The orchestrator's `?_sync=1` seam awaits the flip inline so the test can
assert post-conditions on the same event loop tick.
"""
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
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",
display_name="Ben", role="owner")
# Happy: a fresh RFC-0001 + rfc-0001-ohm repo name.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
# Happy: a fresh RFC-0001.
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
assert r.status_code == 200, r.text
d = r.json()
assert d["id"]["ok"] is True
assert d["repo"]["ok"] is True
assert d["owners"]["ok"] is True
assert d["blocking_prs"]["ok"] is True
assert d["can_submit"] is True
# No repo field in the meta-only check response.
assert "repo" not in d
# ID format error — non-numeric tail.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-abcd", "repo": "rfc-0001-ohm"})
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-abcd"})
d = r.json()
assert d["id"]["ok"] is False
assert d["can_submit"] is False
# Repo name pattern error — leading dot.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": ".bad"})
d = r.json()
assert d["repo"]["ok"] is False
def test_graduate_check_refuses_when_no_owners(app_with_fake_gitea):
"""An unclaimed super-draft fails the owners precondition; can_submit
flips false even with valid id+repo."""
flips false even with a valid id."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
# No owners — simulates an unclaimed super-draft.
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=[])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
d = r.json()
assert d["owners"]["ok"] is False
assert "No owners" in d["owners"]["error"]
assert d["can_submit"] is False
def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea):
"""The full §13.3 sequence: create repo, seed files, open PR, merge
PR, refresh cache. End state: cached_rfcs.state='active', the meta
entry's body is stripped, the per-RFC repo has RFC.md, the audit
log carries graduate_start graduate_complete bracketing the
per-step rows."""
def test_graduate_happy_path_flips_in_place_keeping_body(app_with_fake_gitea):
"""The meta-only flip: open + merge a frontmatter PR. End state:
cached_rfcs.state='active', the meta entry's body is KEPT, `repo` is
null, NO per-RFC repo exists, and the audit log carries graduate_start
graduate_pr_open graduate_pr_merge graduate_complete."""
from fastapi.testclient import TestClient
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(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0042", "repo_name": "rfc-0042-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0042", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
d = r.json()
assert d["finished"] is True
assert d["succeeded"] is True
assert d["repo"] == "wiggleverse/rfc-0042-ohm"
# 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.
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 entry on main: state flipped, body KEPT, repo null.
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
graduated = entry_mod.parse(meta_text)
assert graduated.state == "active"
assert graduated.id == "RFC-0042"
assert graduated.repo == "wiggleverse/rfc-0042-ohm"
assert graduated.repo is None
assert graduated.graduated_by == "ben"
assert graduated.graduated_at # non-empty ISO date
assert graduated.body.strip() == ""
# 5. cached_rfcs.state flipped to active via the inline refresh.
assert "Open Human Model is a framework" in graduated.body
# cached_rfcs flipped to active via the inline refresh; body intact.
cached = db.conn().execute(
"SELECT state, rfc_id, repo, body FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "active"
assert cached["rfc_id"] == "RFC-0042"
assert cached["repo"] == "wiggleverse/rfc-0042-ohm"
# cached body now mirrors RFC.md from the per-RFC repo.
assert cached["repo"] is None
assert "Open Human Model is a framework" in cached["body"]
# Audit log: graduate_start, graduate_repo_create, graduate_repo_seed,
# graduate_pr_open, graduate_pr_merge, graduate_complete, in order.
kinds = [
r["action_kind"]
for r in db.conn().execute(
row["action_kind"]
for row in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
for needed in ("graduate_start", "graduate_repo_create",
"graduate_repo_seed", "graduate_pr_open",
for needed in ("graduate_start", "graduate_pr_open",
"graduate_pr_merge", "graduate_complete"):
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):
"""§9.8: an open meta-repo body-edit PR against rfcs/<slug>.md blocks
graduation before the bot starts the sequence §13.3's rollback
complexity does not grow."""
def test_graduate_coexists_with_open_body_edit_pr(app_with_fake_gitea):
"""§9.8 (meta-only): an open meta-repo body-edit PR no longer blocks
graduation the body is kept, so they coexist. /check stays
submittable and the flip succeeds."""
from fastapi.testclient import TestClient
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")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
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")
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
# Cut an edit branch and open a body-edit PR (full Slice 4 path).
# Cut an edit branch and open a body-edit PR.
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
thread_id = view["main_thread_id"]
@@ -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",
json={"title": "Add harm", "description": "Adds harm dimension."},
).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",
display_name="Ben", role="owner")
r = client.get("/api/rfcs/ohm/blocking-prs")
items = r.json()["items"]
assert len(items) == 1
assert items[0]["pr_number"] == pr_number
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"}).json()
assert "blocking_prs" not in d
assert d["can_submit"] is True
# /check refuses can_submit.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
d = r.json()
assert d["blocking_prs"]["ok"] is False
assert d["can_submit"] is False
# POST refuses with 409 — the bot never starts the sequence.
# The flip succeeds — coexists with the open body-edit PR.
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
)
assert r.status_code == 409
assert "blocking graduation" in r.text or "block" in r.text
def test_graduate_rollback_on_step_2_seed_failure(app_with_fake_gitea):
"""Step 2 (seed files) fails partway → the orchestrator rolls back
step 1 (delete the repo) and records the rollback in the audit log.
The cached_rfcs row stays at 'super-draft'."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
from app.gitea import Gitea, GiteaError
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Monkey-patch the bot to fail on seed_graduated_rfc. The repo
# has already been created in step 1; the rollback must delete it.
orig_seed = Bot.seed_graduated_rfc
async def boom(self, *args, **kwargs):
raise GiteaError(500, "simulated seed failure for rollback test")
Bot.seed_graduated_rfc = boom
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0003", "repo_name": "rfc-0003-ohm",
"owners": ["ben"]},
)
finally:
Bot.seed_graduated_rfc = orig_seed
assert r.status_code == 200, r.text
d = r.json()
assert d["finished"] is True
assert d["succeeded"] is False
# Repo deleted as the rollback inverse.
assert ("wiggleverse", "rfc-0003-ohm") not in fake.repos
# Meta entry unchanged.
assert r.json()["succeeded"] is True
cached = db.conn().execute(
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "super-draft"
assert cached["rfc_id"] is None
# Audit log carries the rollback row.
kinds = [
r["action_kind"]
for r in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
assert "graduate_start" in kinds
assert "graduate_repo_create" in kinds
assert "graduate_repo_delete" in kinds
assert "graduate_rollback" in kinds
assert "graduate_complete" not in kinds
assert cached["state"] == "active"
def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
"""Step 3 (open PR) fails → the orchestrator rolls back steps 2 and
1 (deleting the repo, which reclaims the seed commits at the same
time). The meta-repo entry is untouched."""
def test_graduate_open_pr_failure_leaves_super_draft(app_with_fake_gitea):
"""An open-PR failure creates nothing — the entry stays a super-draft
with its body intact and no graduation PR on the meta repo."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
@@ -387,19 +310,92 @@ def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0007", "repo_name": "rfc-0007-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0007", "owners": ["ben"]},
)
finally:
Bot.open_graduation_pr = orig_open_pr
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is False
# Repo torn down.
assert ("wiggleverse", "rfc-0007-ohm") not in fake.repos
# Meta entry's body still has the pitch (not stripped).
# Entry untouched: still super-draft, body intact on main.
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"]
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):
"""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",
display_name="Ben", role="owner")
# Seed a synthetic in-flight state so the registry refuses the second.
st = api_graduation._new_active(
"ohm", rfc_id="RFC-0001", repo_name="rfc-0001-ohm",
repo_full="wiggleverse/rfc-0001-ohm", owners=["ben"], arbiters=["ben"],
"ohm", rfc_id="RFC-0001", owners=["ben"], arbiters=["ben"],
)
st.finished = False
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
)
assert r.status_code == 409
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):
"""§13.4: chat threads on the super-draft's canonical-body view
(`branch_name='main'`) are interpreted as the new RFC's main-thread
after graduation. The rows don't move — the rfc_slug is canonical
per §2.3 so the same thread surfaces from both before and after
the graduation."""
"""§13.4: chat threads on the entry's main view (`branch_name='main'`)
stay put across the flip the rfc_slug is canonical per §2.3 so the
same thread surfaces from /branches/main before and after graduation."""
from fastapi.testclient import TestClient
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",
display_name="Ben", role="owner")
# Materialize a whole-doc main thread + a message on it. This
# mirrors what reading the canonical-body view would create
# lazily (§8.12 / api_branches._ensure_branch_chat_thread).
cur = db.conn().execute(
"""
INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, created_by)
@@ -466,32 +454,26 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git
(thread_id,),
)
# Graduate.
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0099", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
# The thread row's identity is unchanged.
row = db.conn().execute(
"SELECT id, branch_name FROM threads WHERE id = ?", (thread_id,),
).fetchone()
assert row["branch_name"] == "main"
# The new RFC's main view surfaces the same thread id as its
# whole-doc main thread (the entry is now active, the branch
# 'main' now points at the per-RFC repo's main, but the
# `(rfc_slug, branch_name)` key remains the canonical anchor).
r = client.get("/api/rfcs/ohm/branches/main")
assert r.status_code == 200, r.text
assert r.json()["main_thread_id"] == thread_id
def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea):
"""§9.8: after graduation, threads on meta-repo edit branches stay
attached to their original branch_name and surface from the new
RFC's /main response under `pre_graduation_history`."""
def test_edit_branch_surfaces_normally_after_graduation(app_with_fake_gitea):
"""§13.4 (meta-only): after graduation an edit branch is a *current*
branch of the now-active RFC it surfaces in the normal `branches`
list, and there is no separate `pre_graduation_history` set (that
affordance is legacy per-repo only)."""
from fastapi.testclient import TestClient
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")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
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")
# Alice cuts an edit branch and starts chatting on it.
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
@@ -513,30 +493,25 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
db.conn().execute(
"""
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,),
)
# Ben graduates.
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0100", "repo_name": "rfc-0100-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0100", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
# /main on the now-active RFC surfaces the pre-graduation history.
r = client.get("/api/rfcs/ohm/main")
d = r.json()
d = client.get("/api/rfcs/ohm/main").json()
assert d["state"] == "active"
hist = d["pre_graduation_history"]
assert len(hist) >= 1
assert any(h["branch_name"] == branch for h in hist)
target = next(h for h in hist if h["branch_name"] == branch)
assert target["message_count"] >= 1
# The edit branch is a current branch; no pre-graduation hop.
assert d["pre_graduation_history"] == []
assert any(b["name"] == branch for b in d["branches"]), \
f"edit branch not in branches: {[b['name'] for b in d['branches']]}"
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()
assert d["branch_name"] == "claim/ohm"
# The PR body's diff carries Alice in owners.
text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"]
ent = entry_mod.parse(text)
assert "alice" in ent.owners
# cached_prs records pr_kind='meta_claim' via refresh_meta_pulls.
row = db.conn().execute(
"SELECT pr_kind FROM cached_prs WHERE pr_number = ?", (d["pr_number"],),
).fetchone()
+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):
"""§19.2 candidate Slice 8 settles: when graduation rolls back
after step 3 (open_pr), the `graduate-<slug>-<6hex>` branch is
deleted alongside the PR close so failed-graduation branches
don't accumulate on the meta repo across retries."""
"""Meta-only (§13.3): when the flip's merge fails after the PR is
open, the orchestrator closes the PR and deletes its
`graduate-<slug>-<6hex>` branch so failed attempts don't accumulate
on the meta repo across retries."""
from fastapi.testclient import TestClient
from app import db
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",
display_name="Ben", role="owner")
# Force a step-4 (merge_pr) failure so step 3 (open_pr) has
# already landed and the rollback exercises the branch cleanup.
# Force a merge_pr failure so the flip PR (open_pr) has already
# landed and the cleanup exercises the branch deletion.
orig_merge = Bot.merge_graduation_pr
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
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0099", "owners": ["ben"]},
)
finally:
Bot.merge_graduation_pr = orig_merge
assert r.status_code == 200, r.text
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")]
graduation_branches = [n for n in meta_branches if n.startswith("graduate-ohm-")]
assert graduation_branches == [], (
+91
View File
@@ -577,6 +577,97 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
assert ("merge_proposal", "ben") in kinds
def test_merged_idea_pr_with_deleted_branch_clears_proposal(app_with_fake_gitea):
"""Regression: a merged idea PR whose branch was deleted must not
linger as a 'pending idea' ghost.
Found via the ROADMAP #35 operator authoring lane: merging an idea
PR from the CLI with `--delete-branch` makes Gitea report the PR's
`head.ref` as the synthetic `refs/pull/<N>/head` sentinel instead of
`propose/<slug>`. `refresh_meta_pulls` derives the slug from the
branch name, so the sentinel parsed to slug=None, the row was skipped,
and `cached_prs.state` stayed frozen at 'open' leaving the entry
showing as BOTH a super-draft (cached_rfcs reconciled off the push)
AND a pending idea (cached_prs never updated). The fix recovers the
original branch name from the already-stored cached_prs row.
The web UX never tripped this because it leaves the branch in place
(the repo's default_delete_branch_after_merge is false).
The bug only manifests on an out-of-band merge (the PR merged +
branch deleted directly in Gitea, with the in-app merge endpoint never
reconciling the row while the branch still existed) -- which is exactly
what the #35 CLI lane does. An in-app merge reconciles cached_prs to
'merged' before the branch is gone, so it never trips this; the test
therefore drives the Gitea state directly to reproduce the CLI path.
"""
from fastapi.testclient import TestClient
from app import db, cache, gitea as gitea_mod
from app.config import load_config
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
r = client.post("/api/rfcs/propose", json={
"title": "Informed Consent",
"slug": "informed-consent",
"pitch": "A first-class definition of consent in OHM.",
"tags": [],
})
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
# The proposal is cached as an open idea PR.
items = client.get("/api/proposals").json()["items"]
assert any(i["pr_number"] == pr_number for i in items)
# Out-of-band CLI merge (ROADMAP #35 lane): the PR is merged AND
# its branch deleted directly in Gitea, WITHOUT the in-app merge
# endpoint ever running. So cached_prs still says state='open' and
# Gitea now reports the merged PR's head.ref as the sentinel. This
# is the exact state `rfc-authoring.sh pr-merge --delete-branch`
# leaves behind.
for pr in fake.pulls[("wiggleverse", "meta")]:
if pr["number"] == pr_number:
# land the file on main (the push side already reconciles
# cached_rfcs into a super-draft via the webhook/sweep)
for (o, rp, br, p), data in list(fake.files.items()):
if (o, rp, br) == ("wiggleverse", "meta", "propose/informed-consent"):
fake.files[("wiggleverse", "meta", "main", p)] = dict(data)
pr["state"] = "closed"
pr["merged"] = True
pr["merged_at"] = "2026-05-29T12:13:00Z"
pr["closed_at"] = "2026-05-29T12:13:00Z"
pr["merge_commit_sha"] = fake._next_sha()
pr["head"]["ref"] = f"refs/pull/{pr_number}/head"
fake.branches[("wiggleverse", "meta")].pop("propose/informed-consent", None)
# The reconcile sweep runs (a later webhook, or the 5-min safety net).
import asyncio
cfg = load_config()
gclient = gitea_mod.Gitea(cfg)
asyncio.run(cache.refresh_meta_repo(cfg, gclient))
asyncio.run(cache.refresh_meta_pulls(cfg, gclient))
# The bug: this used to still list informed-consent (frozen 'open'
# row, slug unparseable from the sentinel). The fix recovers the
# stored branch name, so the row reconciles to merged and the ghost
# is gone.
assert client.get("/api/proposals").json()["items"] == []
# And the cached_prs row is correctly merged, not a frozen 'open'.
row = db.conn().execute(
"SELECT state FROM cached_prs WHERE pr_number = ?", (pr_number,)
).fetchone()
assert row["state"] == "merged", f"expected merged, got {row['state']}"
# The super-draft itself is unaffected — still in the catalog.
items = client.get("/api/rfcs").json()["items"]
assert any(i["slug"] == "informed-consent" and i["state"] == "super-draft" for i in items)
def test_slug_uniqueness_enforced(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.29.0",
"version": "0.31.1",
"type": "module",
"scripts": {
"dev": "vite",
+46 -8
View File
@@ -542,7 +542,10 @@
font-size: var(--text-md); font-weight: 600; text-decoration: none;
}
.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; }
/* v0.8.0 thin "your beta access is in review" banner. Shown on every
@@ -1607,12 +1610,18 @@
/* ---- §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 {
position: relative; background: transparent; border: 1px solid var(--c-gray-200);
border-radius: var(--radius-md); padding: 4px 10px; cursor: pointer; font-size: var(--text-lg);
margin-right: 12px;
position: relative; display: inline-flex; align-items: center; justify-content: center;
background: transparent; border: none;
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 {
position: absolute; top: -6px; right: -6px;
background: #dc2626; color: white; font-size: var(--text-2xs);
@@ -1856,7 +1865,7 @@
}
.settings-table th, .admin-table th {
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;
border-bottom: 1px solid var(--c-gray-200);
}
@@ -1937,7 +1946,21 @@
.admin-tab-header h2 {
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 {
font-size: var(--text-base); text-transform: uppercase;
letter-spacing: 0.05em; color: var(--c-gray-500);
@@ -1977,8 +2000,23 @@
.allowlist-add .btn-primary:hover:not(:disabled) { background: var(--c-gray-700); }
.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); }
/* "(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 {
display: inline-flex; align-items: center; gap: 6px;
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
in two clicks. Anonymous viewers see it too. */}
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
Philosophy
About
</Link>
<Link to="/docs" className="header-about" title="User guide">
Docs
+5 -4
View File
@@ -530,18 +530,19 @@ export async function listBlockingPRs(slug) {
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()
if (id != null) params.set('id', id)
if (repo != null) params.set('repo', repo)
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/graduate/check?${params}`))
}
export async function startGraduation(slug, { rfcId, repoName, owners }) {
export async function startGraduation(slug, { rfcId, owners }) {
const res = await fetch(`/api/rfcs/${slug}/graduate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rfc_id: rfcId, repo_name: repoName, owners }),
body: JSON.stringify({ rfc_id: rfcId, owners }),
})
return jsonOrThrow(res)
}
+45 -22
View File
@@ -181,7 +181,20 @@ function UsersTab() {
return (
<div className="admin-tab">
<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">
The pending bucket is the beta-access review queue (§6.1 /
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
user's ability to write without silencing them.
</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>
{error && <p className="settings-note warning">{error}</p>}
{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
// invites.
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 (
<>
<tr>
<td>
<div className="user-cell">
<span className="user-handle">{handle}</span>
{pendingInvite && (
<span
className="invite-badge"
title={`Admin-created invite; expires ${pendingInvite.expires_at}`}
>(pending invite)</span>
)}
<div className="user-cell-handle">
<span className="user-handle">{handle}</span>
{pendingInvite && (
<span
className="invite-badge"
title={`Admin-created invite; expires ${pendingInvite.expires_at}`}
>pending invite</span>
)}
</div>
<span className="muted">
{fullName || u.display_name}
{u.email ? ` · ${u.email}` : ''}
{showEmail ? ` · ${u.email}` : ''}
</span>
</div>
</td>
@@ -317,8 +325,8 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
<span className="muted">N/A</span>
)}
</td>
<td className="muted">{u.created_at || '—'}</td>
<td className="muted">{u.last_seen_at || '—'}</td>
<TimeCell value={u.created_at} />
<TimeCell value={u.last_seen_at} />
</tr>
{state === 'pending' && u.beta_request_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 }) {
const state = u.permission_state || 'granted'
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)
// with debounced server-side validation per §13.2 and a precondition
// popover backed by /blocking-prs for the §9.8 open-body-edit-PR gate.
//
// On confirm, opens the §13.3 SSE stream and renders the five named
// steps with per-step states. On failure, the rollback step's events
// append to the stack and a "What happened" panel renders below until
// the admin dismisses it.
// Meta-only topology (SPEC §1): graduation is an in-place state flip on
// the meta entry no per-RFC repo is created and the body is kept. The
// dialog renders two editable fields (integer ID + initial owners) with
// debounced server-side validation per §13.2. On confirm it opens the
// §13.3 SSE stream and renders the two named steps (open the flip PR,
// merge it). There is no rollback step and no repo-name field.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
graduateCheck,
listBlockingPRs,
openGraduationProgress,
startGraduation,
} from '../api'
const CHECK_DEBOUNCE_MS = 250
const STEP_KEY_ORDER = ['create_repo', 'seed_files', 'open_pr', 'merge_pr', 'refresh_cache']
const STEP_KEY_ORDER = ['open_pr', 'merge_pr']
export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
// Suggest defaults from the catalog.
const suggestedId = useMemo(() => suggestNextRfcId(entry?.allKnownIds || []), [entry])
const [rfcId, setRfcId] = useState(suggestedId)
const [repoName, setRepoName] = useState(`rfc-${stripPrefix(suggestedId)}-${slug}`)
const [owners, setOwners] = useState(entry?.owners?.length ? entry.owners : [])
const [newOwner, setNewOwner] = useState('')
const [checkResult, setCheckResult] = useState(null)
const [blockingPRs, setBlockingPRs] = useState([])
const [precondPopover, setPrecondPopover] = useState(false)
const [phase, setPhase] = useState('idle') // idle | running | done | rolled_back | error
const [phase, setPhase] = useState('idle') // idle | running | done | failed | error
const [streamState, setStreamState] = useState(null)
const [submitError, setSubmitError] = useState(null)
const esRef = useRef(null)
// Initial blocking-PRs probe + ongoing /check polling.
useEffect(() => {
listBlockingPRs(slug).then(({ items }) => setBlockingPRs(items || [])).catch(() => {})
}, [slug])
useEffect(() => {
const t = setTimeout(() => {
graduateCheck(slug, { id: rfcId, repo: repoName })
graduateCheck(slug, { id: rfcId })
.then(setCheckResult)
.catch(() => {})
}, CHECK_DEBOUNCE_MS)
return () => clearTimeout(t)
}, [slug, rfcId, repoName])
}, [slug, rfcId])
useEffect(() => () => { esRef.current?.close() }, [])
const idError = checkResult?.id?.error || null
const repoError = checkResult?.repo?.error || null
const ownersOk = owners.length > 0
const ownersError = ownersOk ? null : 'Add at least one initial owner'
const blockingError = blockingPRs.length > 0
? `${blockingPRs.length} open body-edit PR${blockingPRs.length === 1 ? '' : 's'} blocking graduation`
: null
// First-blocker tooltip text per §13.2.
const firstBlocker = idError || repoError || ownersError || blockingError
const canSubmit = !firstBlocker && phase === 'idle' && checkResult?.id?.ok && checkResult?.repo?.ok
const firstBlocker = idError || ownersError
const canSubmit = !firstBlocker && phase === 'idle' && checkResult?.id?.ok && ownersOk
const handleAddOwner = useCallback(() => {
const v = newOwner.trim().toLowerCase()
@@ -81,7 +66,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
setSubmitError(null)
setPhase('running')
try {
await startGraduation(slug, { rfcId, repoName, owners })
await startGraduation(slug, { rfcId, owners })
} catch (err) {
setPhase('idle')
setSubmitError(err.message)
@@ -96,7 +81,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
// Short hold per §13.3, then dismiss.
setTimeout(() => onCompleted?.(payload), 1500)
} else {
setPhase('rolled_back')
setPhase('failed')
}
}
},
@@ -105,7 +90,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
setPhase('error')
},
})
}, [slug, rfcId, repoName, owners, onCompleted])
}, [slug, rfcId, owners, onCompleted])
// ----- Render -----
@@ -122,10 +107,10 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
{!showStack && (
<div className="modal-body">
<p className="modal-intro">
§13: graduate the super-draft to its own repo. The meta-repo entry
becomes frontmatter-only; the canonical body moves to `RFC.md` in
the new repo. The sequence runs as five transactional steps with
rollback per §13.3.
§13: graduate the super-draft to active. This is an in-place
state flip the entry keeps its body and stays in the meta
repo; only the frontmatter changes (state, integer ID, and the
graduation stamps). No new repository is created.
</p>
<div className="form-row">
@@ -141,19 +126,6 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
{idError && <p className="field-error">{idError}</p>}
</div>
<div className="form-row">
<label>Repo name</label>
<input
type="text"
value={repoName}
onChange={(e) => setRepoName(e.target.value.trim())}
placeholder="rfc-NNNN-slug"
disabled={phase !== 'idle'}
/>
<p className="field-help">Becomes `&lt;org&gt;/{repoName || 'rfc-…'}` on Gitea.</p>
{repoError && <p className="field-error">{repoError}</p>}
</div>
<div className="form-row">
<label>Initial owners</label>
<div className="owner-list">
@@ -189,68 +161,24 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
</div>
{ownersError && <p className="field-error">{ownersError}</p>}
</div>
{blockingPRs.length > 0 && (
<div className="precondition-block">
<button
type="button"
className="precondition-toggle"
onClick={() => setPrecondPopover(p => !p)}
>
{blockingPRs.length} open body-edit PR{blockingPRs.length === 1 ? '' : 's'} blocking graduation
&nbsp;{precondPopover ? '▾' : '▸'}
</button>
{precondPopover && (
<div className="precondition-popover">
{blockingPRs.map(pr => (
<div key={pr.pr_number} className="precondition-row">
<div className="precondition-row-main">
<strong>PR #{pr.pr_number}</strong> {pr.title || '(no title)'}
<div className="precondition-row-meta">
{pr.author ? `by @${pr.author}` : ''}
{pr.last_activity_at ? ` · ${pr.last_activity_at.slice(0, 10)}` : ''}
</div>
</div>
<div className="precondition-row-actions">
<a
className="btn-link"
href={`/rfc/${slug}/pr/${pr.pr_number}`}
target="_blank"
rel="noreferrer"
>Open </a>
</div>
</div>
))}
<p className="precondition-help">
§9.8: open body-edit PRs would attempt to re-introduce a
body to a frontmatter-only entry after step 3. Resolve
them (merge or withdraw) and re-open this dialog.
</p>
</div>
)}
</div>
)}
</div>
)}
{showStack && (
<div className="modal-body">
<StepStack
steps={streamState?.steps || []}
rollbackSteps={streamState?.rollback_steps || []}
/>
{phase === 'rolled_back' && (
<StepStack steps={streamState?.steps || []} />
{phase === 'failed' && (
<div className="what-happened">
<h3>What happened</h3>
<p>
The graduation could not complete. The app rolled back the
steps that had already run; nothing was left half-applied on
Gitea. Error: <code>{streamState?.error || 'unknown'}</code>.
The graduation could not complete. Because it is a single
in-place flip, nothing was left half-applied `{slug}` stays
a super-draft. Error: <code>{streamState?.error || 'unknown'}</code>.
</p>
<p>
Read the failure detail next to the red step above. Resolve
the underlying cause (a repo-name collision, a network flake,
a concurrent PR landing on `rfcs/{slug}.md`) and try again.
Read the failure detail next to the red step above, resolve
the underlying cause (a concurrent PR landing on{' '}
`rfcs/{slug}.md`, a network flake), and try again.
</p>
</div>
)}
@@ -258,9 +186,8 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
<div className="graduation-complete">
<h3>Graduation complete</h3>
<p>
`{slug}` is now active as <strong>{streamState?.rfc_id}</strong>{' '}
at <code>{streamState?.repo_full}</code>. The catalog and the
RFC view reflect the new state.
`{slug}` is now active as <strong>{streamState?.rfc_id}</strong>.
The catalog and the RFC view reflect the new state.
</p>
</div>
)}
@@ -277,23 +204,23 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
disabled={!canSubmit}
title={canSubmit ? '' : firstBlocker || ''}
>
Graduate to RFC repo
Graduate
</button>
</>
)}
{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>
)}
{phase === 'done' && (
<button className="btn-primary" onClick={() => onCompleted?.(streamState)}>
View the new RFC
View the RFC
</button>
)}
</div>
{submitError && phase !== 'rolled_back' && (
{submitError && phase !== 'failed' && (
<div className="modal-error">Error: {submitError}</div>
)}
</div>
@@ -302,14 +229,10 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
}
function StepStack({ steps, rollbackSteps }) {
function StepStack({ steps }) {
return (
<div className="step-stack">
{steps.map(s => <StepRow key={s.key} step={s} />)}
{rollbackSteps.length > 0 && (
<div className="rollback-divider">Rollback</div>
)}
{rollbackSteps.map(s => <StepRow key={s.key} step={s} />)}
</div>
)
}
@@ -350,8 +273,3 @@ function suggestNextRfcId(existing) {
const next = used.size === 0 ? 1 : (Math.max(...used) + 1)
return `RFC-${String(next).padStart(4, '0')}`
}
function stripPrefix(rfcId) {
return rfcId?.startsWith('RFC-') ? rfcId.slice(4) : rfcId
}