Compare commits

..

10 Commits

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 06:56:14 -07:00
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
Ben Stull 8a94e26f75 Merge pull request 'Release v0.29.0: #28 Parts 2+3 — create-RFC offers + contribute-to-pending requests' (#3) from feature/v0.29.0-pr-rfc-create-contribute into main 2026-05-29 03:11:02 +00:00
Ben Stull 3c9109c392 v0.29.0: #28 Parts 2+3 — create-RFC offers + contribute-to-pending requests
Extends the v0.26.0 (#28 Part 1) read-time scanner into three buckets in
one pass — active link (Part 1), pending-RFC contribute offer (Part 3),
create-RFC offer (Part 2) — precedence active > pending > candidate. The
backend still emits only structured segments (never HTML), so the surface
stays XSS-safe by construction.

Part 2 — create-RFC offers: a multi-word tag from the #27 taxonomy with no
defining RFC renders, for a create-rights viewer, as an inline "+ create
RFC" affordance that opens the propose modal pre-filled (?propose=<term>;
ProposeModal gained initialTitle). Conservative multi-word gate; broader
heuristics + the Haiku path are deferred.

Part 3 — contribute-to-pending offers: a term matching a super-draft
renders, for a signed-in non-owner, an "ask to contribute" affordance with
the owner's display name. It opens a 3-field request form (who/why/optional
use-case); submitting lands a contribution_requests row (migration 024) and
one actionable §15 notification per owner (new kind
contribution_request_on_pending_rfc, personal-direct). The owner's inbox
shows who/why/use-case inline with Accept/Decline. Accept fires #12's
owner-invite flow with the requester as invitee and echoes a notification
back; decline notifies the requester. Pre-merge idea PRs are out of scope.

New endpoints: GET /api/rfcs/{slug}/contribution-target,
POST /api/rfcs/{slug}/contribution-requests,
.../{id}/accept, .../{id}/decline. The invite issue path was refactored
into one reusable api_invitations.issue_invitation(...) chokepoint shared
by the manual invite endpoint and Part 3's accept.

Tests: 9 new (3 scanner-bucket unit + 6 e2e). Full suite 374 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:10:13 -07:00
Ben Stull 019c8a9185 Merge pull request 'Release v0.28.0: security-audit-0026 I3 + I4 (HTML-email guard + async Turnstile siteverify)' (#2) from feature/v0.28.0-email-turnstile-async into main 2026-05-29 02:42:05 +00:00
Ben Stull 79a447c77b Release v0.28.0: security-audit-0026 I3 + I4 (HTML-email guard + async Turnstile)
Two informational findings from the Session-0026 audit, both
framework-internal defense-in-depth. No operator action: no migration,
no schema/config/overlay change, no deployment-facing surface.

- I3: guard the dead text/html branch in email_envelope.build_envelope.
  No send path passes body_html; the unused branch would emit HTML built
  from possibly-unescaped user content (C1 stored-XSS class in the mail
  channel). Passing body_html now raises NotImplementedError; the arg is
  kept for documented future symmetry, enabling HTML mail becomes a
  deliberate escape-then-unguard change.

- I4: make turnstile.verify_token async. The sync httpx.post ran inside
  the async /auth/otc/request handler, blocking the event loop up to the
  10s timeout on a slow CloudFlare call. It now awaits httpx.AsyncClient
  via a narrow _siteverify_post seam (tests patch the seam, not the
  shared AsyncClient). The sole caller (main.py) now awaits it.

Tests: full backend suite 365 passed. Added a coroutine-contract unit
test for verify_token and flipped the email_envelope HTML test to assert
the guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 19:12:16 -07:00
40 changed files with 2879 additions and 1303 deletions
+302
View File
@@ -23,6 +23,308 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond release's steps in order — no A-to-B path is pre-computed beyond
that. that.
## 0.31.2 — 2026-05-29
**Patch — landing (`/`) welcome panel spacing. Visual only: CSS in
`App.css` (`.welcome`). A plain frontend rebuild applies it.**
The welcome read-view was jammed against the catalog divider with no
top offset and loose, uneven paragraph spacing. Root cause: `.main-pane`
carries a bare `.main-pane { padding: 0; display: flex }` override (the
§8 three-column RFC shell) that shadows the earlier padded read-view
rule, so the pane provides no padding — and `.welcome` (just
`max-width`) never compensated. The welcome surface now owns its own
breathing room: 56px top / 48px side gutters, a capped 680px measure,
a stronger `text-3xl` "Welcome." hero, and even `--space-8` paragraph
rhythm at `--leading-relaxed`. Applies to both the signed-out and
signed-in welcome (same `.welcome` class).
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
## 0.31.1 — 2026-05-29
**Patch — admin Users tab + header UX polish. Visual only: CSS plus
markup/structure in `Admin.jsx` (no API, schema, config, overlay, or
secret change). A plain frontend rebuild applies it.**
Two latent CSS defects fixed:
- **`.invite-badge` had no rule.** The "(pending invite)" marker on
admin-created-but-unclaimed user rows rendered as bare parenthetical
text. It's now a quiet amber pill, consistent with the other status
badges.
- **`.btn-link-quiet` never reset native button chrome.** Used as a
bare link-style `<button>` (admin Revoke / Grant / Remove, the modal
close ×, and link-buttons in Login / BetaPending), it kept the
browser's default grey button box. The reset that the `.otc-login`
scope already carried is folded into the base rule, so every
`btn-link-quiet` is now a true quiet link.
Users-tab cleanups, all token-based:
- Table column headers no longer wrap (`WRITE-MUTED` was breaking onto
two lines); timestamps render as an intentional date-over-time stack
instead of a ragged mid-value wrap; the duplicated email in a row's
subline (the handle already *is* the email when there's no Gitea
login) is de-duplicated; the "Create user + invite" action moves
flush-right beside the title; inline DB-column references in the
intro copy read as quiet chips.
Header:
- **Inbox (§15.2) trigger restyled for the dark header.** It carried a
light-surface treatment — a `gray-200` border and a `gray-50` hover —
that rendered as a pale box in the nav and went white-background /
white-icon (invisible) on hover. It now speaks the nav-link
vocabulary (`.header-about` et al.): borderless, `gray-300` icon
brightening to white on a faint translucent hover, unread badge
unchanged.
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
## 0.31.0 — 2026-05-29
**Minor — meta-only repository topology (SPEC §1, ROADMAP #36). RFCs no
longer graduate into their own Gitea repositories; every RFC lives in
its meta-repo entry (`rfcs/<slug>.md`) for its whole life, and
graduation is an in-place `super-draft → active` state flip that keeps
the body in the entry. The per-RFC-repo machinery — repo creation,
`RFC.md`/`README.md`/`.rfc/metadata.yaml` seeding, body-strip, the
five-step transactional sequence and its rollback — is removed. This is
the framework change behind a much simpler deployer story: one content
repository, every RFC under `rfcs/`.**
What changed, concretely:
- **Graduation is a single flip.** `POST /api/rfcs/<slug>/graduate` now
opens one meta-repo PR that re-serializes the entry with
`state: active`, the integer `id`, and `graduated_at`/`graduated_by`
**body unchanged, `repo` left null** — then auto-merges it. No repo
is created, nothing is seeded, and there is no rollback (an open- or
merge-failure leaves the entry a super-draft; a failed merge's PR and
branch are cleaned up). The Graduate dialog drops the **Repo name**
field (two fields now: integer ID + owners) and the progress stack is
two steps (`open_pr`, `merge_pr`).
- **Active RFCs edit on the meta repo.** Branch/PR/chat dispatch keys on
meta-residency (`repo IS NULL`) rather than `state == 'super-draft'`,
so an active RFC's branches, body-edit PRs, threads, flags, and
`changes` all live on the meta repo exactly as a super-draft's do.
`promote-to-branch` names an active RFC's auto-branch
`edit-<slug>-<hex>` so the shared-repo cache can attribute it.
- **Open body-edit PRs no longer block graduation** (§9.8) — the body is
kept, so they coexist with the flip.
- **`refresh_rfc_repo` and the per-RFC read path are dead** for
meta-only entries (the reconciler only sweeps entries with a non-null
`repo`, of which there are none after the fold-back below).
**Upgrade steps:**
- Deployments **MUST** fold any already-graduated per-RFC-repo RFC back
into its meta entry before/with this deploy: restore the per-RFC
`RFC.md` body into `rfcs/<slug>.md`, set `repo: null` (keep
`state: active` and the integer `id`), and archive the per-RFC repo.
An entry left with a non-null `repo` keeps using the retained legacy
read path, but **no new** per-RFC repos are ever created. For OHM,
RFC-0001 `human` was folded back in driver session 0041.0 (§13.6).
- No schema migration, no new config, no new secret, no overlay change.
A plain code deploy applies it; the running reconciler reconciles the
catalog on its next sweep (≤5 min).
- The `repo:` frontmatter field and the `/api/rfcs/<slug>/blocking-prs`
endpoint are **retained** (the field for schema stability + legacy
entries; the endpoint as an informational, non-blocking surface), so
no client contract is removed — `graduate/check` simply no longer
returns a `repo` field and never reports `blocking_prs` as a gate.
## 0.30.2 — 2026-05-29
**Patch — header nav label: the persistent chrome link reverts from
"Philosophy" back to "About." Display text only — the route
(`/philosophy`), the `header-about` class, and the page itself are
unchanged. A plain frontend rebuild applies it; no schema, API, config,
overlay, or secret change.**
The §14.3 persistent link was relabeled "About" → "Philosophy" in
v0.21.0. This restores "About" as the neutral, framework-native label
(the CSS class `header-about` and the surrounding comment already call
it "the About link"). A deployment that wants a more pointed framing
can title its own About page in the `PHILOSOPHY.md` content the
`PHILOSOPHY_PATH` override serves.
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
## 0.30.1 — 2026-05-29
**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-
candidate terms, and offer-to-contribute-to-a-pending-RFC. One auto-
applied migration (024, additive: a new `contribution_requests` table).
No config/overlay/secret change; no nginx/systemd change. A plain code
deploy + the auto-migration picks it up. Shipped from driver session
0033.0.**
Both parts extend the v0.26.0 (#28 Part 1) read-time scanner
(`backend/app/rfc_links.py`) and its renderer
(`frontend/src/components/LinkedText.jsx`). The scanner now sorts each
matched term into one of three buckets — active link (Part 1, unchanged),
pending-RFC contribute offer (Part 3), create-RFC offer (Part 2) — in one
pass, with precedence active > pending > candidate at any position. The
backend still emits only structured segments (never HTML), so the surface
stays XSS-safe by construction.
- **Part 2 — create-RFC offers.** A *strong-candidate* term — a
**multi-word tag** from the #27 tag taxonomy that has no defining RFC
(no active or super-draft RFC whose slug/title is that term) — renders,
for a viewer with create rights (`permission_state='granted'`), as an
inline "+ create RFC" affordance. Clicking opens the propose-RFC modal
with the term pre-filled as the title (`ProposeModal` gained an
`initialTitle`; the affordance routes via `?propose=<term>`, read in
`App.jsx`). The heuristic is deliberately conservative — multi-word is
the same false-positive guard the title rule uses, so a single common
tag word (`identity`) is never offered. Broader candidate detection
(capitalized phrases mined from text, terms repeated across recent PRs,
or the #27 Haiku `ANTHROPIC_API_KEY` pathway) is a sanctioned but
deferred extension.
- **Part 3 — contribute-to-pending offers.** A term matching a *pending*
RFC — a super-draft (`state='super-draft'`: accepted as an idea, owned,
with a contribution surface, not yet graduated) — renders, for a
signed-in non-owner, as an inline "ask to contribute" affordance
carrying the owner's display name ("<owner> is working on an RFC for
'<term>'"). It opens a contribute-request form (`?contribute=<slug>`)
with three fields — **who I am** (required), **why I'm asking**
(required), **what I'd use it for** (optional, mirroring #26). Submitting
lands a `contribution_requests` row and one actionable §15 inbox
notification per owner (new kind `contribution_request_on_pending_rfc`,
category `personal-direct` — so it reuses the existing
`email_personal_direct` preference, no new toggle). In the inbox the
owner sees the requester's who/why/use-case inline with **Accept** /
**Decline**. Accept fires #12's owner-invite flow with the requester as
the invitee (a `contributor` `rfc_invitations` row + the existing invite
email) and echoes a notification back to the requester; Decline closes
the request and notifies the requester. Pre-merge idea PRs (not yet in
`cached_rfcs`, no contribution surface) are deliberately out of scope —
a documented future extension.
New endpoints (all under the existing `/api` router):
`GET /api/rfcs/{slug}/contribution-target`,
`POST /api/rfcs/{slug}/contribution-requests`,
`POST /api/rfcs/{slug}/contribution-requests/{id}/accept`,
`POST /api/rfcs/{slug}/contribution-requests/{id}/decline`.
The owner-invite issue path was refactored into one reusable chokepoint,
`api_invitations.issue_invitation(...)`, shared by the manual invite
endpoint and Part 3's accept path so the dup-guard, token mint, insert,
and transactional email stay identical.
Upgrade steps:
1. Deployments **MUST** apply the auto-run migration `024` (additive: the
new `contribution_requests` table; no existing table or row is
touched). The standard deploy path runs pending migrations on start —
no manual step beyond deploying the new code.
2. No config, overlay, or secret change is required. The Part 2 candidate
affordance reuses #27's tag taxonomy; it surfaces only when the corpus
carries multi-word tags without a defining RFC, and the create
affordance renders only for beta-granted viewers. Part 3's email reuse
sends through the existing invitation SMTP path — no new key.
## 0.28.0 — 2026-05-28
**Minor — security-hardening follow-up (Session-0026 audit, informational
findings I3 + I4). No operator action required: no migration, no schema
change, no config/overlay change, no API/behavior change for any caller.
A plain code deploy picks it up. Shipped from driver session 0032.0.**
Two informational findings from the Session-0026 audit, both
framework-internal defense-in-depth:
- **I3 — dead HTML-email branch guarded.** `email_envelope.build_envelope`
accepted a `body_html=` argument that built a `multipart/alternative`
body, but no send path ever passed it — every rfc-app mail is plain
text. An unused branch that would emit HTML built from (potentially
unescaped) user content is the C1 stored-XSS class waiting in the mail
channel. The branch is now a loud guard: passing `body_html` raises
`NotImplementedError`. The argument is kept in the signature for
documented future symmetry; enabling HTML mail becomes a deliberate
change that MUST HTML-escape user content at the call site and remove
the guard in the same commit.
- **I4 — Turnstile siteverify no longer blocks the event loop.**
`turnstile.verify_token` was a synchronous function issuing a blocking
`httpx.post` from inside the async `/auth/otc/request` handler, so a
slow CloudFlare response stalled the single worker for up to the 10s
timeout. It is now `async` and awaits the call on an
`httpx.AsyncClient` (matching the codebase's existing async-httpx
pattern), isolated behind a narrow `_siteverify_post` seam. The sole
caller (`main.py`) now `await`s it.
Upgrade steps: **none.** Both changes are internal. The `verify_token`
signature changed from sync to `async` (callers must `await`), but the
only caller is in-tree (`main.py`) and is updated in this release; no
deployment-facing surface, config key, or migration is affected.
## 0.27.0 — 2026-05-28 ## 0.27.0 — 2026-05-28
**Minor — security-hardening release (Session-0026 audit remediation). **Minor — security-hardening release (Session-0026 audit remediation).
+105 -12
View File
@@ -39,23 +39,44 @@ thread. Every write affordance is replaced with a sign-in prompt.
## Signing in ## Signing in
While the framework is in private beta, only invited email addresses Anyone can start the sign-in flow with their own email address — there
can complete sign-in. If your email is on the allowlist, the is no invite-only allowlist. Sign-in is passwordless:
"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.
Once you have an account, you're a **contributor** by default — the 1. **Enter your email.** If the deployment has human verification
role that grants every write affordance the app exposes, scoped by enabled (a Cloudflare Turnstile challenge), you complete it here.
the per-RFC and per-branch rules described below. 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 ## Proposing a new RFC
A new RFC begins as a proposal. The "+ Propose new RFC" button at 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: things:
- **Title.** The word, concept, or topic this RFC would define. - **Title.** The word, concept, or topic this RFC would define.
@@ -65,8 +86,13 @@ things:
inline. inline.
- **Pitch.** One or two paragraphs answering *why this RFC is - **Pitch.** One or two paragraphs answering *why this RFC is
needed*. This becomes the body of the entry. needed*. This becomes the body of the entry.
- **Tags.** Optional. The AI suggests tags from the pitch; you can - **Use case.** Optional. *What will you be using this RFC for?*
accept, dismiss, or type your own. 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 Submitting the modal does one concrete thing: it opens a pull
request against the framework's meta repository, adding one new 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 ## Working on a branch
Contribute mode flips one branch into edit-enabled. The centre 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 entirely. The framework names a single "owner zero" at
bootstrap. 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 The practical difference between admin and owner is narrow but
load-bearing: admin is the operational tier — it does the day-to- load-bearing: admin is the operational tier — it does the day-to-
day moderation and stewardship work; owner is the tier that 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 ## Where to learn more
- The framework's *why* lives in [the philosophy - 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 ## 1. Repository topology
Each RFC is its own Gitea repository. There is in addition exactly one **meta There is exactly one **meta repository** that holds every RFC in the
repository** that serves as the authoritative directory of all RFCs in the system as a single markdown entry under `rfcs/` — drafts, active work,
system — drafts, active work, and retired entries alike. and retired entries alike — regardless of state. An RFC is a single
canonical document, and its document *is* its meta entry: the body
lives in the entry file at every state, from idea through active. There
are **no per-RFC repositories**.
All Git operations across all repositories are performed by a single **bot For a deployment, this single repository is its **content repository**:
the one place every RFC document lives (under `rfcs/`), alongside the
framework's `PHILOSOPHY.md`, `README.md`, and `CONTRIBUTING.md` (§2). A
deployment names it concretely — `<deployment>-content` reads cleanly
— and the `META_REPO` setting carries the name. The term *meta
repository* persists for the config surface and
historical continuity, but under the meta-only topology this repo is,
functionally, the deployment's content repo: "one repo, your RFCs are
in `rfcs/`" is the whole mental model a new deployer needs.
> **Topology change (v0.31.0, meta-only — supersedes the original
> per-RFC-repo model).** This spec originally said "each RFC is its own
> Gitea repository," and graduation created a dedicated `rfc-NNNN-<slug>`
> repo and moved the body into its `RFC.md`. That model is **retired**.
> The per-RFC-repo machinery never paid for itself under this design:
> authorization is decided in app data before a single bot acts (below),
> not by per-repo Gitea permissions; raw `git clone`+`push` was never a
> supported contribution path; and the super-draft phase already ran
> meta-only. So an RFC now lives in its meta entry for its whole life,
> and graduation is an in-place state flip rather than a repo-creation
> transaction (see §13). Where later sections still say "the RFC's repo"
> or "RFC.md on the new repo," read it as "the RFC's meta entry body" —
> the editing, branch, PR, and chat machinery is unchanged; only the
> location collapses onto the meta repo. The one RFC graduated under the
> old model, **RFC-0001 `human`**, was folded back into its meta entry
> and its `wiggleverse/rfc-0001-human` repo archived (see §13.6). The
> decision record is OHM ROADMAP #36.
All Git operations on the meta repository are performed by a single **bot
service account** in Gitea. Real human users do not have meaningful Gitea service account** in Gitea. Real human users do not have meaningful Gitea
permissions on the repos themselves; their accounts exist for OAuth identity permissions on the repo itself; their accounts exist for OAuth identity
only. The bot is the author of every commit, the opener of every PR, and the only. The bot is the author of every commit, the opener of every PR, and the
merger of every merge. Authorization decisions are made by the app, in app merger of every merge. Authorization decisions are made by the app, in app
data, *before* the bot acts on the user's behalf. data, *before* the bot acts on the user's behalf.
@@ -67,8 +98,8 @@ The meta repo's `main` branch contains:
arriving at the meta-repo via Git rather than via the app. The **index arriving at the meta-repo via Git rather than via the app. The **index
below the header is regenerated by CI on every merge to main** and lists below the header is regenerated by CI on every merge to main** and lists
active RFCs, super-drafts, and (eventually) retired entries with links active RFCs, super-drafts, and (eventually) retired entries with links
into the corresponding entry files and, when present, the RFC's own into the corresponding entry files. (There is no per-RFC repository to
repository. link to under the meta-only topology, §1.)
- `CONTRIBUTING.md` — explains how to propose, claim, and contribute. - `CONTRIBUTING.md` — explains how to propose, claim, and contribute.
- A workflow file (Gitea Actions) that regenerates the README index. - A workflow file (Gitea Actions) that regenerates the README index.
@@ -84,7 +115,9 @@ slug: human
title: Human title: Human
state: super-draft # super-draft | active | withdrawn state: super-draft # super-draft | active | withdrawn
id: null # null until graduated; then "RFC-0042" id: null # null until graduated; then "RFC-0042"
repo: null # null until graduated; then "wiggleverse/rfc-0042-human" repo: null # always null under the meta-only topology (§1).
# Retained for schema stability + historical
# entries; never populated by graduation (§13).
proposed_by: ben@wiggleverse.org proposed_by: ben@wiggleverse.org
proposed_at: 2026-05-22 proposed_at: 2026-05-22
graduated_at: null graduated_at: null
@@ -103,8 +136,9 @@ tags: [identity, schema]
## Why this RFC is needed ## Why this RFC is needed
(One- or two-paragraph pitch from the proposer. While the entry is a (One- or two-paragraph pitch from the proposer. While the entry is a
super-draft, the body may grow into the actual draft document. On super-draft, the body grows into the actual draft document. The body
graduation, this body migrates to RFC.md in the new repo; see §13.) stays in this entry at every state — graduation is an in-place state
flip and does not move it (§13).)
``` ```
### 2.2 Idea submission as PR ### 2.2 Idea submission as PR
@@ -129,11 +163,14 @@ an idea costs nothing in identifier space.
There are three canonical states stored in entry frontmatter: There are three canonical states stored in entry frontmatter:
- **`super-draft`** — the entry exists in the meta repo's `rfcs/` - **`super-draft`** — the entry exists in the meta repo's `rfcs/`
directory. No dedicated repo yet. Anyone signed in can chat on it; anyone directory. Anyone signed in can chat on it; anyone can claim ownership;
can claim ownership; an owner is required before graduation. an owner is required before graduation.
- **`active`** — the entry has been graduated. A dedicated RFC repo - **`active`** — the entry has been graduated: it carries an integer
exists, `repo:` points to it, and real branches/PRs/conversation happen `id` (`RFC-NNNN`) and `graduated_at`/`graduated_by`. It lives in the
there. same `rfcs/<slug>.md` entry it always did — graduation is an in-place
state flip (§13), not a move. Branches, PRs, and conversation happen on
the meta repo against that entry, exactly as they did while it was a
super-draft. `repo:` stays null (§1).
- **`withdrawn`** — pulled before becoming canonical. Stays in the - **`withdrawn`** — pulled before becoming canonical. Stays in the
directory as historical record, hidden from default views, filterable in. directory as historical record, hidden from default views, filterable in.
@@ -163,20 +200,23 @@ for "who clicked the button" (see §6.5).
### 3.2 State change side-effects ### 3.2 State change side-effects
For now, changing state in the meta repo entry is the *only* required Changing state in the meta repo entry is the *only* required operation
operation for a state transition. Graduation has additional side effects for a state transition — graduation included. Graduation additionally
(creating the new repo, seeding it); those are covered in §13. We assigns the integer `id` and stamps `graduated_at`/`graduated_by` in the
deliberately do not tag commits, lock branches, or post notices on same commit (§13); it has no other side effects under the meta-only
state change for now — the entry frontmatter is the single source of topology (no repo to create, nothing to seed). We deliberately do not
truth and any further automation is a later refinement. tag commits, lock branches, or post notices on state change for now —
the entry frontmatter is the single source of truth and any further
automation is a later refinement.
--- ---
## 4. Storage architecture: Git is truth, app keeps a cache ## 4. Storage architecture: Git is truth, app keeps a cache
Gitea remains the source of truth for everything Git-shaped: meta repo Gitea remains the source of truth for everything Git-shaped: meta repo
content, RFC repo content, branches, PRs, commits. Nothing in this system content, branches, PRs, commits — all of which live on the single meta
overrides Gitea on those concerns. repo under the meta-only topology (§1). Nothing in this system overrides
Gitea on those concerns.
The app maintains a **SQLite database**, colocated with the FastAPI The app maintains a **SQLite database**, colocated with the FastAPI
process, that serves three purposes: process, that serves three purposes:
@@ -187,10 +227,11 @@ process, that serves three purposes:
assignments, per-branch grants, branch visibility settings, chat assignments, per-branch grants, branch visibility settings, chat
history, audit logs. This data is canonical; it is not cached, it history, audit logs. This data is canonical; it is not cached, it
is owned by the app. is owned by the app.
3. **Cached bodies** — the main-branch body of each RFC's `RFC.md` (and 3. **Cached bodies** — the main-branch body of each RFC, read from its
each super-draft's entry body) is cached for left-pane previews and `rfcs/<slug>.md` meta entry (the same source for super-drafts and
read-without-roundtrip. Branch bodies are *not* cached; the editor active RFCs alike under the meta-only topology), is cached for
fetches them live from Gitea when opened. left-pane previews and read-without-roundtrip. Branch bodies are
*not* cached; the editor fetches them live from Gitea when opened.
### 4.1 Cache freshness ### 4.1 Cache freshness
@@ -201,7 +242,7 @@ Two paths keep the cache current, running in parallel:
A webhook handler does a focused re-read of just what changed. A webhook handler does a focused re-read of just what changed.
Typical latency: sub-second. Typical latency: sub-second.
- **A periodic reconciler** runs every five minutes and does a full - **A periodic reconciler** runs every five minutes and does a full
sweep — list meta-repo entries, list each RFC repo's branches and sweep — list meta-repo entries, list the meta repo's branches and
PRs, diff against the cache, fix drift. This is the safety net for PRs, diff against the cache, fix drift. This is the safety net for
missed webhooks and downtime. missed webhooks and downtime.
@@ -1607,42 +1648,43 @@ contributor — identical to an active RFC's main chat per §11.4 plus
### 9.8 Graduation handoff additions ### 9.8 Graduation handoff additions
§13's graduation sequence was written before this section's > **Meta-only update (v0.31.0).** This section originally reconciled
machinery existed. The mechanics §13 needs to absorb fold inline > §13's per-RFC-repo graduation transaction with the §9-era editing
into §13.2 and §13.4 in their respective sections; the substantive > machinery. Under the meta-only topology (§1, §13) the entry never
additions are captured here for cross-reference: > moves and its body is never stripped, so the frictions this section
> existed to manage **dissolve**. The bullets are retained, struck
> through, for the audit trail of what the per-repo model required.
- **Open body-edit PRs block graduation.** §13.3's step 3 removes Under meta-only, graduation is a single frontmatter-flipping commit
to `rfcs/<slug>.md` (§13.3). Nothing else changes: the body stays in
the entry; branches, edit-PRs, threads, flags, and `changes` rows all
remain exactly where they were, keyed by the slug per §2.3, and keep
working against the same meta entry after the flip as before it. There
is no entry move, no body strip, no per-repo seed, and therefore no
"handoff" to coordinate.
- ~~**Open body-edit PRs block graduation.** §13.3's step 3 removes
the meta-repo entry's body field, and an open body-edit PR the meta-repo entry's body field, and an open body-edit PR
post-graduation would attempt to re-introduce a body to a post-graduation would attempt to re-introduce a body to a
frontmatter-only entry. The Graduate dialog disables the confirm frontmatter-only entry.~~ **No longer applies** — the body is kept,
button if any meta-repo PR is open against `rfcs/<slug>.md`. The so an open body-edit PR coexists with graduation. Graduation touches
precondition is enforced before the bot starts §13.3's sequence, only the frontmatter; a body-edit PR that merges after graduation
so §13.3's rollback complexity does not grow. edits the same entry's body just as it would have before. The
- **Bare edit branches survive graduation.** Edit branches without Graduate dialog no longer gates on open body-edit PRs (§13.2).
an open PR are not blocked. They remain on the meta repo subject - ~~**Bare edit branches survive graduation.**~~ Trivially true now —
to §12's hygiene timers. The contributor can re-cut against the no repo boundary is crossed, so every edit branch simply remains a
new RFC repo's main if they still want the work. The branch chat meta-repo branch on the same slug, subject to §12's hygiene timers,
persists per §8.4 as historical record even after auto-close, so with no "re-cut against the new repo" step.
the argument that produced the work is preserved regardless of - ~~**Chat migration includes range and paragraph sub-threads.**~~ No
whether the work itself merges. migration occurs: whole-doc, range, and paragraph threads stay on
- **Chat migration includes range and paragraph sub-threads.** their `(rfc_slug, branch_name)` rows. Their anchors resolve against
§13.4's chat-follows-the-work rule covers the whole-doc main the same entry body, which did not move, so §8.12's stale mechanic is
thread; it extends to range and paragraph sub-threads on the not provoked by graduation.
super-draft's main view, which migrate as part of the same - ~~**Pre-graduation history surfaces from the new RFC view.**~~ There
movement. Anchors re-resolve against `RFC.md` on the new repo; is no "new RFC view" distinct from the entry's own view, so there is
since §13.3's step 2 seeds `RFC.md` from the super-draft body no pre-graduation hop to bridge. Edit-branch threads, flags, and
verbatim, anchors typically locate the same content. Where they `changes` rows surface on the active RFC the same way they did on the
do not, §8.12's stale mechanic engages. super-draft — same slug, same surface.
- **Pre-graduation history surfaces from the new RFC view.**
Meta-repo edit-branch chats, flag threads, and `changes` rows
stay attached to their original `branch_name` on the meta repo;
they do not migrate. A **"Pre-graduation history"** affordance on
the new RFC view surfaces these — the slug remains the canonical
key per §2.3, so the query is a straightforward lookup of
`threads` and `changes` rows where `rfc_slug = <slug>` and
`branch_name` begins with `edit/<slug>/`. UI affordance; no data
movement, no rollback cost.
--- ---
@@ -1953,16 +1995,23 @@ email request to an owner.
--- ---
## 13. The graduation flow (super-draft → active RFC repo) ## 13. The graduation flow (super-draft → active, in place)
Graduation is initiated by an owner or admin clicking "Graduate to RFC Graduation is initiated by an owner or admin clicking "Graduate" on a
repo" on a super-draft's page. The button is disabled with a tooltip super-draft's page. The button is disabled with a tooltip when the
when the super-draft has no owners (see §13.1) or when any meta-repo super-draft has no owners (see §13.1). Open meta-repo body-edit PRs no
body-edit PR is open against `rfcs/<slug>.md` (see §9.8 — open longer block graduation: under the meta-only topology (§1) the body is
body-edit PRs would attempt to re-introduce a body to a frontmatter- kept in the entry, so graduation touches only frontmatter and coexists
only entry after step 3 of §13.3). Bare edit branches without an open with body edits (see §9.8). Bare edit branches are likewise unaffected;
PR do not block graduation; they remain on the meta repo subject to they remain on the meta repo subject to §12's hygiene timers.
§12's hygiene timers.
> **Meta-only rewrite (v0.31.0).** §13 originally described a
> transactional create-repo-seed-flip sequence (`super-draft → active
> RFC repo`) with rollback. That is **retired** (§1). Graduation is now
> a single in-place state flip on the entry: no repo is created, the
> body is not moved or stripped, and there is nothing to roll back. The
> subsections below are rewritten to the new model; §13.3 records what
> the old transaction did, struck through, for the audit trail.
### 13.1 Claim ownership (prerequisite) ### 13.1 Claim ownership (prerequisite)
@@ -1983,137 +2032,117 @@ broadening rather than a precondition for the proposer's own RFC.)
### 13.2 The Graduate dialog ### 13.2 The Graduate dialog
Clicking "Graduate to RFC repo" opens a small dialog with three Clicking "Graduate" opens a small dialog with two editable fields:
editable fields:
- **Integer ID** — pre-filled as `max(existing integer IDs) + 1`, - **Integer ID** — pre-filled as `max(existing integer IDs) + 1`,
formatted as `RFC-NNNN`. Editable to allow gap reservations but the formatted as `RFC-NNNN`. Editable to allow gap reservations but the
default is just the next number. default is just the next number.
- **Repo name** — pre-filled as `rfc-NNNN-<slug>`, editable but
constrained to valid Gitea repo names.
- **Initial owners** — pre-filled from the entry's `owners:`, with an - **Initial owners** — pre-filled from the entry's `owners:`, with an
"add owner" picker. Must have at least one. "add owner" picker. Must have at least one.
(The old **Repo name** field is gone — there is no repo to name under
the meta-only topology.)
Each field validates inline as the admin types, with a short Each field validates inline as the admin types, with a short
debounce, against the catalog cache and a regex — integer-ID debounce, against the catalog cache and a regex — integer-ID
collision against existing IDs, repo-name pattern against valid collision against existing IDs, the at-least-one-owner constraint on
Gitea name rules, the at-least-one-owner constraint on the picker. the picker. Errors render as a short line of text beneath the
Errors render as a short line of text beneath the offending field. offending field. The integer-ID collision check is re-issued
The repo-name collision check is re-issued atomically server-side atomically server-side on confirm, since a concurrent graduation
on confirm, since a concurrent graduation could land between could land between dialog-open and submit. While any field is
dialog-open and submit. While any field is invalid, the confirm invalid, the confirm button is disabled and its tooltip names the
button is disabled and its tooltip names the first blocker first blocker specifically — "Integer ID 42 is already taken," "Add
specifically — "Integer ID 42 is already taken," "Repo name must be at least one initial owner" — the same grammar the precondition
lowercase letters, digits, and dashes," "Add at least one initial popover below uses, so the dialog and the gate read as one surface
owner" — the same grammar the precondition popover below uses, so rather than two competing styles.
the dialog and the gate read as one surface rather than two
competing styles.
The dialog's confirm button is also disabled when the preconditions The dialog's confirm button is also disabled when the entry has no
from §13's opening paragraph fail — no owners on the entry, or any owners. The disabled button opens a small popover on hover or click
open meta-repo PR against `rfcs/<slug>.md`. The disabled button listing the failing precondition with an inline remediation
opens a small popover on hover or click that lists each failing affordance: "No owners claimed yet" surfaces a "Copy share link"
precondition as its own line item with an inline remediation affordance for surfacing the super-draft to a would-be claimer, plus
affordance per item. "No owners claimed yet" surfaces a "Copy share a secondary "Claim ownership yourself" — admins are contributors per
link" affordance for surfacing the super-draft to a would-be §6.1, so they can claim if they intend to graduate solo. Open
claimer, plus a secondary "Claim ownership yourself" — admins are body-edit PRs are **not** a precondition anymore (§9.8): the body is
contributors per §6.1, so they can claim if they intend to graduate kept, so they coexist with graduation.
solo. "N open body-edit PRs" expands inline within the popover to a
list of the offending PRs, one per row, carrying each PR's title,
author, and last-activity timestamp plus inline merge, withdraw,
and open-in-new-tab affordances; admins hold §6.3 authority on
those PRs and can resolve the precondition from the popover without
leaving the Graduate context.
The preconditions are enforced before the bot starts §13.3's ### 13.3 The flip
sequence, so §13.3's rollback complexity is unchanged.
### 13.3 The transactional sequence Confirming the dialog runs a single operation as the bot: open a PR
against the meta repo that re-serializes `rfcs/<slug>.md` with
`state: active`, `id: RFC-NNNN`, `graduated_at: <timestamp>`,
`graduated_by: <admin username>`, and the `owners:` from the dialog —
**leaving the body unchanged** — then auto-merge it (the admin who
clicked is the merge actor). The webhook flow updates the SQLite cache
and the catalog row transitions per §7.2.
Confirming the dialog runs this sequence as the bot: ```
super-draft entry ──[graduate]──▶ same entry, state: active, id assigned
(body unchanged, repo: null, lives in rfcs/<slug>.md throughout)
```
1. Create the new Gitea repo. There is no repo to create, nothing to seed, and the body is neither
2. Seed it with an initial commit on `main` containing: moved nor stripped, so there is **no multi-step transaction and no
- `README.md` (header pointing at the meta-repo entry, plus the rollback**. If opening or merging the flip PR fails, the entry simply
super-draft's pitch body migrated over). stays a super-draft and the admin sees the error — nothing partial was
- `RFC.md` (the actual document, starting from the super-draft body created that needs cleaning up. The dialog reports a single in-flight
or a template if the body is empty). "Graduating…" state that resolves to success (the PR merged) or a
- `.rfc/metadata.yaml` — mirror of the meta-repo frontmatter for plain error (the PR could not be opened or merged), rather than the
future tooling. old five-step stack. On success, a brief "Graduation complete" frame
3. Open a PR against the meta repo updating the entry: `state: active`, holds for a moment before the dialog closes.
`id: RFC-NNNN`, `repo: <new repo URL>`, `graduated_at: <timestamp>`,
`graduated_by: <admin username>`. The meta-repo entry's body field
is removed (frontmatter only, plus a generated "see the full RFC at
<repo>" link).
4. Auto-merge the PR (the same admin who clicked the button is the
merge actor).
5. Webhook flow updates the SQLite cache; left pane reflects the new
state immediately.
The dialog renders the sequence in flight as a stack of the five > ~~**The old transactional sequence (per-RFC-repo model, retired).**
named steps with per-step states — `pending`, `running`, `done`, > Confirming created a new Gitea repo; seeded it with `README.md`,
`failed`, `not reached` — and a one-line caption beneath the current > `RFC.md` (body migrated), and `.rfc/metadata.yaml`; opened a meta-repo
step naming the concrete operation ("Creating repository > PR flipping `state`/`id`/`repo`/`graduated_*` **and stripping the
wiggleverse/rfc-0042-human…"). The stack streams from > entry body** to frontmatter-only with a "see the full RFC at <repo>"
the server via the SSE surface in §17, one event per step > link; auto-merged it; refreshed the cache. A five-step SSE stack
transition. On success, a brief "Graduation complete" frame holds > rendered progress, and any mid-sequence failure rolled back (delete
for a moment before the dialog closes and the catalog row > the half-created repo, abandon the PR). All of that machinery is
transitions per §7.2. > removed — the body strip was the only reason most of it existed.~~
If any step fails partway, the app rolls back: deletes the ### 13.4 Chat, branches, and history stay put
half-created repo, abandons the unmerged PR, surfaces a clear error
to the admin. The rollback is itself a visible step appended to the
stack on failure — the admin sees that cleanup ran, not just that
the act failed. The failed step turns red, later original-sequence
steps mark "not reached," and a "What happened" panel renders below
the stack explaining what was rolled back, what wasn't (if anything
is unrecoverable), and what to do next. The panel persists until the
admin dismisses it — a failure surface is not auto-dismissed.
Graduation is rare enough to afford this level of care.
### 13.4 Chat history follows the work Nothing moves at graduation. The whole-doc main thread (§8.4), range
and paragraph sub-threads (§8.12), edit-branch chats, flag threads,
and `changes` rows all remain on their existing `(rfc_slug,
branch_name)` rows — the slug is the canonical key per §2.3 and does
not change. Their anchors resolve against the same entry body, which
did not move, so §8.12's stale mechanic is not provoked by graduation.
The chat thread attached to the super-draft moves to the new repo's Because there is no repo boundary to cross, there is no
main-branch chat at graduation. This covers both the whole-doc main "pre-graduation history" hop: the active RFC's view is the same view
thread per §8.4 and any range or paragraph sub-threads per §8.12 the super-draft had, listing the same `main`, open branches, and open
anchored to the super-draft's main view; anchors re-resolve against PRs in the §8.1 breadcrumb dropdown. Edit branches that closed during
`RFC.md` on the new repo and, where they fail, §8.12's stale the super-draft phase surface through the ordinary "Show closed
mechanic engages. The meta-repo entry retains a generated link branches" filter — there is no separate "lived on the meta repo before
"Conversation continues at <repo URL>." The chat is about the RFC, the repo existed" set to distinguish, because the repo never existed.
not the meta-repo entry, and it should travel with the work.
Meta-repo edit-branch chats, flag threads, and `changes` rows from ### 13.5 Reversing graduation
the super-draft phase **do not migrate**. They stay attached to
their original `branch_name` on the meta repo and surface from the
new RFC view via a **"Pre-graduation history"** affordance — a
straightforward lookup of `threads` and `changes` rows where
`rfc_slug = <slug>` and `branch_name` begins with `edit/<slug>/`
(the slug remains the canonical key per §2.3, before and after
graduation). UI affordance; no data movement, no rollback cost.
The affordance renders as a section in the §8.1 breadcrumb dropdown The canonical forward path from `active` is still `withdrawn` (§3.1),
on the new RFC view, alongside `main`, open branches, and open PRs, and `withdrawn → super-draft` reopens an entry. Under the meta-only
headed "Pre-graduation history (N)" with each pre-graduation edit topology, reversing a graduation is no longer operationally messy —
branch listed as its own row. Selecting a row swaps the center there are no repo commits to orphan, only a frontmatter flip — but the
column to a read-only render of that branch's body at its last state graph in §3.1 remains the authority: `active → withdrawn →
commit and the right column to that branch's chat, with associated super-draft` is the supported route, and the integer `id`, once
change-cards and flags inline — the same machinery a closed branch assigned, is not reclaimed (gap-free allocation per §2.3 tolerates
on an active RFC uses per §10.7 and §11.5. Anchors on pre-graduation gaps from withdrawals). A direct `active → super-draft` un-graduate is
threads resolve against the pre-graduation body, not against not exposed in v1; withdraw-and-reopen covers the need.
`RFC.md` on the new repo. The pre-graduation set is kept distinct
from the post-graduation "Show closed branches" filter in the same
dropdown — "branches that closed normally on this repo" and
"branches that lived on the meta repo before this repo existed" are
semantically different sets, and conflating them would obscure the
graduation hop.
### 13.5 Graduation is not reversible ### 13.6 RFC-0001 fold-back (migration record)
Once an entry is graduated to `active`, the path forward is RFC-0001 `human` was graduated under the original per-RFC-repo model
`withdrawn`, not back to `super-draft`. Reversing graduation cleanly (2026-05-26) into `wiggleverse/rfc-0001-human`, with its body in that
is operationally messy (existing commits in the new repo, etc.) and repo's `RFC.md` and its meta entry stripped to frontmatter. When the
the cost of not having it is low — withdraw and re-graduate as a meta-only topology landed (v0.31.0, OHM ROADMAP #36, driver session
fresh idea if needed. 0041.0), RFC-0001 was folded back to the single model: the full
`RFC.md` body was restored into `rfcs/human.md` in the meta repo
(`repo:` set null, `state: active` and `id: RFC-0001` retained), and
`wiggleverse/rfc-0001-human` was archived with a `README` pointing at
the canonical home in the app. RFC-0001 is therefore an ordinary
meta-only active RFC like any other; no grandfathered per-repo path
remains in the code.
--- ---
+1 -1
View File
@@ -1 +1 @@
0.27.0 0.31.2
+5
View File
@@ -21,6 +21,7 @@ from pydantic import BaseModel, Field
from . import ( from . import (
api_admin, api_admin,
api_branches, api_branches,
api_contributions,
api_discussion, api_discussion,
api_graduation, api_graduation,
api_invitations, api_invitations,
@@ -141,6 +142,10 @@ def make_router(
# invited users keep read access but cannot write (v0.6.0 # invited users keep read access but cannot write (v0.6.0
# contract extended to per-RFC scope). # contract extended to per-RFC scope).
router.include_router(api_invitations.make_router()) router.include_router(api_invitations.make_router())
# v0.29.0 (roadmap item #28 Part 3): offer-to-contribute-to-a-pending
# (super-draft) RFC. Reuses the #12 invite flow (api_invitations above)
# on accept; lands the request + owner notifications via §15 notify.
router.include_router(api_contributions.make_router())
# --------------------------------------------------------------- # ---------------------------------------------------------------
# §17: /api/health — unauthenticated post-flight probe. # §17: /api/health — unauthenticated post-flight probe.
+59 -37
View File
@@ -150,7 +150,7 @@ def make_router(
# `refresh_meta_branches` writes is internal scaffolding for the # `refresh_meta_branches` writes is internal scaffolding for the
# §10.1 has-commits-ahead check — the §9.4 dropdown's first # §10.1 has-commits-ahead check — the §9.4 dropdown's first
# position is rendered separately as 'canonical body'. # position is rendered separately as 'canonical body'.
if _is_super_draft(rfc): if _is_meta_resident(rfc):
branch_rows = db.conn().execute( branch_rows = db.conn().execute(
""" """
SELECT branch_name, head_sha, state, last_commit_at, pinned SELECT branch_name, head_sha, state, last_commit_at, pinned
@@ -180,7 +180,7 @@ def make_router(
# per-RFC repo. For super-draft: meta_body_edit and meta_metadata # per-RFC repo. For super-draft: meta_body_edit and meta_metadata
# PRs on the meta repo. Same shape either way — the §9.4 dropdown # PRs on the meta repo. Same shape either way — the §9.4 dropdown
# treats both as "open work against this entry." # treats both as "open work against this entry."
pr_kinds = ("meta_body_edit", "meta_metadata") if _is_super_draft(rfc) else ("rfc_branch",) pr_kinds = ("meta_body_edit", "meta_metadata") if _is_meta_resident(rfc) else ("rfc_branch",)
placeholders = ",".join("?" * len(pr_kinds)) placeholders = ",".join("?" * len(pr_kinds))
pr_rows = db.conn().execute( pr_rows = db.conn().execute(
f""" f"""
@@ -204,17 +204,18 @@ def make_router(
for r in pr_rows for r in pr_rows
] ]
# For super-drafts the cached body is entry.body already (see # For meta-resident entries the cached body is entry.body already
# cache._upsert_cached_rfc), so no extraction is needed. # (see cache._upsert_cached_rfc), so no extraction is needed.
# §9.8 / §13.4 pre-graduation history: for active RFCs, surface # Pre-graduation history is a LEGACY-only affordance: under the
# any `threads` or `changes` rows whose `branch_name` starts with # meta-only topology (§1, §13.4) graduation moves nothing, so an
# `edit-<slug>-` so the breadcrumb dropdown can render the # active RFC's edit branches are its *current* branches and already
# affordance as a distinct disclosure alongside main, open # surface in `branches` above — there is no separate pre-graduation
# branches, and open PRs. The slug is the canonical key per §2.3 # set. The disclosure is therefore computed only for a legacy
# before and after graduation, so the query is a straightforward # per-RFC-repo active entry (`repo` set), where edit branches on the
# lookup — no data movement. # meta repo genuinely predate the per-RFC repo and would otherwise
# not appear. After the RFC-0001 fold-back (§13.6) nothing matches.
pre_grad: list[dict[str, Any]] = [] pre_grad: list[dict[str, Any]] = []
if rfc["state"] == "active": if rfc["state"] == "active" and rfc["repo"]:
pre_grad_rows = db.conn().execute( pre_grad_rows = db.conn().execute(
""" """
SELECT t.branch_name, SELECT t.branch_name,
@@ -292,8 +293,20 @@ def make_router(
owner, repo = _repo_for(rfc) owner, repo = _repo_for(rfc)
new_branch = (body.branch_name or "").strip() new_branch = (body.branch_name or "").strip()
if not new_branch: if not new_branch:
new_branch = _auto_branch_name(viewer.gitea_login) # Meta-only topology (§1): an active RFC's branches live on the
_validate_branch_name(new_branch) # shared meta repo, so the auto name must embed the slug for the
# cache to attribute it (`edit-<slug>-<hex>`, recovered by
# `_slug_from_branch_name`). A legacy per-RFC-repo entry can use
# the slug-free `<login>-draft-<hex>` since every branch there
# belongs to the one RFC. The auto name is trusted (it carries a
# reserved `edit-` prefix by design); only a user-supplied name
# is validated, mirroring `start_edit_branch`.
new_branch = (
_auto_edit_branch_name(slug) if _is_meta_resident(rfc)
else _auto_branch_name(viewer.gitea_login)
)
else:
_validate_branch_name(new_branch)
try: try:
await bot.cut_branch_from_main( await bot.cut_branch_from_main(
viewer.as_actor(), viewer.as_actor(),
@@ -326,8 +339,10 @@ def make_router(
_ensure_branch_vis(slug, new_branch, creator_user_id=viewer.user_id) _ensure_branch_vis(slug, new_branch, creator_user_id=viewer.user_id)
# Make the cache aware immediately so the breadcrumb reflects # Make the cache aware immediately so the breadcrumb reflects
# the new branch without waiting for the webhook hop. # the new branch without waiting for the webhook hop. Meta-resident
await cache.refresh_rfc_repo(config, gitea, slug) # entries (§1) refresh meta branches; a legacy per-RFC repo refreshes
# its own — `_refresh_cache_for` dispatches on residency.
await _refresh_cache_for(rfc)
return {"branch_name": new_branch, "slug": slug} return {"branch_name": new_branch, "slug": slug}
@@ -1081,14 +1096,14 @@ def make_router(
return row return row
def _require_rfc_with_repo(slug: str): def _require_rfc_with_repo(slug: str):
"""Used by every branch-scoped endpoint. For active RFCs, a repo is """Used by every branch-scoped endpoint. Under the meta-only
required. For super-drafts, the meta repo is the implicit target topology (§1) the meta repo is the implicit target for every
no per-RFC repo check needed.""" entry super-draft and active alike so there is no per-RFC
repo check. The name is retained for call-site stability; a
withdrawn entry is still rejected."""
row = _require_rfc(slug) row = _require_rfc(slug)
if row["state"] == "withdrawn": if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn") raise HTTPException(409, "RFC is withdrawn")
if row["state"] == "active" and not row["repo"]:
raise HTTPException(409, "RFC has no repo")
return row return row
def _require_active_rfc(slug: str): def _require_active_rfc(slug: str):
@@ -1106,22 +1121,28 @@ def make_router(
def _is_super_draft(rfc) -> bool: def _is_super_draft(rfc) -> bool:
return rfc["state"] == "super-draft" return rfc["state"] == "super-draft"
def _is_meta_resident(rfc) -> bool:
"""Meta-only topology (§1): an entry lives in the meta repo's
`rfcs/<slug>.md` (super-draft or active-in-place) unless it carries
a legacy per-RFC `repo:` which nothing does after the RFC-0001
fold-back (§13.6)."""
return not rfc["repo"]
def _is_meta_branch_name(name: str) -> bool: def _is_meta_branch_name(name: str) -> bool:
"""A branch name shaped like one of the bot's meta-repo prefixes. """A branch name shaped like one of the bot's meta-repo prefixes.
§9.8's pre-graduation history affordance points the new RFC view Retained for the legacy per-RFC-repo read path; under meta-only
at branches matching `edit-<slug>-...` even after the entry is every entry is already a meta target via `_is_meta_resident`."""
active; treating those names as meta-repo targets lets the read
path dispatch correctly without a separate endpoint."""
return name != "main" and name.startswith(( return name != "main" and name.startswith((
"edit-", "edit/", "metadata-", "metadata/", "claim/", "propose/", "edit-", "edit/", "metadata-", "metadata/", "claim/", "propose/",
"graduate-", "graduate-",
)) ))
def _is_meta_target(rfc, branch: str) -> bool: def _is_meta_target(rfc, branch: str) -> bool:
"""Either a super-draft branch (active edit branch or the """A meta-resident entry (super-draft or active-in-place, §1)
canonical body) or an active RFC's pre-graduation meta-repo targets the meta repo for every branch. The branch-name fallback
branch surfaced through the §9.8 history affordance.""" covers the retired per-RFC-repo case for any legacy entry that
if _is_super_draft(rfc): still carries a `repo:`."""
if _is_meta_resident(rfc):
return True return True
return _is_meta_branch_name(branch) return _is_meta_branch_name(branch)
@@ -1161,7 +1182,7 @@ def make_router(
return entry_mod.serialize(entry) return entry_mod.serialize(entry)
async def _refresh_cache_for(rfc) -> None: async def _refresh_cache_for(rfc) -> None:
if _is_super_draft(rfc): if _is_meta_resident(rfc):
await cache.refresh_meta_repo(config, gitea) await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea) await cache.refresh_meta_branches(config, gitea)
else: else:
@@ -1270,12 +1291,13 @@ def make_router(
return False return False
if branch == "main": if branch == "main":
return False return False
# §9.8: pre-graduation history branches are read-only on the # §9.8 (LEGACY per-repo only): pre-graduation history branches are
# post-graduation surface. The contributor can re-cut against the # read-only on the post-graduation surface of a per-RFC-repo active
# new repo's main if they still want the work, but the meta-repo # entry. Under the meta-only topology (§1, §13.4) an active RFC's
# branches that lived on the super-draft are not editable from # `edit-<slug>-…` branches are its *current* editable branches, not
# the active-RFC view. # a frozen pre-graduation set, so this guard applies only when a
if rfc["state"] == "active" and _is_meta_branch_name(branch): # legacy `repo:` is set (nothing, after the RFC-0001 fold-back).
if rfc["state"] == "active" and rfc["repo"] and _is_meta_branch_name(branch):
return False return False
if viewer.role in ("owner", "admin"): if viewer.role in ("owner", "admin"):
return True return True
@@ -1302,7 +1324,7 @@ def make_router(
def _require_can_contribute(slug: str, branch: str, viewer) -> None: def _require_can_contribute(slug: str, branch: str, viewer) -> None:
rfc = db.conn().execute( rfc = db.conn().execute(
"SELECT state, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?", "SELECT state, repo, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?",
(slug,), (slug,),
).fetchone() ).fetchone()
if not _can_contribute(rfc, slug, branch, viewer): if not _can_contribute(rfc, slug, branch, viewer):
+311
View File
@@ -0,0 +1,311 @@
"""v0.29.0 / roadmap #28 Part 3 — offer-to-contribute-to-a-pending-RFC.
When the #28 scanner (see ``rfc_links.py``) matches a term in submitted
PR/comment text to a **pending** RFC a super-draft
(``cached_rfcs.state='super-draft'``: accepted as an idea, owned, with a
contribution surface, but not yet graduated to an active RFC) the
reader is offered an "ask to contribute" popover. This module is the
backend for that flow:
* ``GET /api/rfcs/{slug}/contribution-target`` what the
contribute form needs (RFC title, owner display, the viewer's
eligibility + whether they already have a pending ask).
* ``POST /api/rfcs/{slug}/contribution-requests`` submit the ask
(who-I-am / why / optional use-case); lands a row + one §15
notification per owner.
* ``POST /api/rfcs/{slug}/contribution-requests/{id}/accept`` owner:
accept, which fires #12's owner-invite flow with the requester as the
invitee (opening the RFC's discussion/contribution surface), then
echoes a notification back to the requester.
* ``POST /api/rfcs/{slug}/contribution-requests/{id}/decline`` owner:
decline; the request closes and the requester is notified.
"Pending" is scoped to a super-draft because that is the state with an
owner to route to, a contribution surface to open, and a row in
``cached_rfcs`` for the ``rfc_invitations`` FK the accept path reuses.
Pre-merge idea PRs are deliberately out of scope (no contribution
surface yet) a documented future extension, mirroring the
conservative scoping in ``rfc_links.py``.
"""
from __future__ import annotations
import sqlite3
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import api_invitations, auth, db, notify
# Field caps — generous for free text, bounded so a request row (and the
# notification payload that carries it) can't be used to store unbounded
# blobs. Mirrors the order-of-magnitude of the propose/tag-suggest caps.
_WHO_MAX = 2000
_WHY_MAX = 4000
_USE_CASE_MAX = 4000
_TERM_MAX = 200
class ContributionRequestBody(BaseModel):
# The term in the PR/comment text that surfaced the offer (the
# super-draft's title/slug). Carried for the owner's context line.
matched_term: str = Field(min_length=1, max_length=_TERM_MAX)
who_i_am: str = Field(min_length=1, max_length=_WHO_MAX)
why: str = Field(min_length=1, max_length=_WHY_MAX)
use_case: str | None = Field(default=None, max_length=_USE_CASE_MAX)
def _require_super_draft(slug: str):
"""The contribute surface only operates on a *pending* RFC. 404 on
unknown; 409 on a state that isn't a super-draft (active RFCs use the
Part-1 link, not a contribute offer; withdrawn is closed)."""
row = db.conn().execute(
"SELECT slug, title, state, owners_json, proposed_by FROM cached_rfcs WHERE slug = ?",
(slug,),
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
if row["state"] != "super-draft":
raise HTTPException(409, "RFC is not a pending super-draft")
return row
def _require_request(slug: str, request_id: int):
row = db.conn().execute(
"""
SELECT id, rfc_slug, requester_user_id, matched_term, who_i_am, why,
use_case, status
FROM contribution_requests WHERE id = ? AND rfc_slug = ?
""",
(request_id, slug),
).fetchone()
if row is None:
raise HTTPException(404, "Contribution request not found")
return row
def _viewer_relationship(viewer, slug: str) -> str | None:
"""Why this viewer can't *request* to contribute — or None if they can.
Owners/admins already have the RFC; existing collaborators are already
in. Both get a clear 409 rather than a useless self-request."""
if auth.is_rfc_owner(viewer, slug) or viewer.role in ("owner", "admin"):
return "You already own or administer this RFC."
if auth.is_rfc_collaborator(viewer, slug):
return "You're already a collaborator on this RFC."
return None
def make_router() -> APIRouter:
router = APIRouter()
# ---------------------------------------------------------------
# GET — what the contribute form needs to render + gate itself.
# ---------------------------------------------------------------
@router.get("/api/rfcs/{slug}/contribution-target")
async def contribution_target(slug: str, request: Request) -> dict[str, Any]:
row = db.conn().execute(
"SELECT slug, title, state, owners_json, proposed_by FROM cached_rfcs WHERE slug = ?",
(slug,),
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
from . import rfc_links # local import: avoid a module import cycle
owner = rfc_links._owner_display(db.conn(), row["owners_json"], row["proposed_by"])
viewer = auth.current_user(request)
eligible = True
reason: str | None = None
already_requested = False
if row["state"] != "super-draft":
eligible, reason = False, "This RFC is no longer pending."
elif viewer is None:
eligible, reason = False, "Sign in to ask to contribute."
elif viewer.permission_state != "granted":
eligible, reason = False, "Your beta access request is in review."
else:
reason = _viewer_relationship(viewer, slug)
if reason is not None:
eligible = False
else:
already_requested = bool(
db.conn().execute(
"""
SELECT 1 FROM contribution_requests
WHERE rfc_slug = ? AND requester_user_id = ? AND status = 'pending'
LIMIT 1
""",
(slug, viewer.user_id),
).fetchone()
)
return {
"slug": row["slug"],
"title": row["title"],
"owner": owner,
"eligible": eligible and not already_requested,
"reason": reason,
"already_requested": already_requested,
}
# ---------------------------------------------------------------
# POST — submit a contribute request.
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/contribution-requests")
async def create_contribution_request(
slug: str, body: ContributionRequestBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_super_draft(slug)
reason = _viewer_relationship(viewer, slug)
if reason is not None:
raise HTTPException(409, reason)
who_i_am = body.who_i_am.strip()
why = body.why.strip()
use_case = (body.use_case or "").strip() or None
matched_term = body.matched_term.strip()
if not who_i_am or not why:
raise HTTPException(422, "Both 'who I am' and 'why' are required.")
try:
cur = db.conn().execute(
"""
INSERT INTO contribution_requests
(rfc_slug, requester_user_id, matched_term, who_i_am, why, use_case)
VALUES (?, ?, ?, ?, ?, ?)
""",
(slug, viewer.user_id, matched_term, who_i_am, why, use_case),
)
except sqlite3.IntegrityError:
# The partial unique index — one open request per (RFC, user).
raise HTTPException(409, "You already have a pending request to contribute to this RFC.")
request_id = cur.lastrowid
# One actionable notification per owner; stamp the first onto the
# row as the inbox-action handle (any owner can act on the request).
notif_ids = notify.fan_out_contribution_request(
rfc_slug=slug,
requester_user_id=viewer.user_id,
request_id=request_id,
matched_term=matched_term,
who_i_am=who_i_am,
why=why,
use_case=use_case,
)
if notif_ids:
db.conn().execute(
"UPDATE contribution_requests SET notification_id = ? WHERE id = ?",
(notif_ids[0], request_id),
)
return {"id": request_id, "rfc_slug": slug, "status": "pending"}
# ---------------------------------------------------------------
# POST — owner accepts → fire #12's invite flow.
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/contribution-requests/{request_id}/accept")
async def accept_contribution_request(
slug: str, request_id: int, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(403, "Only the RFC's owner can act on contribution requests")
req = _require_request(slug, request_id)
if req["status"] != "pending":
raise HTTPException(409, f"This request was already {req['status']}.")
requester = db.conn().execute(
"SELECT id, email FROM users WHERE id = ?", (req["requester_user_id"],)
).fetchone()
if requester is None or not (requester["email"] or "").strip():
raise HTTPException(422, "The requester has no email address on file to invite.")
# Fire #12's owner-invite flow with the requester as the invitee.
# If a pending contributor invitation already exists (the owner
# invited them out-of-band first), reuse it rather than failing.
try:
invitation = api_invitations.issue_invitation(
slug=slug,
inviter_user_id=viewer.user_id,
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
invitee_email=requester["email"],
role_in_rfc="contributor",
rfc_title=rfc["title"],
)
invitation_id = invitation["id"]
except HTTPException as exc:
if exc.status_code != 409:
raise
existing = db.conn().execute(
"""
SELECT id FROM rfc_invitations
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
AND role_in_rfc = 'contributor' AND status = 'pending'
ORDER BY id DESC LIMIT 1
""",
(slug, requester["email"].strip()),
).fetchone()
invitation_id = existing["id"] if existing else None
db.conn().execute(
"""
UPDATE contribution_requests
SET status = 'accepted', decided_at = datetime('now'),
decided_by_user_id = ?, invitation_id = ?
WHERE id = ?
""",
(viewer.user_id, invitation_id, request_id),
)
notify.notify_contribution_decided(
rfc_slug=slug,
requester_user_id=req["requester_user_id"],
decider_user_id=viewer.user_id,
request_id=request_id,
accepted=True,
)
return {"ok": True, "status": "accepted", "invitation_id": invitation_id}
# ---------------------------------------------------------------
# POST — owner declines.
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/contribution-requests/{request_id}/decline")
async def decline_contribution_request(
slug: str, request_id: int, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_super_draft(slug)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(403, "Only the RFC's owner can act on contribution requests")
req = _require_request(slug, request_id)
if req["status"] != "pending":
raise HTTPException(409, f"This request was already {req['status']}.")
db.conn().execute(
"""
UPDATE contribution_requests
SET status = 'declined', decided_at = datetime('now'),
decided_by_user_id = ?
WHERE id = ?
""",
(viewer.user_id, request_id),
)
notify.notify_contribution_decided(
rfc_slug=slug,
requester_user_id=req["requester_user_id"],
decider_user_id=viewer.user_id,
request_id=request_id,
accepted=False,
)
return {"ok": True, "status": "declined"}
return router
+132 -365
View File
@@ -1,26 +1,30 @@
"""Slice 5 API surface — the §13 graduation flow's endpoints and the """§13 graduation flow — the meta-only in-place state flip.
in-process orchestrator that runs the §13.3 transactional sequence with
rollback.
Owns four routes per §17: Under the meta-only topology (SPEC §1), graduation no longer creates a
per-RFC repo. It is a single frontmatter-flipping commit to the entry's
`rfcs/<slug>.md` on the meta repo: open a PR that re-serializes the entry
with `state: active`, the assigned integer `id`, `graduated_at` /
`graduated_by`, and the dialog's owners — **leaving the body unchanged** —
then auto-merge it. There is no repo to create, nothing to seed, and the
body is neither moved nor stripped, so there is no multi-step transaction
and no rollback (§13.3). If the open or merge fails, the entry stays a
super-draft and we clean up the half-open PR/branch (the only artifact a
mid-flip failure can leave behind).
Routes (§17):
- GET /api/rfcs/<slug>/blocking-prs (§13.2 precondition popover)
- GET /api/rfcs/<slug>/graduate/check (§13.2 debounced validator) - GET /api/rfcs/<slug>/graduate/check (§13.2 debounced validator)
- POST /api/rfcs/<slug>/graduate (§13.3 kickoff) - POST /api/rfcs/<slug>/graduate (§13.3 the flip)
- GET /api/rfcs/<slug>/graduate/progress (§13.3 SSE step stream) - GET /api/rfcs/<slug>/graduate/progress (§13.3 SSE step stream)
- GET /api/rfcs/<slug>/blocking-prs (informational; no longer a
graduation precondition)
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim), which is Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim).
graduation's prerequisite for non-admins per §13.1.
The orchestrator runs in-process each in-flight graduation lives in a The orchestrator runs in-process each in-flight graduation lives in a
small `GraduationState` keyed by slug, with an asyncio.Queue feeding the small `GraduationState` keyed by slug, with an asyncio.Queue feeding the
SSE handler. Per the §13.3 transactional contract, every forward step is SSE handler. §13.4's chat/branch/history are a database no-op: every row
paired with an undo; rollback runs the undos in reverse order from the is keyed by the slug per §2.3 and stays put across the flip.
last step that completed. §13.4's chat migration is a database semantic
no-op (the threads' `(rfc_slug, branch_name='main')` rows are interpreted
as super-draft canonical-body before graduation and as new-RFC main
afterwards same shape, different meaning), so the only DB work the
sequence does is the audit-log rows the bot's `_log` writes per step.
""" """
from __future__ import annotations from __future__ import annotations
@@ -44,24 +48,18 @@ log = logging.getLogger(__name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step machine # Step machine — two steps under meta-only: open the flip PR, merge it.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
STEP_KEYS = ( STEP_KEYS = (
"create_repo",
"seed_files",
"open_pr", "open_pr",
"merge_pr", "merge_pr",
"refresh_cache",
) )
STEP_LABELS = { STEP_LABELS = {
"create_repo": "Create per-RFC repository", "open_pr": "Open graduation PR (flip state to active)",
"seed_files": "Seed RFC.md, README.md, and .rfc/metadata.yaml",
"open_pr": "Open meta-repo graduation PR",
"merge_pr": "Merge graduation PR", "merge_pr": "Merge graduation PR",
"refresh_cache": "Refresh catalog and views",
} }
@@ -77,8 +75,6 @@ class StepState:
class GraduationState: class GraduationState:
slug: str slug: str
rfc_id: str rfc_id: str
repo_name: str
repo_full: str
owners: list[str] owners: list[str]
arbiters: list[str] arbiters: list[str]
steps: list[StepState] steps: list[StepState]
@@ -86,8 +82,6 @@ class GraduationState:
finished: bool = False finished: bool = False
succeeded: bool = False succeeded: bool = False
error: str | None = None error: str | None = None
rollback_started: bool = False
rollback_steps: list[StepState] = field(default_factory=list)
new_pr_number: int | None = None new_pr_number: int | None = None
graduation_branch: str | None = None graduation_branch: str | None = None
@@ -95,12 +89,9 @@ class GraduationState:
return { return {
"slug": self.slug, "slug": self.slug,
"rfc_id": self.rfc_id, "rfc_id": self.rfc_id,
"repo_full": self.repo_full,
"steps": [_step_payload(s) for s in self.steps], "steps": [_step_payload(s) for s in self.steps],
"rollback_steps": [_step_payload(s) for s in self.rollback_steps],
"finished": self.finished, "finished": self.finished,
"succeeded": self.succeeded, "succeeded": self.succeeded,
"rolled_back": self.rollback_started,
"error": self.error, "error": self.error,
"pr_number": self.new_pr_number, "pr_number": self.new_pr_number,
} }
@@ -114,7 +105,7 @@ def _step_payload(s: StepState) -> dict:
# is fine; the registry is keyed by slug to refuse concurrent graduations # is fine; the registry is keyed by slug to refuse concurrent graduations
# of the same entry (the §13.2 atomic re-check is a separate defense # of the same entry (the §13.2 atomic re-check is a separate defense
# against a concurrent attempt of a DIFFERENT slug claiming the same # against a concurrent attempt of a DIFFERENT slug claiming the same
# integer ID or repo name). # integer ID).
_active: dict[str, GraduationState] = {} _active: dict[str, GraduationState] = {}
@@ -122,10 +113,10 @@ def _get_active(slug: str) -> GraduationState | None:
return _active.get(slug) return _active.get(slug)
def _new_active(slug: str, *, rfc_id: str, repo_name: str, repo_full: str, def _new_active(slug: str, *, rfc_id: str,
owners: list[str], arbiters: list[str]) -> GraduationState: owners: list[str], arbiters: list[str]) -> GraduationState:
state = GraduationState( state = GraduationState(
slug=slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full, slug=slug, rfc_id=rfc_id,
owners=owners, arbiters=arbiters, owners=owners, arbiters=arbiters,
steps=[StepState(key=k, label=STEP_LABELS[k]) for k in STEP_KEYS], steps=[StepState(key=k, label=STEP_LABELS[k]) for k in STEP_KEYS],
) )
@@ -138,17 +129,9 @@ def _new_active(slug: str, *, rfc_id: str, repo_name: str, repo_full: str,
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# §13.2: Gitea repo name pattern. Gitea accepts alphanumerics, dashes,
# dots, and underscores; cannot start with a dot. 100-char cap as a sane
# upper bound — the spec doesn't pin a max but Gitea's enforcement does.
_REPO_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$")
_RFC_ID_RE = re.compile(r"^RFC-\d{4,}$") _RFC_ID_RE = re.compile(r"^RFC-\d{4,}$")
def _is_valid_repo_name(name: str) -> bool:
return bool(_REPO_NAME_RE.match(name)) and ".." not in name
def _is_valid_rfc_id(rfc_id: str) -> bool: def _is_valid_rfc_id(rfc_id: str) -> bool:
return bool(_RFC_ID_RE.match(rfc_id)) return bool(_RFC_ID_RE.match(rfc_id))
@@ -167,13 +150,6 @@ def _suggest_next_rfc_id() -> str:
return f"RFC-{nxt:04d}" return f"RFC-{nxt:04d}"
def _suggest_repo_name(slug: str, rfc_id: str) -> str:
# rfc-NNNN-<slug> per §13.2's default. Strip the 'RFC-' prefix and
# lowercase the number-pad.
num = rfc_id.split("-", 1)[1] if "-" in rfc_id else "0001"
return f"rfc-{num}-{slug}"
def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool: def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool:
row = db.conn().execute( row = db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE rfc_id = ? AND slug != ?", "SELECT slug FROM cached_rfcs WHERE rfc_id = ? AND slug != ?",
@@ -189,7 +165,6 @@ def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool:
class GraduateBody(BaseModel): class GraduateBody(BaseModel):
rfc_id: str = Field(min_length=5, max_length=40) rfc_id: str = Field(min_length=5, max_length=40)
repo_name: str = Field(min_length=1, max_length=100)
owners: list[str] = Field(min_length=1) owners: list[str] = Field(min_length=1)
@@ -206,19 +181,17 @@ def make_router(
router = APIRouter() router = APIRouter()
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# §13.2: GET /api/rfcs/<slug>/blocking-prs # GET /api/rfcs/<slug>/blocking-prs
# Lists open meta-repo PRs against rfcs/<slug>.md per the precondition # Lists open meta-repo body-edit PRs against rfcs/<slug>.md. Under the
# popover. Returns PR number, title, author, last-activity timestamp, # meta-only topology (§9.8) these no longer block graduation — the body
# and the viewer's available actions (merge, withdraw, open-in-new-tab). # is kept, so a body-edit PR coexists with the flip. Retained as an
# informational surface (the dialog can show "N body-edit PRs open").
# ------------------------------------------------------------------- # -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/blocking-prs") @router.get("/api/rfcs/{slug}/blocking-prs")
async def list_blocking_prs(slug: str, request: Request) -> dict[str, Any]: async def list_blocking_prs(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request) viewer = auth.current_user(request)
rfc = _require_super_draft(slug) rfc = _require_super_draft(slug)
# §13's opening paragraph: only body-edit PRs block graduation.
# Bare edit branches without an open PR do not block. The query
# filters cached_prs to open meta_body_edit kinds for this slug.
rows = db.conn().execute( rows = db.conn().execute(
""" """
SELECT pr_number, title, opened_by, opened_at, head_branch, pr_kind SELECT pr_number, title, opened_by, opened_at, head_branch, pr_kind
@@ -261,14 +234,15 @@ def make_router(
"open_in_new_tab": True, "open_in_new_tab": True,
}, },
}) })
return {"items": items} # `blocking` is a legacy field name kept for client compatibility;
# under §9.8 these PRs do not block graduation.
return {"items": items, "blocking": False}
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# §13.2: GET /api/rfcs/<slug>/graduate/check?id=&repo= # GET /api/rfcs/<slug>/graduate/check?id=
# Inline validation for the Graduate dialog — debounced from the # Inline validation for the Graduate dialog — debounced from the
# client; the dialog calls this as the admin types. Returns per-field # client. Two fields under meta-only: the integer ID and the owners
# collision/validity from the catalog cache plus a server-authoritative # precondition. There is no repo name to validate (§13.2).
# repo-name collision check.
# ------------------------------------------------------------------- # -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/graduate/check") @router.get("/api/rfcs/{slug}/graduate/check")
@@ -281,16 +255,7 @@ def make_router(
# admins/owners, but the check itself is read-only. # admins/owners, but the check itself is read-only.
candidate_id = (request.query_params.get("id") or "").strip() candidate_id = (request.query_params.get("id") or "").strip()
candidate_repo = (request.query_params.get("repo") or "").strip()
owners = json.loads(rfc["owners_json"] or "[]") owners = json.loads(rfc["owners_json"] or "[]")
blocking_count = db.conn().execute(
"""
SELECT COUNT(*) AS n FROM cached_prs
WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit'
""",
(slug,),
).fetchone()["n"]
# ID field # ID field
id_payload: dict[str, Any] = {"value": candidate_id, "ok": True, "error": None} id_payload: dict[str, Any] = {"value": candidate_id, "ok": True, "error": None}
@@ -304,34 +269,6 @@ def make_router(
id_payload["ok"] = False id_payload["ok"] = False
id_payload["error"] = f"Integer ID {candidate_id} is already taken" id_payload["error"] = f"Integer ID {candidate_id} is already taken"
# Repo field — validate pattern then probe Gitea for an existing
# repo of that name under our org. The repo lookup is a single GET
# so it's cheap to call on every keystroke (debounced from the
# client per §13.2).
repo_payload: dict[str, Any] = {"value": candidate_repo, "ok": True, "error": None}
if not candidate_repo:
repo_payload["ok"] = False
repo_payload["error"] = "Repo name is required"
elif not _is_valid_repo_name(candidate_repo):
repo_payload["ok"] = False
repo_payload["error"] = (
"Repo name must be alphanumerics, dashes, dots, or underscores "
"(start with alphanumeric)"
)
else:
try:
existing = await gitea.get_repo(config.gitea_org, candidate_repo)
except GiteaError as e:
# Network/auth flake — surface as a non-fatal hint; the
# atomic server-side check at POST time is the authority.
existing = None
log.warning("graduate_check: Gitea get_repo error: %s", e)
if existing is not None:
repo_payload["ok"] = False
repo_payload["error"] = (
f"Repo `{config.gitea_org}/{candidate_repo}` already exists"
)
# Owners precondition — §13's opening paragraph. # Owners precondition — §13's opening paragraph.
owners_payload: dict[str, Any] = { owners_payload: dict[str, Any] = {
"ok": len(owners) > 0, "ok": len(owners) > 0,
@@ -340,28 +277,13 @@ def make_router(
"error": None if len(owners) > 0 else "No owners claimed yet", "error": None if len(owners) > 0 else "No owners claimed yet",
} }
# Blocking PR precondition — §9.8 / §13's opening paragraph.
prs_payload: dict[str, Any] = {
"ok": blocking_count == 0,
"count": blocking_count,
"error": (
None if blocking_count == 0
else f"{blocking_count} open body-edit PR{'' if blocking_count == 1 else 's'} blocking graduation"
),
}
in_flight = _get_active(slug) in_flight = _get_active(slug)
any_invalid = not ( any_invalid = not (id_payload["ok"] and owners_payload["ok"])
id_payload["ok"] and repo_payload["ok"]
and owners_payload["ok"] and prs_payload["ok"]
)
return { return {
"slug": slug, "slug": slug,
"id": id_payload, "id": id_payload,
"repo": repo_payload,
"owners": owners_payload, "owners": owners_payload,
"blocking_prs": prs_payload,
"can_submit": (not any_invalid) and (in_flight is None or in_flight.finished), "can_submit": (not any_invalid) and (in_flight is None or in_flight.finished),
"in_flight": ( "in_flight": (
None if in_flight is None None if in_flight is None
@@ -370,8 +292,8 @@ def make_router(
} }
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# §13.3: POST /api/rfcs/<slug>/graduate # POST /api/rfcs/<slug>/graduate
# Atomic re-validation, then kicks off the sequence as an async task. # Atomic re-validation, then kicks off the flip as an async task.
# The client opens GET /graduate/progress on confirm to watch the SSE. # The client opens GET /graduate/progress on confirm to watch the SSE.
# ------------------------------------------------------------------- # -------------------------------------------------------------------
@@ -392,9 +314,8 @@ def make_router(
# §13.2 atomic re-validation. The dialog's debounced check runs # §13.2 atomic re-validation. The dialog's debounced check runs
# client-side as the admin types; this is the authoritative check # client-side as the admin types; this is the authoritative check
# that closes the dialog-open-to-confirm race. # that closes the dialog-open-to-confirm race on the integer ID.
rfc_id = body.rfc_id.strip() rfc_id = body.rfc_id.strip()
repo_name = body.repo_name.strip()
owners = [o.strip() for o in body.owners if o.strip()] owners = [o.strip() for o in body.owners if o.strip()]
if not owners: if not owners:
raise HTTPException(422, "Add at least one initial owner") raise HTTPException(422, "Add at least one initial owner")
@@ -402,35 +323,10 @@ def make_router(
raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)") raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)")
if _rfc_id_taken(rfc_id, excluding_slug=slug): if _rfc_id_taken(rfc_id, excluding_slug=slug):
raise HTTPException(409, f"Integer ID {rfc_id} is already taken") raise HTTPException(409, f"Integer ID {rfc_id} is already taken")
if not _is_valid_repo_name(repo_name):
raise HTTPException(422, "Repo name must be alphanumerics, dashes, dots, or underscores")
try:
existing_repo = await gitea.get_repo(config.gitea_org, repo_name)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
if existing_repo is not None:
raise HTTPException(409, f"Repo `{config.gitea_org}/{repo_name}` already exists")
# §9.8 precondition gate — enforced before the bot starts the
# sequence so the §13.3 rollback complexity does not grow. An
# open body-edit PR against rfcs/<slug>.md would attempt to
# re-introduce a body to a frontmatter-only entry after step 3.
blocking = db.conn().execute(
"""
SELECT COUNT(*) AS n FROM cached_prs
WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit'
""",
(slug,),
).fetchone()["n"]
if blocking > 0:
raise HTTPException(
409,
f"{blocking} open body-edit PR{'' if blocking == 1 else 's'} block graduation",
)
# Read the meta-repo entry once — we need the file's sha for the # Read the meta-repo entry once — we need the file's sha for the
# graduation PR's update_file call and the original body so the # graduation PR's update_file call and the body to carry through
# bot can seed RFC.md on the new repo with the migrated body. # unchanged (meta-only keeps the body in the entry, §13.3).
fetched = await gitea.read_file( fetched = await gitea.read_file(
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main", config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
) )
@@ -442,19 +338,17 @@ def make_router(
except Exception as e: except Exception as e:
raise HTTPException(500, f"Meta entry malformed: {e}") raise HTTPException(500, f"Meta entry malformed: {e}")
repo_full = f"{config.gitea_org}/{repo_name}"
arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1] arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1]
# Compose the graduated frontmatter — body stripped, graduation # Compose the graduated frontmatter — body KEPT, graduation fields
# fields filled. The serializer is run now so the PR-open step # filled, repo left null (§1). Serialized now so the PR-open step
# has the contents pre-rendered (single source of truth for the # has the contents pre-rendered.
# body migration vs. the meta-entry update).
graduated_entry = entry_mod.Entry( graduated_entry = entry_mod.Entry(
slug=slug, slug=slug,
title=super_draft_entry.title, title=super_draft_entry.title,
state="active", state="active",
id=rfc_id, id=rfc_id,
repo=repo_full, repo=None,
proposed_by=super_draft_entry.proposed_by, proposed_by=super_draft_entry.proposed_by,
proposed_at=super_draft_entry.proposed_at, proposed_at=super_draft_entry.proposed_at,
graduated_at=entry_mod.today(), graduated_at=entry_mod.today(),
@@ -462,37 +356,30 @@ def make_router(
owners=owners, owners=owners,
arbiters=arbiters, arbiters=arbiters,
tags=list(super_draft_entry.tags), tags=list(super_draft_entry.tags),
body="", models=super_draft_entry.models,
funder=super_draft_entry.funder,
body=super_draft_entry.body,
) )
graduated_contents = entry_mod.serialize(graduated_entry) graduated_contents = entry_mod.serialize(graduated_entry)
state = _new_active( state = _new_active(
slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full, slug, rfc_id=rfc_id, owners=owners, arbiters=arbiters,
owners=owners, arbiters=arbiters,
) )
# Audit: graduation started. The terminal `graduate_complete` / # Audit: graduation started. The terminal `graduate_complete` /
# `graduate_rollback` rows below close the linkable sequence. # `graduate_failed` rows below close the linkable sequence.
_audit( _audit(
viewer.user_id, viewer.gitea_login, "graduate_start", viewer.user_id, viewer.gitea_login, "graduate_start",
rfc_slug=slug, rfc_slug=slug,
details={ details={"rfc_id": rfc_id, "owners": owners},
"rfc_id": rfc_id, "repo": repo_full, "owners": owners,
"blocking_prs": blocking,
},
) )
# Test seam: `?_sync=1` awaits the orchestrator inline so # Test seam: `?_sync=1` awaits the orchestrator inline so
# integration tests can assert post-conditions without driving # integration tests can assert post-conditions without driving
# the SSE. Production clients use the spec-described shape — # the SSE. Production clients POST then subscribe to the SSE.
# POST returns immediately, the client subscribes to the
# progress SSE.
coro = _orchestrate( coro = _orchestrate(
config=config, gitea=gitea, bot=bot, config=config, gitea=gitea, bot=bot,
actor=viewer.as_actor(), state=state, actor=viewer.as_actor(), state=state,
super_draft_body=super_draft_entry.body,
super_draft_title=super_draft_entry.title,
super_draft_tags=list(super_draft_entry.tags),
graduated_contents=graduated_contents, graduated_contents=graduated_contents,
meta_file_sha=meta_sha, meta_file_sha=meta_sha,
) )
@@ -505,38 +392,31 @@ def make_router(
"ok": True, "ok": True,
"slug": slug, "slug": slug,
"rfc_id": rfc_id, "rfc_id": rfc_id,
"repo": repo_full,
"stream_url": f"/api/rfcs/{slug}/graduate/progress", "stream_url": f"/api/rfcs/{slug}/graduate/progress",
"finished": state.finished, "finished": state.finished,
"succeeded": state.succeeded, "succeeded": state.succeeded,
} }
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# §13.3: GET /api/rfcs/<slug>/graduate/progress # GET /api/rfcs/<slug>/graduate/progress
# SSE stream of the step transitions. One event per step transition # SSE stream of the flip's step transitions (open_pr, merge_pr).
# (pending → running → done / failed), plus the trailing rollback
# step's events if any earlier step fails.
# ------------------------------------------------------------------- # -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/graduate/progress") @router.get("/api/rfcs/{slug}/graduate/progress")
async def graduate_progress(slug: str, request: Request): async def graduate_progress(slug: str, request: Request):
# v0.6.0 (item #4): the progress SSE surfaces admin-internal step # The progress SSE surfaces admin-internal step detail (PR number)
# detail (repo name, PR number, rollback steps) that isn't part of # that isn't part of the anonymous-read contract. POST /graduate is
# the v0.3.0 anonymous-read contract for catalog/RFC bodies. The # gated to RFC owners/arbiters and app admins/owners; the read SSE
# corresponding POST /graduate is gated to RFC owners/arbiters and # shares that operator-visible surface and requires an authenticated
# app admins/owners via `_can_graduate`; the read SSE shares that # viewer. We keep the floor at require_user (not require_contributor)
# operator-visible surface, so it requires at least an # so a write-muted operator can still observe a graduation they
# authenticated viewer. We keep the floor at require_user (not # kicked off before being muted.
# require_contributor) so a write-muted operator can still observe
# the progress of a graduation they kicked off before being muted.
auth.require_user(request) auth.require_user(request)
state = _get_active(slug) state = _get_active(slug)
if state is None: if state is None:
raise HTTPException(404, "No graduation in flight for this slug") raise HTTPException(404, "No graduation in flight for this slug")
async def event_stream(): async def event_stream():
# Emit the current snapshot first so a late subscriber sees
# the steps already completed.
yield _sse_event("snapshot", state.to_payload()) yield _sse_event("snapshot", state.to_payload())
if state.finished: if state.finished:
yield _sse_event("done", state.to_payload()) yield _sse_event("done", state.to_payload())
@@ -555,22 +435,16 @@ def make_router(
# §13.1: POST /api/rfcs/<slug>/claim # §13.1: POST /api/rfcs/<slug>/claim
# Opens a meta-repo PR adding the actor's gitea_login to the entry's # Opens a meta-repo PR adding the actor's gitea_login to the entry's
# owners list. Anyone signed in may claim — the merge is gated to # owners list. Anyone signed in may claim — the merge is gated to
# owners/admins per §13.1 (which collapses to admins for unclaimed # owners/admins per §13.1.
# entries since `owners` is empty).
# ------------------------------------------------------------------- # -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/claim") @router.post("/api/rfcs/{slug}/claim")
async def claim_ownership(slug: str, request: Request) -> dict[str, Any]: async def claim_ownership(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request) viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug) rfc = _require_super_draft(slug)
# Refuse if the actor is already in owners — no-op claim.
existing_owners = json.loads(rfc["owners_json"] or "[]") existing_owners = json.loads(rfc["owners_json"] or "[]")
if viewer.gitea_login in existing_owners: if viewer.gitea_login in existing_owners:
return {"ok": True, "noop": True} return {"ok": True, "noop": True}
# Refuse if a claim PR for this actor is already open. The branch
# name `claim/<slug>` collides per actor implicitly since Gitea
# refuses duplicate branch creation; we surface a clean 409 here
# so the client doesn't see a 502.
already = db.conn().execute( already = db.conn().execute(
""" """
SELECT pr_number FROM cached_prs SELECT pr_number FROM cached_prs
@@ -581,8 +455,6 @@ def make_router(
if already: if already:
raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}") raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}")
# Compose the new entry contents — owners list with the claimant
# appended.
fetched = await gitea.read_file( fetched = await gitea.read_file(
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main", config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
) )
@@ -626,7 +498,7 @@ def make_router(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Orchestrator # Orchestrator — the §13.3 in-place flip
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -637,57 +509,21 @@ async def _orchestrate(
bot: Bot, bot: Bot,
actor: Actor, actor: Actor,
state: GraduationState, state: GraduationState,
super_draft_body: str,
super_draft_title: str,
super_draft_tags: list[str],
graduated_contents: str, graduated_contents: str,
meta_file_sha: str, meta_file_sha: str,
) -> None: ) -> None:
"""Run §13.3 step by step. Each step: """Open the flip PR, then merge it. Two steps, no transaction:
- marks itself `running` and pushes an event - open_pr fails nothing was created; the entry stays a super-draft.
- calls the bot method (which writes to Gitea + audit log) - merge_pr fails close the open PR and delete its branch (the only
- marks itself `done` (or `failed`) and pushes another event artifact a mid-flip failure can leave on the meta repo), then the
entry stays a super-draft.
On failure at step N, every later step is marked `not-reached` and There is no rollback of a *merged* flip once the meta-repo merge has
`_rollback` runs undoes in reverse from N-1 to 1. landed, the path forward is §3's `withdraw` (§13.5).
""" """
try: try:
# ----- Step 1: create per-RFC repo ----- # ----- Step 1: open the graduation PR (flip frontmatter) -----
await _start(state, "create_repo", f"Creating `{state.repo_full}`…")
try:
await bot.create_rfc_repo_for_graduation(
actor, org=config.gitea_org, repo_name=state.repo_name,
slug=state.slug, title=super_draft_title,
)
except GiteaError as e:
await _fail(state, "create_repo", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at="create_repo")
return
await _done(state, "create_repo", state.repo_full)
# ----- Step 2: seed RFC.md, README.md, .rfc/metadata.yaml -----
await _start(state, "seed_files", "Writing initial commit on main…")
try:
await bot.seed_graduated_rfc(
actor,
org=config.gitea_org, repo_name=state.repo_name,
slug=state.slug, title=super_draft_title,
rfc_body=super_draft_body, rfc_id=state.rfc_id,
meta_full=config.meta_repo_full,
meta_path=f"rfcs/{state.slug}.md",
owners=state.owners, arbiters=state.arbiters,
tags=super_draft_tags,
)
except GiteaError as e:
await _fail(state, "seed_files", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at="seed_files")
return
await _done(state, "seed_files", "RFC.md, README.md, .rfc/metadata.yaml")
# ----- Step 3: open graduation PR -----
await _start(state, "open_pr", "Opening graduation PR…") await _start(state, "open_pr", "Opening graduation PR…")
try: try:
pr = await bot.open_graduation_pr( pr = await bot.open_graduation_pr(
@@ -696,19 +532,18 @@ async def _orchestrate(
slug=state.slug, slug=state.slug,
new_file_contents=graduated_contents, new_file_contents=graduated_contents,
prior_sha=meta_file_sha, prior_sha=meta_file_sha,
rfc_id=state.rfc_id, repo_full=state.repo_full, rfc_id=state.rfc_id,
owners=state.owners, owners=state.owners,
) )
except GiteaError as e: except GiteaError as e:
await _fail(state, "open_pr", f"Gitea: {e.detail}") await _fail(state, "open_pr", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor, await _finish_failed(state, failed_at="open_pr", on_behalf_of=actor.gitea_login)
state=state, failed_at="open_pr")
return return
state.new_pr_number = pr["number"] state.new_pr_number = pr["number"]
state.graduation_branch = pr["head"]["ref"] state.graduation_branch = pr["head"]["ref"]
await _done(state, "open_pr", f"PR #{state.new_pr_number}") await _done(state, "open_pr", f"PR #{state.new_pr_number}")
# ----- Step 4: merge the graduation PR ----- # ----- Step 2: merge the graduation PR -----
await _start(state, "merge_pr", f"Merging PR #{state.new_pr_number}") await _start(state, "merge_pr", f"Merging PR #{state.new_pr_number}")
try: try:
await bot.merge_graduation_pr( await bot.merge_graduation_pr(
@@ -720,35 +555,28 @@ async def _orchestrate(
) )
except GiteaError as e: except GiteaError as e:
await _fail(state, "merge_pr", f"Gitea: {e.detail}") await _fail(state, "merge_pr", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor, await _cleanup_unmerged(config=config, bot=bot, actor=actor, state=state)
state=state, failed_at="merge_pr") await _finish_failed(state, failed_at="merge_pr", on_behalf_of=actor.gitea_login)
return return
await _done(state, "merge_pr", f"PR #{state.new_pr_number} merged") await _done(state, "merge_pr", f"PR #{state.new_pr_number} merged")
# ----- Step 5: refresh the cache so the catalog flips immediately. # Refresh the cache so the catalog flips immediately. The webhook
# Per §13.3 step 5 the webhook flow is the steady-state path, but # flow is the steady-state path (§13.3); we refresh inline so the
# we refresh inline so the dialog can transition to "graduation # dialog can transition to "graduation complete" with the catalog
# complete" with the catalog row already showing `active`. A # row already showing `active`. A refresh failure does not unwind
# cache-refresh failure does not unwind Git state — the # the merge — the reconciler catches up per §4.1.
# reconciler will catch up per §4.1.
await _start(state, "refresh_cache", "Refreshing catalog and views…")
try: try:
await cache.refresh_meta_repo(config, gitea) await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea) await cache.refresh_meta_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea) await cache.refresh_meta_pulls(config, gitea)
await cache.refresh_rfc_repo(config, gitea, state.slug)
except Exception as e: except Exception as e:
log.warning("graduate refresh_cache failed for %s: %s", state.slug, e) log.warning("graduate cache refresh failed for %s: %s", state.slug, e)
await _done(state, "refresh_cache", f"Cache will catch up via reconciler ({e})")
else:
await _done(state, "refresh_cache", "Catalog and main view updated")
# Terminal success row in the audit log.
_audit( _audit(
None, actor.gitea_login, "graduate_complete", None, actor.gitea_login, "graduate_complete",
rfc_slug=state.slug, rfc_slug=state.slug,
details={ details={
"rfc_id": state.rfc_id, "repo": state.repo_full, "rfc_id": state.rfc_id,
"owners": state.owners, "pr_number": state.new_pr_number, "owners": state.owners, "pr_number": state.new_pr_number,
}, },
) )
@@ -757,109 +585,38 @@ async def _orchestrate(
await state.queue.put({"event": "completed", "payload": state.to_payload()}) await state.queue.put({"event": "completed", "payload": state.to_payload()})
except Exception as e: except Exception as e:
log.exception("graduate: unexpected error for %s", state.slug) log.exception("graduate: unexpected error for %s", state.slug)
# Best-effort: mark the in-flight step failed, then roll back.
running = next((s for s in state.steps if s.status == "running"), None) running = next((s for s in state.steps if s.status == "running"), None)
if running is not None: if running is not None:
await _fail(state, running.key, f"unexpected: {e}") await _fail(state, running.key, f"unexpected: {e}")
await _rollback( await _finish_failed(
config=config, gitea=gitea, bot=bot, actor=actor, state, failed_at=running.key if running else "unknown",
state=state, failed_at=running.key if running else "unknown", on_behalf_of=actor.gitea_login,
) )
finally: finally:
# Push the sentinel so any open SSE handler returns. # Push the sentinel so any open SSE handler returns.
await state.queue.put(None) await state.queue.put(None)
async def _rollback( async def _cleanup_unmerged(
*, *, config: Config, bot: Bot, actor: Actor, state: GraduationState,
config: Config, gitea: Gitea, bot: Bot, actor: Actor,
state: GraduationState, failed_at: str,
) -> None: ) -> None:
"""Run undoes in reverse order from the last completed step. Each """A merge failure leaves the flip PR open on its `graduate-<slug>-<hex>`
undo emits its own rollback-step event so the dialog can render the branch. Close the PR and delete the branch so failed attempts don't
cleanup as a visible step appended to the stack per §13.3.""" accumulate on the meta repo. Best-effort failures here are logged,
state.rollback_started = True not surfaced as a separate step (the entry already stays a super-draft).
# Mark every step after the failed one as not-reached so the rendered """
# stack is honest about what didn't run.
seen_failure = False
for s in state.steps:
if s.status == "failed":
seen_failure = True
continue
if seen_failure and s.status == "pending":
s.status = "not-reached"
# Walk completed steps in reverse and run their inverses.
for s in reversed(state.steps):
if s.status != "done":
continue
undo = _UNDO_BY_STEP.get(s.key)
if undo is None:
continue
rb = StepState(key=f"undo:{s.key}", label=f"Undo: {s.label}",
status="running", detail="")
state.rollback_steps.append(rb)
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
try:
detail = await undo(
config=config, gitea=gitea, bot=bot, actor=actor, state=state,
)
except Exception as e:
rb.status = "failed"
rb.detail = f"{e}"
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
continue
rb.status = "done"
rb.detail = detail or ""
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
_audit(
None, actor.gitea_login, "graduate_rollback",
rfc_slug=state.slug,
details={
"failed_at": failed_at,
"error": state.error,
"rfc_id": state.rfc_id,
"repo": state.repo_full,
"undone": [s.key for s in state.rollback_steps if s.status == "done"],
},
)
state.finished = True
state.succeeded = False
await state.queue.put({"event": "rolled_back", "payload": state.to_payload()})
async def _undo_create_repo(*, config, gitea, bot, actor, state) -> str:
await bot.delete_rfc_repo(
actor, org=config.gitea_org, repo_name=state.repo_name,
slug=state.slug, reason="graduation rollback",
)
return f"Deleted `{state.repo_full}`"
async def _undo_seed_files(*, config, gitea, bot, actor, state) -> str:
# The seed commits live inside the per-RFC repo created in step 1;
# deleting the repo (step 1's undo) reclaims them at the same time.
# We surface a separate rollback step here so the rendered stack
# mirrors the forward steps, but the work is folded into _undo_create_repo.
return "Folded into repo deletion"
async def _undo_open_pr(*, config, gitea, bot, actor, state) -> str:
if state.new_pr_number is None: if state.new_pr_number is None:
return "No PR opened" return
await bot.close_graduation_pr( try:
actor, await bot.close_graduation_pr(
org=config.gitea_org, meta_repo=config.meta_repo, actor,
pr_number=state.new_pr_number, org=config.gitea_org, meta_repo=config.meta_repo,
head_branch=state.graduation_branch or "", pr_number=state.new_pr_number,
slug=state.slug, reason="graduation rollback", head_branch=state.graduation_branch or "",
) slug=state.slug, reason="graduation merge failed",
# Per the §19.2 "graduation rollback's branch cleanup" candidate )
# that Slice 8 settles: delete the dash-suffixed branch on rollback except Exception:
# so failed-graduation branches don't accumulate on the meta repo. log.exception("graduate cleanup: close PR #%s failed", state.new_pr_number)
# The §12 hygiene sweep would catch this eventually, but closing
# the loop here removes the chance of pile-up across retries.
branch_name = state.graduation_branch or "" branch_name = state.graduation_branch or ""
if branch_name: if branch_name:
try: try:
@@ -870,24 +627,35 @@ async def _undo_open_pr(*, config, gitea, bot, actor, state) -> str:
branch=branch_name, branch=branch_name,
slug=state.slug, slug=state.slug,
action_kind="delete_post_merge_branch", action_kind="delete_post_merge_branch",
reason="graduation rollback", reason="graduation merge failed",
) )
except Exception: except Exception:
log.exception("rollback: delete_branch failed for %s", branch_name) log.exception("graduate cleanup: delete_branch %s failed", branch_name)
return f"Closed PR #{state.new_pr_number}"
# merge_pr's undo is intentionally absent — once the meta-repo merge has async def _finish_failed(state: GraduationState, *, failed_at: str, on_behalf_of: str) -> None:
# landed, graduation is irreversible per §13.5. If we ever reach a merged """Mark any step after the failure as not-reached, write the audit
# state and a later step fails (which can't happen — refresh_cache failures row, and emit the terminal failed event."""
# fold into success), there is no clean undo path; the user transitions seen_failure = False
# via §3's `withdraw` instead. for s in state.steps:
if s.status == "failed":
_UNDO_BY_STEP = { seen_failure = True
"create_repo": _undo_create_repo, continue
"seed_files": _undo_seed_files, if seen_failure and s.status == "pending":
"open_pr": _undo_open_pr, s.status = "not-reached"
} _audit(
None, on_behalf_of, "graduate_failed",
rfc_slug=state.slug,
details={
"failed_at": failed_at,
"error": state.error,
"rfc_id": state.rfc_id,
"pr_number": state.new_pr_number,
},
)
state.finished = True
state.succeeded = False
await state.queue.put({"event": "failed", "payload": state.to_payload()})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -907,7 +675,7 @@ def _can_graduate(rfc, viewer) -> bool:
def _audit( def _audit(
actor_user_id: int | None, actor_user_id: int | None,
on_behalf_of: str, on_behalf_of: str | None,
action_kind: str, action_kind: str,
*, *,
rfc_slug: str | None = None, rfc_slug: str | None = None,
@@ -918,7 +686,7 @@ def _audit(
"""Direct audit-log write for graduation lifecycle events that don't """Direct audit-log write for graduation lifecycle events that don't
correspond to a single Gitea write. The per-step Gitea writes log correspond to a single Gitea write. The per-step Gitea writes log
themselves via the bot's `_log`; this is for the bracketing themselves via the bot's `_log`; this is for the bracketing
`graduate_start` / `graduate_complete` / `graduate_rollback` rows.""" `graduate_start` / `graduate_complete` / `graduate_failed` rows."""
db.conn().execute( db.conn().execute(
""" """
INSERT INTO actions INSERT INTO actions
@@ -935,8 +703,7 @@ def _audit(
json.dumps(details) if details else None, json.dumps(details) if details else None,
), ),
) )
# §15 chokepoint per Slice 6: the bracket rows (graduate_start, # §15 chokepoint: the bracket rows drive their own notifications.
# graduate_complete) drive their own notifications per §15.1.
from . import notify from . import notify
notify.fan_out_from_action( notify.fan_out_from_action(
actor_user_id=actor_user_id, actor_user_id=actor_user_id,
+77 -59
View File
@@ -133,69 +133,15 @@ def make_router() -> APIRouter:
"Only the RFC's owner can invite collaborators", "Only the RFC's owner can invite collaborators",
) )
invitee_email = body.invitee_email.strip() return issue_invitation(
role_in_rfc = body.role_in_rfc slug=slug,
inviter_user_id=viewer.user_id,
# Refuse re-inviting an email that already has a pending
# invitation on this RFC at the same role. Different-role
# re-invite is allowed (upgrade discussant → contributor)
# — the new row supersedes the old in the UI listing's
# natural ordering, and acceptance of either picks up the
# corresponding role.
existing = db.conn().execute(
"""
SELECT id FROM rfc_invitations
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
AND role_in_rfc = ? AND status = 'pending'
LIMIT 1
""",
(slug, invitee_email, role_in_rfc),
).fetchone()
if existing:
raise HTTPException(
409,
f"{invitee_email} already has a pending {role_in_rfc} invitation for this RFC",
)
token = _mint_token()
cur = db.conn().execute(
"""
INSERT INTO rfc_invitations
(rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
token, expires_at)
VALUES (?, ?, ?, ?, ?, datetime('now', ?))
""",
(
slug,
viewer.user_id,
invitee_email,
role_in_rfc,
token,
f"+{INVITATION_TTL_DAYS} days",
),
)
invitation_id = cur.lastrowid
# Send the email — synchronous. A send failure logs and
# returns; the row stays so the owner can recover via the
# listing (which carries the token for an out-of-band share).
_send_invitation_email(
to_address=invitee_email,
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner", inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
invitee_email=body.invitee_email,
role_in_rfc=body.role_in_rfc,
rfc_title=rfc["title"], rfc_title=rfc["title"],
role_in_rfc=role_in_rfc,
token=token,
) )
return {
"id": invitation_id,
"rfc_slug": slug,
"invitee_email": invitee_email,
"role_in_rfc": role_in_rfc,
"status": "pending",
"token": token,
}
# --------------------------------------------------------------- # ---------------------------------------------------------------
# GET /api/rfcs/<slug>/invitations # GET /api/rfcs/<slug>/invitations
# The owner's listing of every invitation on the RFC, regardless # The owner's listing of every invitation on the RFC, regardless
@@ -473,6 +419,78 @@ def _effective_status(row) -> str:
return "expired" if is_past else "pending" return "expired" if is_past else "pending"
def issue_invitation(
*,
slug: str,
inviter_user_id: int,
inviter_display: str,
invitee_email: str,
role_in_rfc: str,
rfc_title: str,
) -> dict:
"""Mint + persist + email one ``rfc_invitations`` row.
The single chokepoint for issuing an invitation: the owner's manual
`POST /api/rfcs/{slug}/invitations` endpoint and roadmap #28 Part 3's
accept path both route through here, so the dup-guard, token mint,
insert, and transactional email stay identical.
Refuses (409) re-inviting an email that already has a pending
invitation on this RFC at the same role. A different-role re-invite is
allowed (the discussant contributor upgrade) the new row
supersedes the old in the listing's natural ordering, and acceptance
of either picks up the corresponding role.
Returns the new row's dict (including the raw token, for the owner's
out-of-band share / the caller's record-keeping). A send failure logs
and returns; the row stays so the owner can recover via the listing.
"""
invitee_email = invitee_email.strip()
existing = db.conn().execute(
"""
SELECT id FROM rfc_invitations
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
AND role_in_rfc = ? AND status = 'pending'
LIMIT 1
""",
(slug, invitee_email, role_in_rfc),
).fetchone()
if existing:
raise HTTPException(
409,
f"{invitee_email} already has a pending {role_in_rfc} invitation for this RFC",
)
token = _mint_token()
cur = db.conn().execute(
"""
INSERT INTO rfc_invitations
(rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
token, expires_at)
VALUES (?, ?, ?, ?, ?, datetime('now', ?))
""",
(slug, inviter_user_id, invitee_email, role_in_rfc, token, f"+{INVITATION_TTL_DAYS} days"),
)
invitation_id = cur.lastrowid
_send_invitation_email(
to_address=invitee_email,
inviter_display=inviter_display,
rfc_title=rfc_title,
role_in_rfc=role_in_rfc,
token=token,
)
return {
"id": invitation_id,
"rfc_slug": slug,
"invitee_email": invitee_email,
"role_in_rfc": role_in_rfc,
"status": "pending",
"token": token,
}
def _mint_token() -> str: def _mint_token() -> str:
"""A 256-bit URL-safe token. The token shape is opaque to the """A 256-bit URL-safe token. The token shape is opaque to the
consumer; the email link encodes it as a query param.""" consumer; the email link encodes it as a query param."""
+15 -11
View File
@@ -603,7 +603,7 @@ def make_router(
repo=repo, repo=repo,
slug=slug, slug=slug,
file_path=_file_path_for(rfc), file_path=_file_path_for(rfc),
is_super_draft=_is_super_draft(rfc), is_super_draft=_is_meta_resident(rfc),
original_branch=original_branch, original_branch=original_branch,
resolution_branch=resolution_branch, resolution_branch=resolution_branch,
) )
@@ -671,32 +671,36 @@ def make_router(
"""Used by the §10 PR-flow read and write paths. Per §17's routing- """Used by the §10 PR-flow read and write paths. Per §17's routing-
collapse rule, a super-draft RFC also routes here its body-edit collapse rule, a super-draft RFC also routes here its body-edit
PRs are meta-repo PRs with pr_kind='meta_body_edit', but the API PRs are meta-repo PRs with pr_kind='meta_body_edit', but the API
surface is identical.""" surface is identical. Under the meta-only topology (§1) an active
RFC is meta-resident too (repo is null) that is normal, not an
error, so there is no per-RFC-repo precondition."""
row = _require_rfc(slug) row = _require_rfc(slug)
if row["state"] not in ("active", "super-draft"): if row["state"] not in ("active", "super-draft"):
raise HTTPException(409, f"RFC is {row['state']}") raise HTTPException(409, f"RFC is {row['state']}")
if row["state"] == "active" and not row["repo"]:
raise HTTPException(409, "RFC has no repo")
return row return row
def _is_super_draft(rfc) -> bool: def _is_meta_resident(rfc) -> bool:
return rfc["state"] == "super-draft" """Meta-only topology (§1): an entry lives in the meta repo's
`rfcs/<slug>.md` (super-draft or active-in-place) unless it carries
a legacy per-RFC `repo:` which nothing does after the RFC-0001
fold-back (§13.6). Drives the body/path/repo dispatch below."""
return not rfc["repo"]
def _owner_repo(rfc) -> tuple[str, str]: def _owner_repo(rfc) -> tuple[str, str]:
if _is_super_draft(rfc): if _is_meta_resident(rfc):
return config.gitea_org, config.meta_repo return config.gitea_org, config.meta_repo
owner, repo = rfc["repo"].split("/", 1) owner, repo = rfc["repo"].split("/", 1)
return owner, repo return owner, repo
def _file_path_for(rfc) -> str: def _file_path_for(rfc) -> str:
if _is_super_draft(rfc): if _is_meta_resident(rfc):
return f"rfcs/{rfc['slug']}.md" return f"rfcs/{rfc['slug']}.md"
return RFC_FILE_PATH return RFC_FILE_PATH
def _extract_body(rfc, file_contents: str) -> str: def _extract_body(rfc, file_contents: str) -> str:
"""For super-draft entries the file on disk is the full """For meta-resident entries the file on disk is the full
frontmatter+body envelope; the editable body is entry.body.""" frontmatter+body envelope; the editable body is entry.body."""
if not _is_super_draft(rfc): if not _is_meta_resident(rfc):
return file_contents return file_contents
try: try:
entry = entry_mod.parse(file_contents) entry = entry_mod.parse(file_contents)
@@ -760,7 +764,7 @@ def make_router(
return row["original_pr_number"] if row else None return row["original_pr_number"] if row else None
async def _refresh_after_pr_write(rfc) -> None: async def _refresh_after_pr_write(rfc) -> None:
if _is_super_draft(rfc): if _is_meta_resident(rfc):
await cache.refresh_meta_repo(config, gitea) await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea) await cache.refresh_meta_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea) await cache.refresh_meta_pulls(config, gitea)
+13 -136
View File
@@ -695,111 +695,7 @@ class Bot:
) )
return sha return sha
# ----- §13 graduation: per-step primitives and rollback inverses ----- # ----- §13 graduation (meta-only): open + merge the flip PR -----
async def create_rfc_repo_for_graduation(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
title: str,
) -> dict:
"""§13.3 step 1: create the per-RFC repo.
Empty repo (no auto-init) `seed_graduated_rfc` writes the first
commit on `main`. Returns the Gitea repo payload."""
repo = await self._gitea.create_org_repo(
org, repo_name, description=f"RFC: {title}"
)
_log(
actor,
"graduate_repo_create",
rfc_slug=slug,
details={"repo": f"{org}/{repo_name}", "title": title},
)
return repo
async def seed_graduated_rfc(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
title: str,
rfc_body: str,
rfc_id: str,
meta_full: str,
meta_path: str,
owners: list[str],
arbiters: list[str],
tags: list[str],
) -> str:
"""§13.3 step 2: seed RFC.md, README.md, .rfc/metadata.yaml on the
new repo's `main`. Three create_file calls; one audit row.
Returns the final commit sha on main.
"""
import yaml as _yaml
ae = actor.email or f"{actor.gitea_login}@users.noreply"
# 2a) RFC.md — the document. The super-draft's body is migrated
# verbatim per §13.3; if the body is empty we seed a minimal
# placeholder so the editor has something to render on first open.
body = rfc_body.strip() + "\n" if rfc_body.strip() else (
f"# {title}\n\n*RFC.md to be filled in — the super-draft graduated with an empty body.*\n"
)
rfc_msg = _stamp_single(f"Seed RFC.md from super-draft {slug}", actor)
rfc_result = await self._gitea.create_file(
org, repo_name, "RFC.md",
content=body, message=rfc_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
# 2b) README.md — header pointing back at the meta-repo entry.
readme = (
f"# {rfc_id}{title}\n\n"
f"This repository carries the canonical text of {rfc_id}.\n"
f"The meta-repo entry is `{meta_path}` in `{meta_full}`.\n\n"
f"The RFC body is in `RFC.md`. Contributions go through the\n"
f"app's §8 RFC view — open a branch, propose changes, land a PR.\n"
)
readme_msg = _stamp_single(f"Seed README.md for {rfc_id}", actor)
await self._gitea.create_file(
org, repo_name, "README.md",
content=readme, message=readme_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
# 2c) .rfc/metadata.yaml — mirror of meta-repo frontmatter for
# future tooling (linting, automation, CI lookups).
meta_yaml = _yaml.safe_dump(
{
"slug": slug, "title": title, "id": rfc_id,
"owners": owners, "arbiters": arbiters, "tags": list(tags),
},
sort_keys=False,
)
meta_msg = _stamp_single(f"Seed .rfc/metadata.yaml for {rfc_id}", actor)
meta_result = await self._gitea.create_file(
org, repo_name, ".rfc/metadata.yaml",
content=meta_yaml, message=meta_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
last_sha = (
meta_result.get("commit", {}).get("sha")
or rfc_result.get("commit", {}).get("sha")
or ""
)
_log(
actor,
"graduate_repo_seed",
rfc_slug=slug,
branch_name="main",
bot_commit_sha=last_sha,
details={"repo": f"{org}/{repo_name}", "rfc_id": rfc_id},
)
return last_sha
async def open_graduation_pr( async def open_graduation_pr(
self, self,
@@ -811,13 +707,14 @@ class Bot:
new_file_contents: str, new_file_contents: str,
prior_sha: str, prior_sha: str,
rfc_id: str, rfc_id: str,
repo_full: str,
owners: list[str], owners: list[str],
) -> dict: ) -> dict:
"""§13.3 step 3: open a PR against the meta repo that strips the """§13.3 (meta-only): open a PR against the meta repo that flips the
super-draft body and fills graduation frontmatter fields. Branch entry's frontmatter to `state: active` with the integer `id` and
name uses the `graduate-<slug>-<6hex>` shape dash-separated like graduation stamps **keeping the body unchanged** (§1 meta-only
the other meta-repo branches per the §19.2 path-routing candidate. topology; no repo is created and no body is stripped). Branch name
uses the `graduate-<slug>-<6hex>` shape dash-separated like the
other meta-repo branches per the §19.2 path-routing candidate.
""" """
import secrets import secrets
@@ -844,11 +741,11 @@ class Bot:
pr_body_text = ( pr_body_text = (
f"Graduates super-draft `{slug}` to active.\n\n" f"Graduates super-draft `{slug}` to active.\n\n"
f"- ID: `{rfc_id}`\n" f"- ID: `{rfc_id}`\n"
f"- Repo: `{repo_full}`\n"
f"- Owners: {owners_str}\n\n" f"- Owners: {owners_str}\n\n"
f"The meta-repo entry becomes frontmatter-only; the canonical body\n" f"This is an in-place state flip per the meta-only topology\n"
f"moves to `RFC.md` in the new repo. The graduation sequence is\n" f"(SPEC §1, §13.3): the entry `rfcs/{slug}.md` keeps its body and\n"
f"transactional per §13.3." f"stays in the meta repo. Only the frontmatter changes — `state`,\n"
f"`id`, and the graduation stamps."
) )
_subject, pr_body = _stamp("", pr_body_text, actor) _subject, pr_body = _stamp("", pr_body_text, actor)
pr = await self._gitea.create_pull( pr = await self._gitea.create_pull(
@@ -862,7 +759,7 @@ class Bot:
branch_name=branch, branch_name=branch,
pr_number=pr["number"], pr_number=pr["number"],
bot_commit_sha=commit_sha, bot_commit_sha=commit_sha,
details={"pr_title": pr_title, "rfc_id": rfc_id, "repo": repo_full}, details={"pr_title": pr_title, "rfc_id": rfc_id},
) )
return pr return pr
@@ -912,27 +809,7 @@ class Bot:
details={"rfc_id": rfc_id}, details={"rfc_id": rfc_id},
) )
# ----- §13.3 rollback inverses ----- # ----- §13.3 (meta-only): cleanup of an unmerged flip PR -----
async def delete_rfc_repo(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
reason: str,
) -> None:
"""Undo of `create_rfc_repo_for_graduation`. Records `graduate_repo_delete`
in the audit log with the rollback reason so the §13.3 stack's
rendered failure surface can be reconstructed from `actions`."""
await self._gitea.delete_repo(org, repo_name)
_log(
actor,
"graduate_repo_delete",
rfc_slug=slug,
details={"repo": f"{org}/{repo_name}", "reason": reason},
)
async def close_graduation_pr( async def close_graduation_pr(
self, self,
+42 -4
View File
@@ -219,6 +219,19 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
open_pulls, closed_pulls = [], [] open_pulls, closed_pulls = [], []
for pull in open_pulls + closed_pulls: for pull in open_pulls + closed_pulls:
head_branch = pull.get("head", {}).get("ref", "") 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) state = _state_from_pull(pull)
gitea_opener = (pull.get("user") or {}).get("login") or "" gitea_opener = (pull.get("user") or {}).get("login") or ""
opened_by = _resolve_actor( opened_by = _resolve_actor(
@@ -329,9 +342,13 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
if not slug: if not slug:
continue continue
rfc = db.conn().execute( rfc = db.conn().execute(
"SELECT state FROM cached_rfcs WHERE slug = ?", (slug,) "SELECT state, repo FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone() ).fetchone()
if not rfc or rfc["state"] != "super-draft": # Meta-only topology (§1): edit branches live on the meta repo for
# every meta-resident entry — super-drafts and active RFCs alike
# (active RFCs are graduated in place and keep editing here, §13).
# A legacy per-RFC repo (repo set) is the only thing excluded.
if not rfc or rfc["repo"] or rfc["state"] not in ("super-draft", "active"):
continue continue
edit_keys_seen.add((slug, name)) edit_keys_seen.add((slug, name))
db.conn().execute( db.conn().execute(
@@ -352,7 +369,8 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
# diverges from this single point. # diverges from this single point.
if meta_main_sha: if meta_main_sha:
super_drafts = db.conn().execute( super_drafts = db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE state = 'super-draft'" "SELECT slug FROM cached_rfcs "
"WHERE repo IS NULL AND state IN ('super-draft', 'active')"
).fetchall() ).fetchall()
for r in super_drafts: for r in super_drafts:
db.conn().execute( db.conn().execute(
@@ -374,7 +392,8 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
SELECT b.rfc_slug, b.branch_name SELECT b.rfc_slug, b.branch_name
FROM cached_branches b FROM cached_branches b
JOIN cached_rfcs r ON r.slug = b.rfc_slug JOIN cached_rfcs r ON r.slug = b.rfc_slug
WHERE r.state = 'super-draft' WHERE r.repo IS NULL
AND r.state IN ('super-draft', 'active')
AND b.state != 'deleted' AND b.state != 'deleted'
AND b.branch_name != 'main' AND b.branch_name != 'main'
""" """
@@ -431,6 +450,25 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
for pull in open_pulls + closed_pulls: for pull in open_pulls + closed_pulls:
head_branch = pull.get("head", {}).get("ref", "") 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) slug = _slug_from_head_branch(head_branch)
if slug is None: if slug is None:
continue continue
+27 -15
View File
@@ -60,12 +60,17 @@ def build_envelope(
`from_name` is the display label that goes through `formataddr` `from_name` is the display label that goes through `formataddr`
so spaces / commas in the display string are encoded correctly. so spaces / commas in the display string are encoded correctly.
`body_plain` is mandatory. `body_html`, if supplied, lands as the `body_plain` is mandatory. `body_html` is **reserved and not yet
second part of a `multipart/alternative` body mail clients enabled** (security-audit-0026 I3): no send path supplies it today
that prefer HTML render it; clients that don't fall back to the every rfc-app mail is plain text and passing it raises
plain part. The text/plain part comes first per RFC 2046, so a `NotImplementedError`. The parameter is kept in the signature for
plain-text client that picks the first body gets the readable documented future symmetry: when HTML mail is enabled it will land
text. as the second part of a `multipart/alternative` body (text/plain
first per RFC 2046, so a plain-text client picking the first part
still gets the readable text). Enabling it is a deliberate act the
caller MUST HTML-escape any user content into `body_html` first (cf.
the C1 stored-XSS class: a mail client renders the HTML) and remove
the guard below in the same change.
`reply_to`, when set, lets a send path point replies at a `reply_to`, when set, lets a send path point replies at a
different mailbox than the From line (e.g., a watcher different mailbox than the From line (e.g., a watcher
@@ -131,13 +136,20 @@ def build_envelope(
# idempotent and not require auth. See # idempotent and not require auth. See
# `api_notifications.py` for the receiver. # `api_notifications.py` for the receiver.
msg["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click" msg["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
if body_html: if body_html is not None:
# multipart/alternative: text/plain first, text/html second. # I3 (security-audit-0026): the multipart/alternative HTML path
# `set_content` sets the first part (and the message's main # is intentionally NOT enabled. No send path passes `body_html`
# body); `add_alternative` adds the second part and # today, and emitting an HTML body built from user-supplied
# restructures the message as multipart/alternative. # content without escaping it first would reintroduce the C1
msg.set_content(body_plain) # stored-XSS class in the mail channel (the recipient's client
msg.add_alternative(body_html, subtype="html") # renders the HTML). Fail loudly here rather than silently
else: # shipping HTML: enabling HTML mail is a deliberate change that
msg.set_content(body_plain) # MUST HTML-escape user content at the call site and remove this
# guard together. The text/plain path below is the only live one.
raise NotImplementedError(
"HTML email is not enabled (security-audit-0026 I3): do not "
"pass body_html until user content is HTML-escaped at the "
"call site and this guard is intentionally removed."
)
msg.set_content(body_plain)
return msg return msg
+8 -6
View File
@@ -284,9 +284,10 @@ async def _delete_branch_via_bot(
reason: str, reason: str,
) -> bool: ) -> bool:
"""Call `bot.delete_branch` with the system actor. Resolves the """Call `bot.delete_branch` with the system actor. Resolves the
`(org, repo)` pair from the slug: super-draft edit branches and `(org, repo)` pair from the slug: under the meta-only topology (§1)
graduation branches live on the meta repo; active-RFC branches every meta-resident entry's edit branches and graduation branches
live on the per-RFC repo named by `cached_rfcs.repo`. live on the meta repo; a legacy per-RFC repo (a `repo:` that survives
from before the fold-back, §13.6) is named by `cached_rfcs.repo`.
Returns True on a clean delete; False if the rfc row is missing Returns True on a clean delete; False if the rfc row is missing
(we leave the branch row in place a subsequent reconciler sweep (we leave the branch row in place a subsequent reconciler sweep
@@ -297,12 +298,13 @@ async def _delete_branch_via_bot(
if rfc is None: if rfc is None:
log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch) log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch)
return False return False
if rfc["state"] == "super-draft": if not rfc["repo"]:
owner, repo = config.gitea_org, config.meta_repo owner, repo = config.gitea_org, config.meta_repo
elif rfc["state"] == "active" and rfc["repo"] and "/" in rfc["repo"]: elif "/" in rfc["repo"]:
owner, repo = rfc["repo"].split("/", 1) owner, repo = rfc["repo"].split("/", 1)
else: else:
log.warning("hygiene: cannot resolve repo for %s state=%s", slug, rfc["state"]) log.warning("hygiene: cannot resolve repo for %s state=%s repo=%r",
slug, rfc["state"], rfc["repo"])
return False return False
try: try:
await bot.delete_branch( await bot.delete_branch(
+1 -1
View File
@@ -267,7 +267,7 @@ def _oauth_router(config) -> APIRouter:
# secret AND TURNSTILE_REQUIRED=false (the default), the gate # secret AND TURNSTILE_REQUIRED=false (the default), the gate
# opens — see `backend/app/turnstile.py` for the full matrix. # opens — see `backend/app/turnstile.py` for the full matrix.
client_ip = request.client.host if request.client else None client_ip = request.client.host if request.client else None
ts = turnstile.verify_token(body.turnstile_token, client_ip=client_ip) ts = await turnstile.verify_token(body.turnstile_token, client_ip=client_ip)
if not ts.ok: if not ts.ok:
if ts.reason == "misconfigured": if ts.reason == "misconfigured":
# TURNSTILE_REQUIRED=true but the secret is unset. This # TURNSTILE_REQUIRED=true but the secret is unset. This
+95
View File
@@ -270,6 +270,86 @@ def fan_out_new_beta_request(
) )
def fan_out_contribution_request(
*,
rfc_slug: str,
requester_user_id: int,
request_id: int,
matched_term: str,
who_i_am: str,
why: str,
use_case: str | None,
) -> list[int]:
"""Roadmap #28 Part 3: a reader asked to contribute to a pending
(super-draft) RFC. Land one actionable notification per owner and
return their ids (the caller stamps the first onto the request row as
the inbox-action handle).
Personal-direct: the owner is the named subject of the request, so the
§15.4 email gate consults `email_personal_direct` exactly as for the
other owner-facing personal events no new preference column is
needed. The request's three free-text fields ride along in the payload
so the inbox row can show the full ask inline without a second fetch.
Actor is the requester per §15.9.
"""
requester = db.conn().execute(
"SELECT display_name FROM users WHERE id = ?", (requester_user_id,)
).fetchone()
display = (requester["display_name"] if requester else None) or "Someone"
details = {
"request_id": request_id,
"matched_term": matched_term,
"requester_user_id": requester_user_id,
"requester_display": display,
"who_i_am": who_i_am,
"why": why,
"use_case": use_case or "",
}
notif_ids: list[int] = []
for recipient_id in _entry_owner_user_ids(rfc_slug):
if recipient_id == requester_user_id:
continue
notif_ids.append(
_emit_one(
recipient_user_id=recipient_id,
event_kind="contribution_request_on_pending_rfc",
category=CATEGORY_PERSONAL,
actor_user_id=requester_user_id,
rfc_slug=rfc_slug,
branch_name=None,
pr_number=None,
details=details,
)
)
return notif_ids
def notify_contribution_decided(
*,
rfc_slug: str,
requester_user_id: int,
decider_user_id: int,
request_id: int,
accepted: bool,
) -> None:
"""Roadmap #28 Part 3: tell the requester an owner accepted or declined
their contribute request. On accept the requester also receives the
#12 invitation email out-of-band; this inbox row is the in-app echo
that points them at it."""
_emit_one(
recipient_user_id=requester_user_id,
event_kind=(
"contribution_request_accepted" if accepted else "contribution_request_declined"
),
category=CATEGORY_PERSONAL,
actor_user_id=decider_user_id,
rfc_slug=rfc_slug,
branch_name=None,
pr_number=None,
details={"request_id": request_id},
)
def fan_out_chat_message( def fan_out_chat_message(
*, *,
actor_user_id: int, actor_user_id: int,
@@ -769,6 +849,16 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str |
return f"{actor} began graduating {title}." return f"{actor} began graduating {title}."
if event_kind == "pr_conflict_with_main": if event_kind == "pr_conflict_with_main":
return f"{actor} started a resolution branch on {title}." return f"{actor} started a resolution branch on {title}."
if event_kind == "contribution_request_on_pending_rfc":
# Roadmap #28 Part 3: owner-facing, actionable. The term is the
# super-draft reference that surfaced the offer; the inbox row
# renders Accept/Decline beneath this line.
term = extras.get("matched_term") or title
return f"{actor} wants to contribute to your pending RFC for '{term}'."
if event_kind == "contribution_request_accepted":
return f"{actor} accepted your request to contribute to {title} — check your email to accept the invitation."
if event_kind == "contribution_request_declined":
return f"{actor} declined your request to contribute to {title}."
if event_kind == "new_beta_request": if event_kind == "new_beta_request":
# v0.9.0: framework-scoped, not RFC-scoped. The actor (the # v0.9.0: framework-scoped, not RFC-scoped. The actor (the
# requester) and the captured full name + email read as # requester) and the captured full name + email read as
@@ -884,6 +974,11 @@ def list_inbox(
"read_at": row["read_at"], "read_at": row["read_at"],
"category": extras.get("category"), "category": extras.get("category"),
"summary": render_summary(row["event_kind"], row["actor_display"], row["rfc_title"], extras), "summary": render_summary(row["event_kind"], row["actor_display"], row["rfc_title"], extras),
# The row's payload, surfaced for kinds that render inline
# detail (e.g. #28 Part 3's contribute-request who/why/use-case
# + Accept/Decline). Safe to expose: a recipient only ever sees
# their own notifications.
"extras": extras,
}) })
if bundled: if bundled:
+239 -64
View File
@@ -1,43 +1,95 @@
"""Roadmap #28 Part 1 — auto-link RFC references in submitted prose. """Roadmap #28 — scan submitted prose for RFC-shaped references.
Scans plain-text PR descriptions and comment bodies for references to The scanner splits a plain-text PR description / comment body into a list
existing **accepted** (state='active') RFCs and returns a structured list
of *segments* the frontend renders: plain-text runs interleaved with of *segments* the frontend renders: plain-text runs interleaved with
``{"type": "rfc", ...}`` link segments. The backend never emits HTML typed link segments. The backend never emits HTML the frontend maps
the frontend maps link segments onto React anchors so the surface is each segment onto a React node so the surface is XSS-safe by
XSS-safe by construction and independent of any HTML-sanitization layer. construction and independent of any HTML-sanitization layer.
**Read-time enrichment, not submit-time persistence.** The roadmap row Three buckets, one scan (Parts 13):
phrases the scan as happening "at submit/post time"; this module instead
enriches on read. The intent the roadmap actually names "not as live
compose preview" — is honored (drafts are never scanned, only submitted
content on the read paths). Read-time was chosen for three reasons:
1. Correctness links track the *live* active-RFC set. A newly-accepted * ``{"type": "rfc", ...}`` Part 1. The term matches an
RFC starts linking in older comments; a withdrawn RFC stops linking **accepted** (``state='active'``) RFC; renders as a link to it.
everywhere. Submit-time freezing would drift stale. * ``{"type": "rfc-pending", ...}`` Part 3. The term matches a
2. Zero migration no derived data to store. (A concurrent session **pending** RFC a super-draft (``state='super-draft'``: accepted
already holds migration 023; staying migration-free keeps this slice as an idea but not yet graduated to an active RFC) which has an
conflict-free as well as simpler.) owner and a contribution surface. Renders as an "ask to contribute"
3. Cost the active-RFC corpus is small and cache-resident, so building affordance carrying the owner's display name.
the term index and scanning a 20k-char body per read is cheap. * ``{"type": "rfc-candidate", ...}`` Part 2. The term is a
strong-candidate that does **not** yet have a defining RFC. Renders
(for a viewer with create rights) as a "create RFC for '<term>'"
affordance that pre-fills the propose flow.
**Matching is conservative by design.** Only references that are unlikely Precedence at any position is active > pending > candidate, then
to be coincidental link: longest-match-first an active link always wins over a contribute offer
which always wins over a create offer for the same span.
**Read-time enrichment, not submit-time persistence** (unchanged from
Part 1): drafts are never scanned, only submitted content on the read
paths, so links/offers track the *live* corpus. The active-RFC corpus,
super-draft corpus, and tag taxonomy are all small and cache-resident,
so building the index and scanning a 20k-char body per read is cheap.
**Matching stays conservative by design.** A reference links/offers only
when it is unlikely to be coincidental:
* ``rfc_id`` tokens (e.g. ``RFC-0001``) inherently specific. * ``rfc_id`` tokens (e.g. ``RFC-0001``) inherently specific.
* Multi-word titles (containing whitespace, e.g. ``Open Human Model``). * Multi-word titles (containing whitespace, e.g. ``Open Human Model``).
* Hyphenated slugs (containing ``-``, e.g. ``open-human-model``). * Hyphenated slugs (containing ``-``, e.g. ``open-human-model``).
Single common-word titles or slugs (e.g. a hypothetical RFC titled Single common-word titles/slugs are deliberately NOT matched they
"Human") are deliberately NOT auto-linked they would turn every prose would turn every prose occurrence into an affordance.
"human" into a link. Surfacing those is the job of the roadmap's
"curated canonical-terms list", an explicit per-deployment opt-in left as **Part 2 candidate heuristic.** A candidate term is a **multi-word tag**
a future extension rather than guessed at here. from the #27 tag taxonomy (the de-facto set of tags the corpus already
carries) that has no defining RFC (no active or super-draft RFC whose
slug or title is that term). Multi-word is the same false-positive guard
the title rule uses: a single common tag word (``identity``) would be
far too noisy. Broader candidate detection capitalized multi-word
phrases mined from the text, terms repeated across recently-touched PRs,
or the #27 Haiku (``ANTHROPIC_API_KEY``) pathway — is a sanctioned but
deferred extension; the conservative tag-taxonomy heuristic is chosen
here to match Part 1's false-positive-averse philosophy.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, Iterable import json
import re
from typing import Any, Iterable, NamedTuple
class Term(NamedTuple):
"""One match key plus what to emit when it hits.
``key`` is the lowercase span to match (word-boundary, longest-first).
``kind`` is ``'active' | 'pending' | 'candidate'`` and selects the
emitted segment shape. ``slug``/``title`` carry the target RFC (active
+ pending); ``owner`` is the pending RFC's owner display name;
``term`` is the candidate's canonical display spelling.
"""
key: str
kind: str = "active"
slug: str = ""
title: str = ""
owner: str = ""
term: str = ""
# Lower number = higher precedence when two keys of equal length match at
# the same position. A real link beats a contribute offer beats a create
# offer.
_KIND_PRIORITY = {"active": 0, "pending": 1, "candidate": 2}
def _coerce(t: Term | tuple) -> Term:
"""Accept the legacy ``(key, slug, title)`` 3-tuple (treated as an
active term) alongside :class:`Term`, so direct unit-test callers and
older call sites keep working."""
if isinstance(t, Term):
return t
key, slug, title = t # legacy active 3-tuple
return Term(key=key, kind="active", slug=slug, title=title)
def _is_word_char(c: str) -> bool: def _is_word_char(c: str) -> bool:
@@ -46,18 +98,37 @@ def _is_word_char(c: str) -> bool:
return c.isalnum() or c in ("-", "_") return c.isalnum() or c in ("-", "_")
def segment_text(text: str | None, terms: list[tuple[str, str, str]]) -> list[dict[str, Any]]: def _emit(term: Term, label: str) -> dict[str, Any]:
"""Split ``text`` into text / rfc-link segments against ``terms``. """The segment dict for a matched ``term``; ``label`` preserves source
casing."""
if term.kind == "pending":
return {
"type": "rfc-pending",
"slug": term.slug,
"label": label,
"title": term.title,
"owner": term.owner,
}
if term.kind == "candidate":
return {"type": "rfc-candidate", "label": label, "term": term.term}
return {"type": "rfc", "slug": term.slug, "label": label, "title": term.title}
``terms`` is a list of ``(key_lower, slug, title)`` tuples; callers
pass it pre-sorted longest-first so the longest match wins at any def segment_text(text: str | None, terms: Iterable[Term | tuple]) -> list[dict[str, Any]]:
position (so "Open Human Model" wins over a bare "Open"). Matching is """Split ``text`` into text / link segments against ``terms``.
case-insensitive and respects word boundaries on both ends. The
returned ``label`` preserves the source casing. ``terms`` are :class:`Term` objects (or legacy ``(key, slug, title)``
active 3-tuples). Matching is case-insensitive, respects word
boundaries on both ends, and prefers the longest key then higher
:data:`_KIND_PRIORITY` at any position.
Always returns at least one segment; for empty/None input that is a Always returns at least one segment; for empty/None input that is a
single empty text segment, so callers can render uniformly. single empty text segment, so callers can render uniformly.
""" """
ordered = sorted(
(_coerce(t) for t in terms),
key=lambda t: (-len(t.key), _KIND_PRIORITY.get(t.kind, 9)),
)
if not text: if not text:
return [{"type": "text", "text": text or ""}] return [{"type": "text", "text": text or ""}]
@@ -67,28 +138,23 @@ def segment_text(text: str | None, terms: list[tuple[str, str, str]]) -> list[di
n = len(text) n = len(text)
i = 0 i = 0
while i < n: while i < n:
match: tuple[str, str, str, int] | None = None match: tuple[Term, int] | None = None
for key, slug, title in terms: for term in ordered:
klen = len(key) klen = len(term.key)
if klen == 0 or not low.startswith(key, i): if klen == 0 or not low.startswith(term.key, i):
continue continue
before = text[i - 1] if i > 0 else "" before = text[i - 1] if i > 0 else ""
after = text[i + klen] if i + klen < n else "" after = text[i + klen] if i + klen < n else ""
if _is_word_char(before) or _is_word_char(after): if _is_word_char(before) or _is_word_char(after):
continue continue
match = (key, slug, title, klen) match = (term, klen)
break break
if match is not None: if match is not None:
_key, slug, title, klen = match term, klen = match
if buf: if buf:
out.append({"type": "text", "text": "".join(buf)}) out.append({"type": "text", "text": "".join(buf)})
buf = [] buf = []
out.append({ out.append(_emit(term, text[i:i + klen]))
"type": "rfc",
"slug": slug,
"label": text[i:i + klen],
"title": title,
})
i += klen i += klen
else: else:
buf.append(text[i]) buf.append(text[i])
@@ -99,8 +165,8 @@ def segment_text(text: str | None, terms: list[tuple[str, str, str]]) -> list[di
def _keys_for(slug: str, title: str, rfc_id: str | None) -> Iterable[str]: def _keys_for(slug: str, title: str, rfc_id: str | None) -> Iterable[str]:
"""The match keys an active RFC contributes. See the module docstring """The match keys an RFC contributes. See the module docstring for why
for why each gate exists (conservative, false-positive-averse).""" each gate exists (conservative, false-positive-averse)."""
if rfc_id: if rfc_id:
rid = rfc_id.strip() rid = rfc_id.strip()
if len(rid) >= 2: if len(rid) >= 2:
@@ -117,13 +183,23 @@ def _keys_for(slug: str, title: str, rfc_id: str | None) -> Iterable[str]:
yield s.lower() yield s.lower()
def _slugify(term: str) -> str:
"""Deterministic kebab-case — mirrors the propose modal's slugify so a
tag's would-be slug compares correctly against existing RFC slugs."""
return re.sub(r"-+$", "", re.sub(r"^-+", "", re.sub(r"[^a-z0-9]+", "-", term.lower().strip())))
class LinkIndex: class LinkIndex:
"""A reusable term index built once per request and applied to many """A reusable term index built once per request and applied to many
bodies (a PR's description plus every comment on it).""" bodies (a PR's description plus every comment on it)."""
def __init__(self, terms: list[tuple[str, str, str]]): def __init__(self, terms: Iterable[Term | tuple]):
# Longest key first so the longest reference wins at each position. # Coerce + order once; segment_text re-sorts defensively but a
self._terms = sorted(terms, key=lambda t: len(t[0]), reverse=True) # pre-sorted list keeps the per-body cost to the scan itself.
self._terms: list[Term] = sorted(
(_coerce(t) for t in terms),
key=lambda t: (-len(t.key), _KIND_PRIORITY.get(t.kind, 9)),
)
def __bool__(self) -> bool: def __bool__(self) -> bool:
return bool(self._terms) return bool(self._terms)
@@ -132,27 +208,126 @@ class LinkIndex:
return segment_text(text, self._terms) return segment_text(text, self._terms)
def build_index(conn, *, exclude_slug: str | None = None) -> LinkIndex: def _owner_display(conn, owners_json: str | None, proposed_by: str | None) -> str:
"""Build a :class:`LinkIndex` from the accepted (active) RFC corpus. """The display name to show for a pending RFC's owner. First entry of
``owners_json`` resolved to its user row's display name, falling back
to the bare login, then ``proposed_by``, then a neutral noun."""
login = None
try:
owners = json.loads(owners_json or "[]")
if isinstance(owners, list):
login = next((o for o in owners if isinstance(o, str) and o.strip()), None)
except (ValueError, TypeError):
login = None
if login:
row = conn.execute(
"SELECT display_name FROM users WHERE gitea_login = ?", (login,)
).fetchone()
if row and row["display_name"]:
return row["display_name"]
return login
return (proposed_by or "").strip() or "the proposer"
``exclude_slug`` drops the RFC the surrounding surface is itself scoped
to, so an RFC's own title/id/slug don't self-link inside its own PR or def _tag_universe(conn) -> list[str]:
discussion. ``ORDER BY slug`` makes key de-duplication deterministic """Distinct tags across the cached corpus (the #27 de-facto taxonomy),
when two RFCs would contribute the same key (first slug wins).""" preserving original spelling; case-deduped."""
rows = conn.execute( rows = conn.execute("SELECT tags_json FROM cached_rfcs").fetchall()
"SELECT slug, title, rfc_id FROM cached_rfcs WHERE state = 'active' ORDER BY slug" out: list[str] = []
).fetchall()
terms: list[tuple[str, str, str]] = []
seen: set[str] = set() seen: set[str] = set()
for r in rows: for r in rows:
try:
tags = json.loads(r["tags_json"] or "[]")
except (ValueError, TypeError):
continue
if not isinstance(tags, list):
continue
for t in tags:
if not isinstance(t, str):
continue
tag = t.strip()
low = tag.lower()
if tag and low not in seen:
seen.add(low)
out.append(tag)
return out
def build_index(
conn,
*,
exclude_slug: str | None = None,
include_pending: bool = True,
include_candidates: bool = True,
) -> LinkIndex:
"""Build a :class:`LinkIndex` over the three buckets.
``exclude_slug`` drops the RFC the surrounding surface is itself scoped
to, so an RFC's own title/id/slug don't self-link (or self-offer)
inside its own PR or discussion. Precedence is enforced by insertion
order active keys are added first and a later bucket never overrides
an already-claimed key.
"""
terms: list[Term] = []
seen: set[str] = set()
def add(key: str, term: Term) -> None:
if key in seen:
return
seen.add(key)
terms.append(term)
# --- Part 1: accepted (active) RFCs. ORDER BY slug makes key
# de-duplication deterministic when two RFCs would contribute the
# same key (first slug wins). ---
active_rows = conn.execute(
"SELECT slug, title, rfc_id FROM cached_rfcs WHERE state = 'active' ORDER BY slug"
).fetchall()
# Track every slug + title that *has* a defining RFC, so Part 2 never
# offers to create one that already exists (active or pending).
defined_slugs: set[str] = set()
defined_titles: set[str] = set()
for r in active_rows:
slug = r["slug"] slug = r["slug"]
defined_slugs.add((slug or "").lower())
defined_titles.add((r["title"] or "").strip().lower())
if exclude_slug is not None and slug == exclude_slug: if exclude_slug is not None and slug == exclude_slug:
continue continue
title = r["title"] or "" title = r["title"] or ""
rfc_id = r["rfc_id"] if "rfc_id" in r.keys() else None rfc_id = r["rfc_id"] if "rfc_id" in r.keys() else None
for key in _keys_for(slug, title, rfc_id): for key in _keys_for(slug, title, rfc_id):
if key in seen: add(key, Term(key=key, kind="active", slug=slug, title=title))
# --- Part 3: pending (super-draft) RFCs. ---
pending_rows = conn.execute(
"""
SELECT slug, title, rfc_id, owners_json, proposed_by
FROM cached_rfcs WHERE state = 'super-draft' ORDER BY slug
"""
).fetchall()
for r in pending_rows:
slug = r["slug"]
defined_slugs.add((slug or "").lower())
defined_titles.add((r["title"] or "").strip().lower())
if not include_pending:
continue
if exclude_slug is not None and slug == exclude_slug:
continue
title = r["title"] or ""
rfc_id = r["rfc_id"] if "rfc_id" in r.keys() else None
owner = _owner_display(conn, r["owners_json"], r["proposed_by"])
for key in _keys_for(slug, title, rfc_id):
add(key, Term(key=key, kind="pending", slug=slug, title=title, owner=owner))
# --- Part 2: strong-candidate terms with no defining RFC. ---
if include_candidates:
for tag in _tag_universe(conn):
low = tag.lower()
# Conservative: multi-word tags only (same guard as titles).
if " " not in tag and "\t" not in tag:
continue continue
seen.add(key) if low in defined_titles or low in defined_slugs or _slugify(tag) in defined_slugs:
terms.append((key, slug, title)) continue
add(low, Term(key=low, kind="candidate", term=tag))
return LinkIndex(terms) return LinkIndex(terms)
+23 -5
View File
@@ -73,6 +73,19 @@ def _siteverify_url() -> str:
return os.environ.get("TURNSTILE_SITEVERIFY_URL", "").strip() or SITEVERIFY_URL return os.environ.get("TURNSTILE_SITEVERIFY_URL", "").strip() or SITEVERIFY_URL
async def _siteverify_post(url: str, data: dict) -> httpx.Response:
"""Perform the siteverify POST on an `httpx.AsyncClient`.
Isolated as a narrow seam (I4, security-audit-0026): the call is
awaited so a slow CloudFlare response can't block the event loop,
and tests patch *this function* rather than the shared
`httpx.AsyncClient` (which other modules gitea, docs also
construct, so a global patch would break app boot).
"""
async with httpx.AsyncClient(timeout=10.0) as client:
return await client.post(url, data=data)
@dataclass @dataclass
class VerifyOutcome: class VerifyOutcome:
"""Result of a Turnstile siteverify call. """Result of a Turnstile siteverify call.
@@ -98,7 +111,7 @@ class VerifyOutcome:
reason: str reason: str
def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOutcome: async def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOutcome:
"""Validate a Turnstile token against CloudFlare's siteverify endpoint. """Validate a Turnstile token against CloudFlare's siteverify endpoint.
Returns a VerifyOutcome describing whether the calling endpoint Returns a VerifyOutcome describing whether the calling endpoint
@@ -108,9 +121,14 @@ def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOu
* 'misconfigured' 500 "auth misconfigured" * 'misconfigured' 500 "auth misconfigured"
* 'missing-token' / 'failed' / 'network' 400 "verification failed" * 'missing-token' / 'failed' / 'network' 400 "verification failed"
Tests monkeypatch `httpx.post` (or set `TURNSTILE_SITEVERIFY_URL` Async (I4, security-audit-0026): the siteverify call is awaited on an
+ a MockTransport client) to avoid touching the real CloudFlare `httpx.AsyncClient` so a slow CloudFlare response can't block the
endpoint. No real keys are ever embedded in tests. event loop (the prior synchronous `httpx.post` stalled the single
worker for up to the 10s timeout). Callers must `await` it.
Tests monkeypatch `_siteverify_post` (the narrow async seam) to avoid
touching the real CloudFlare endpoint and to keep the patch off the
shared `httpx.AsyncClient`. No real keys are ever embedded in tests.
""" """
secret = _secret() secret = _secret()
required = _required() required = _required()
@@ -133,7 +151,7 @@ def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOu
data["remoteip"] = client_ip data["remoteip"] = client_ip
try: try:
response = httpx.post(_siteverify_url(), data=data, timeout=10.0) response = await _siteverify_post(_siteverify_url(), data)
payload = response.json() payload = response.json()
except Exception as exc: # network, JSON parse, etc. except Exception as exc: # network, JSON parse, etc.
log.warning("Turnstile siteverify call failed: %s", exc) log.warning("Turnstile siteverify call failed: %s", exc)
@@ -0,0 +1,59 @@
-- v0.29.0 / roadmap #28 Part 3 — offer-to-contribute-to-a-pending-RFC.
--
-- When the #28 scanner matches a term in submitted PR/comment text to a
-- *pending* RFC (a super-draft: accepted-as-an-idea but not yet graduated
-- to an active RFC), the reader is offered a "ask to contribute" popover.
-- Submitting it lands a row here AND a notification in each owner's §15
-- inbox; the owner can accept (which fires #12's owner-invite flow with
-- the requester as the invitee) or decline (the requester is notified and
-- the request closes).
--
-- A "pending RFC" is scoped to a super-draft (cached_rfcs.state =
-- 'super-draft'): it is in cached_rfcs (so the rfc_invitations FK that the
-- accept path reuses resolves), it carries owners (owners_json) to route
-- the request to, and it already has a discussion/contribution surface to
-- open. Pre-merge idea PRs (not yet in cached_rfcs, no contribution
-- surface) are deliberately out of scope — see backend/app/rfc_links.py.
--
-- The request row is the persistent record; the inbox notification is the
-- owner-facing actionable surface keyed back to it via `notification_id`.
CREATE TABLE IF NOT EXISTS contribution_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL
REFERENCES cached_rfcs(slug) ON DELETE CASCADE,
requester_user_id INTEGER NOT NULL
REFERENCES users(id) ON DELETE CASCADE,
-- The term in the PR/comment text that surfaced the offer (e.g. the
-- super-draft's title). Carried for the owner's context line and the
-- requester's "what RFC" anchor; not a foreign key.
matched_term TEXT NOT NULL,
-- The three contribute-request fields (§15 / #26 vocabulary).
-- `who_i_am` and `why` are required; `use_case` mirrors #26's
-- optional ground-truth field.
who_i_am TEXT NOT NULL,
why TEXT NOT NULL,
use_case TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'declined')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
decided_at TEXT,
decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
-- The rfc_invitations row minted on accept (the #12 reuse), and the
-- owner-facing notification row that carries the Accept/Decline action.
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_contribution_requests_rfc
ON contribution_requests(rfc_slug, status);
CREATE INDEX IF NOT EXISTS idx_contribution_requests_requester
ON contribution_requests(requester_user_id, status);
-- At most one open (pending) request per (RFC, requester): a second ask
-- while one is still pending is a 409, not a duplicate row. A decided
-- request (accepted/declined) does not block a fresh ask later.
CREATE UNIQUE INDEX IF NOT EXISTS idx_contribution_requests_one_open
ON contribution_requests(rfc_slug, requester_user_id)
WHERE status = 'pending';
@@ -0,0 +1,236 @@
"""v0.29.0 / roadmap #28 Parts 2 & 3 — create-RFC offers + contribute-to-
pending requests.
Two layers, mirroring test_rfc_links_vertical.py:
* The PR-view scanner surfaces `rfc-pending` (Part 3) and `rfc-candidate`
(Part 2) segments alongside Part 1's `rfc` links.
* The contribute-request flow end-to-end: a non-owner asks, each owner
gets an actionable §15 notification, accept fires #12's invite flow,
decline notifies the requester.
Reuses the FakeGitea + seed/session helpers from the existing suites.
"""
from __future__ import annotations
import json
from fastapi.testclient import TestClient
from app import db
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
from test_super_draft_vertical import seed_super_draft
from test_rfc_links_vertical import _open_pr_on
def _set_owner(slug: str, login: str) -> None:
db.conn().execute(
"UPDATE cached_rfcs SET owners_json = ? WHERE slug = ?",
(json.dumps([login]), slug),
)
def _set_tags(slug: str, tags: list[str]) -> None:
db.conn().execute(
"UPDATE cached_rfcs SET tags_json = ? WHERE slug = ?",
(json.dumps(tags), slug),
)
def _segs(segments, kind):
return [s for s in segments if s["type"] == kind]
# ---------------------------------------------------------------------------
# Part 2 + Part 3 — scanner surfaces on the PR view
# ---------------------------------------------------------------------------
def test_pending_and_candidate_segments_on_pr(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
# Host active RFC (OHM — single word, contributes no keys itself)
# carrying a multi-word tag with no defining RFC: the Part 2
# candidate. And a pending super-draft owned by alice: the Part 3
# contribute target.
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
_set_tags("ohm", ["memory model", "identity"])
seed_super_draft(fake, slug="open-human-model", title="Open Human Model",
pitch="A framework for representing humans.", proposed_by="alice")
_set_owner("open-human-model", "alice")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
pr_number = _open_pr_on(
client, fake, host_slug="ohm",
description="This builds on the Open Human Model and the memory model.",
)
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
segs = pr["description_segments"]
pending = _segs(segs, "rfc-pending")
assert len(pending) == 1
assert pending[0]["slug"] == "open-human-model"
assert pending[0]["label"] == "Open Human Model"
assert pending[0]["owner"] == "Alice" # display_name of the owner
candidate = _segs(segs, "rfc-candidate")
assert len(candidate) == 1
assert candidate[0]["term"] == "memory model"
# "identity" is a single-word tag — deliberately NOT a candidate.
assert all("identity" not in s.get("term", "") for s in candidate)
# ---------------------------------------------------------------------------
# Part 3 — the contribute-request flow
# ---------------------------------------------------------------------------
def _seed_pending_owned_by_alice(fake):
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=3, login="bob", role="contributor")
seed_super_draft(fake, slug="open-human-model", title="Open Human Model",
pitch="A framework.", proposed_by="alice")
_set_owner("open-human-model", "alice")
_REQUEST = {
"matched_term": "Open Human Model",
"who_i_am": "Bob, a researcher",
"why": "I have relevant prior work to bring.",
"use_case": "Building an identity tool.",
}
def test_request_accept_invites_and_notifies(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_pending_owned_by_alice(fake)
# Bob asks to contribute.
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
r = client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
assert r.status_code == 200, r.text
request_id = r.json()["id"]
assert r.json()["status"] == "pending"
# A second ask while pending is a 409, not a duplicate row.
assert client.post("/api/rfcs/open-human-model/contribution-requests",
json=_REQUEST).status_code == 409
# Alice (owner) sees the actionable notification with full detail.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
role="contributor", email="alice@test")
inbox = client.get("/api/notifications").json()
reqs = [i for i in inbox["items"]
if i["event_kind"] == "contribution_request_on_pending_rfc"]
assert len(reqs) == 1
assert "wants to contribute" in reqs[0]["summary"]
assert reqs[0]["extras"]["who_i_am"] == "Bob, a researcher"
assert reqs[0]["extras"]["request_id"] == request_id
# Alice accepts → #12 invitation minted for bob's email.
acc = client.post(f"/api/rfcs/open-human-model/contribution-requests/{request_id}/accept")
assert acc.status_code == 200, acc.text
assert acc.json()["status"] == "accepted"
assert acc.json()["invitation_id"]
inv = db.conn().execute(
"SELECT invitee_email, role_in_rfc, status FROM rfc_invitations "
"WHERE rfc_slug = 'open-human-model'"
).fetchone()
assert inv["invitee_email"] == "bob@test"
assert inv["role_in_rfc"] == "contributor"
assert inv["status"] == "pending"
# The request is settled — re-accepting is a 409.
assert client.post(
f"/api/rfcs/open-human-model/contribution-requests/{request_id}/accept"
).status_code == 409
# Bob gets the accepted echo in his inbox.
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
bob_kinds = [i["event_kind"] for i in client.get("/api/notifications").json()["items"]]
assert "contribution_request_accepted" in bob_kinds
def test_decline_notifies_requester(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_pending_owned_by_alice(fake)
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
request_id = client.post(
"/api/rfcs/open-human-model/contribution-requests", json=_REQUEST
).json()["id"]
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
role="contributor", email="alice@test")
dec = client.post(f"/api/rfcs/open-human-model/contribution-requests/{request_id}/decline")
assert dec.status_code == 200, dec.text
assert dec.json()["status"] == "declined"
row = db.conn().execute(
"SELECT status FROM contribution_requests WHERE id = ?", (request_id,)
).fetchone()
assert row["status"] == "declined"
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
bob_kinds = [i["event_kind"] for i in client.get("/api/notifications").json()["items"]]
assert "contribution_request_declined" in bob_kinds
def test_owner_cannot_request_own_rfc(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_pending_owned_by_alice(fake)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
role="contributor", email="alice@test")
r = client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
assert r.status_code == 409
assert "own" in r.json()["detail"].lower()
def test_request_on_active_rfc_rejected(app_with_fake_gitea):
# The contribute offer only exists for pending super-drafts; an active
# RFC uses the Part-1 link instead.
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=3, login="bob", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
r = client.post("/api/rfcs/ohm/contribution-requests", json=_REQUEST)
assert r.status_code == 409
def test_contribution_target_eligibility(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_pending_owned_by_alice(fake)
# Anonymous: not eligible, told to sign in.
t = client.get("/api/rfcs/open-human-model/contribution-target").json()
assert t["eligible"] is False
assert "sign in" in (t["reason"] or "").lower()
assert t["owner"] == "Alice"
# Bob: eligible until he has a pending ask, then not.
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
assert client.get("/api/rfcs/open-human-model/contribution-target").json()["eligible"] is True
client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
after = client.get("/api/rfcs/open-human-model/contribution-target").json()
assert after["already_requested"] is True
assert after["eligible"] is False
+8 -7
View File
@@ -119,19 +119,20 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
r = client.post(f"/api/rfcs/ohm/prs/{body_pr}/merge") r = client.post(f"/api/rfcs/ohm/prs/{body_pr}/merge")
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
# --- 7. Graduate the super-draft. --- # --- 7. Graduate the super-draft (in-place flip, §13). ---
r = client.post( r = client.post(
"/api/rfcs/ohm/graduate?_sync=1", "/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm", json={"rfc_id": "RFC-0001", "owners": ["ben"]},
"owners": ["ben"]},
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert r.json()["succeeded"] is True assert r.json()["succeeded"] is True
d = client.get("/api/rfcs/ohm").json() d = client.get("/api/rfcs/ohm").json()
assert d["state"] == "active" assert d["state"] == "active"
assert d["repo"] == "wiggleverse/rfc-0001-ohm" # Meta-only topology (§1): no per-RFC repo — the active RFC lives
# in its meta entry, `repo` stays null.
assert d["repo"] is None
# --- 8. Alice opens a PR on the now-active RFC's per-RFC repo. --- # --- 8. Alice opens a PR on the now-active RFC (meta repo). ---
# v0.16.0 (item #12): ben is the RFC owner now; alice needs a # v0.16.0 (item #12): ben is the RFC owner now; alice needs a
# per-RFC contributor invitation to cut a branch. In the # per-RFC contributor invitation to cut a branch. In the
# production flow, ben would invite her via /invitations and # production flow, ben would invite her via /invitations and
@@ -200,8 +201,8 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
) )
assert counters["deleted_post_merge"] >= 1, counters assert counters["deleted_post_merge"] >= 1, counters
# The branch is gone from FakeGitea + cached row flipped. # The branch is gone from FakeGitea (meta repo) + cached row flipped.
assert active_branch not in fake.branches[("wiggleverse", "rfc-0001-ohm")] assert active_branch not in fake.branches[("wiggleverse", "meta")]
cached = db.conn().execute( cached = db.conn().execute(
"SELECT state FROM cached_branches WHERE rfc_slug = 'ohm' AND branch_name = ?", "SELECT state FROM cached_branches WHERE rfc_slug = 'ohm' AND branch_name = ?",
(active_branch,), (active_branch,),
+17 -11
View File
@@ -11,6 +11,8 @@ from __future__ import annotations
from email.utils import parsedate_to_datetime from email.utils import parsedate_to_datetime
import pytest
from app.email_envelope import build_envelope from app.email_envelope import build_envelope
@@ -160,14 +162,18 @@ def test_envelope_plain_only_body_is_text_plain():
assert msg.get_content().strip() == "Hello, world." assert msg.get_content().strip() == "Hello, world."
def test_envelope_with_html_is_multipart_alternative(): def test_envelope_html_body_is_guarded_not_enabled():
msg = build_envelope(**_base_kwargs(body_html="<p>Hello, <b>world</b>.</p>")) # I3 (security-audit-0026): the HTML/multipart-alternative path is
assert msg.get_content_type() == "multipart/alternative" # intentionally not enabled — passing body_html must fail loudly so
# Two parts: text/plain first (so plain-text clients picking the # a future caller can't silently ship unescaped user HTML (the C1
# first part get the readable text), text/html second. # stored-XSS class in the mail channel). When HTML mail is enabled
parts = list(msg.iter_parts()) # deliberately, this test flips to assert the multipart shape.
assert len(parts) == 2 with pytest.raises(NotImplementedError):
assert parts[0].get_content_type() == "text/plain" build_envelope(**_base_kwargs(body_html="<p>Hello, <b>world</b>.</p>"))
assert parts[1].get_content_type() == "text/html"
assert "Hello, world." in parts[0].get_content()
assert "<b>world</b>" in parts[1].get_content() def test_envelope_html_none_is_plain_only():
# The guard keys on `is not None`, so the default (None) stays the
# live plain-text path — exercised here to lock the boundary.
msg = build_envelope(**_base_kwargs(body_html=None))
assert msg.get_content_type() == "text/plain"
+171 -198
View File
@@ -1,29 +1,29 @@
"""End-to-end integration tests for the Slice 5 vertical (§13 in full). """End-to-end integration tests for the §13 graduation flow under the
meta-only topology (SPEC §1, ROADMAP #36).
Walks the §13.3 transactional sequence end-to-end against the in-process Graduation is an in-place state flip on the meta entry no per-RFC repo
FakeGitea from test_propose_vertical.py: is created, the body is kept, and there is no multi-step transaction or
rollback (§13.3). These tests walk it against the in-process FakeGitea
from test_propose_vertical.py:
* Seed an owned super-draft (skipping the propose+merge + §13.1 claim * Seed an owned super-draft (the §13.1 claim flow is exercised
round-trips already proven by Slice 1 and exercised in separately in test_claim_opens_meta_pr).
test_claim_opens_meta_pr below for the §13.1 surface itself).
* GET /api/rfcs/<slug>/graduate/check returns per-field validity for * GET /api/rfcs/<slug>/graduate/check returns per-field validity for
the dialog. the two-field dialog (integer id + owners; no repo name).
* GET /api/rfcs/<slug>/blocking-prs returns the §9.8 precondition list. * POST /api/rfcs/<slug>/graduate?_sync=1 opens + merges the flip PR
* POST /api/rfcs/<slug>/graduate?_sync=1 runs the five-step sequence inline. On success: NO per-RFC repo, the meta entry is `state:
inline. On success: per-RFC repo exists with RFC.md / README.md / active` with the body KEPT and `repo` null, cached_rfcs.state flips
.rfc/metadata.yaml, meta-entry body is stripped, frontmatter is to 'active'.
graduated, cached_rfcs.state is 'active'. * An open body-edit PR no longer blocks graduation (§9.8) they
* §9.8 precondition gate refuses the start when a body-edit PR is open. coexist.
* Rollback on a mid-sequence failure unwinds repo creation cleanly. * An open-PR failure leaves the entry a super-draft (nothing created);
* §13.4 chat migration: whole-doc threads under (slug, 'main') survive a merge failure cleans up the half-open PR/branch and leaves the
graduation unchanged the rfc_slug is the canonical key per §2.3, entry a super-draft.
so no data movement is needed. * §13.4: chat threads + edit branches stay put the slug is the
* §9.8 pre-graduation history: the new RFC's /main response surfaces canonical key per §2.3, so nothing moves at the flip.
edit-branch threads under `pre_graduation_history`.
The orchestrator's `?_sync=1` seam awaits the sequence inline so the The orchestrator's `?_sync=1` seam awaits the flip inline so the test can
test can assert post-conditions on the same event loop tick. Production assert post-conditions on the same event loop tick.
clients use the spec-described SSE shape via `/graduate/progress`.
""" """
from __future__ import annotations from __future__ import annotations
@@ -110,7 +110,9 @@ def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_graduate_check_validates_three_fields(app_with_fake_gitea): def test_graduate_check_validates_id_and_owners(app_with_fake_gitea):
"""Two-field dialog under meta-only: integer id + owners. No repo
name to validate (§13.2)."""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea app, fake = app_with_fake_gitea
@@ -121,57 +123,46 @@ def test_graduate_check_validates_three_fields(app_with_fake_gitea):
sign_in_as(client, user_id=1, gitea_login="ben", sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner") display_name="Ben", role="owner")
# Happy: a fresh RFC-0001 + rfc-0001-ohm repo name. # Happy: a fresh RFC-0001.
r = client.get("/api/rfcs/ohm/graduate/check", r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
d = r.json() d = r.json()
assert d["id"]["ok"] is True assert d["id"]["ok"] is True
assert d["repo"]["ok"] is True
assert d["owners"]["ok"] is True assert d["owners"]["ok"] is True
assert d["blocking_prs"]["ok"] is True
assert d["can_submit"] is True assert d["can_submit"] is True
# No repo field in the meta-only check response.
assert "repo" not in d
# ID format error — non-numeric tail. # ID format error — non-numeric tail.
r = client.get("/api/rfcs/ohm/graduate/check", r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-abcd"})
params={"id": "RFC-abcd", "repo": "rfc-0001-ohm"})
d = r.json() d = r.json()
assert d["id"]["ok"] is False assert d["id"]["ok"] is False
assert d["can_submit"] is False assert d["can_submit"] is False
# Repo name pattern error — leading dot.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": ".bad"})
d = r.json()
assert d["repo"]["ok"] is False
def test_graduate_check_refuses_when_no_owners(app_with_fake_gitea): def test_graduate_check_refuses_when_no_owners(app_with_fake_gitea):
"""An unclaimed super-draft fails the owners precondition; can_submit """An unclaimed super-draft fails the owners precondition; can_submit
flips false even with valid id+repo.""" flips false even with a valid id."""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea app, fake = app_with_fake_gitea
with TestClient(app) as client: with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner") provision_user_row(user_id=1, login="ben", role="owner")
# No owners — simulates an unclaimed super-draft.
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=[]) seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=[])
sign_in_as(client, user_id=1, gitea_login="ben", sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner") display_name="Ben", role="owner")
r = client.get("/api/rfcs/ohm/graduate/check", r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
d = r.json() d = r.json()
assert d["owners"]["ok"] is False assert d["owners"]["ok"] is False
assert "No owners" in d["owners"]["error"] assert "No owners" in d["owners"]["error"]
assert d["can_submit"] is False assert d["can_submit"] is False
def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea): def test_graduate_happy_path_flips_in_place_keeping_body(app_with_fake_gitea):
"""The full §13.3 sequence: create repo, seed files, open PR, merge """The meta-only flip: open + merge a frontmatter PR. End state:
PR, refresh cache. End state: cached_rfcs.state='active', the meta cached_rfcs.state='active', the meta entry's body is KEPT, `repo` is
entry's body is stripped, the per-RFC repo has RFC.md, the audit null, NO per-RFC repo exists, and the audit log carries graduate_start
log carries graduate_start graduate_complete bracketing the graduate_pr_open graduate_pr_merge graduate_complete."""
per-step rows."""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app import db, entry as entry_mod from app import db, entry as entry_mod
@@ -186,60 +177,57 @@ def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea
r = client.post( r = client.post(
"/api/rfcs/ohm/graduate?_sync=1", "/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0042", "repo_name": "rfc-0042-ohm", json={"rfc_id": "RFC-0042", "owners": ["ben"]},
"owners": ["ben"]},
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
d = r.json() d = r.json()
assert d["finished"] is True assert d["finished"] is True
assert d["succeeded"] is True assert d["succeeded"] is True
assert d["repo"] == "wiggleverse/rfc-0042-ohm" # No repo in the response, no per-RFC repo on Gitea.
assert "repo" not in d
assert ("wiggleverse", "rfc-0042-ohm") not in fake.repos
assert not any(
k[1].startswith("rfc-0042") for k in fake.repos
), f"a per-RFC repo was created: {fake.repos}"
# 1. Per-RFC repo exists on Gitea. # Meta entry on main: state flipped, body KEPT, repo null.
assert ("wiggleverse", "rfc-0042-ohm") in fake.repos
# 2. Seed files landed on main.
assert ("wiggleverse", "rfc-0042-ohm", "main", "RFC.md") in fake.files
assert ("wiggleverse", "rfc-0042-ohm", "main", "README.md") in fake.files
assert ("wiggleverse", "rfc-0042-ohm", "main", ".rfc/metadata.yaml") in fake.files
rfc_md = fake.files[("wiggleverse", "rfc-0042-ohm", "main", "RFC.md")]["content"]
assert "Open Human Model is a framework" in rfc_md
# 3. Meta entry body is stripped + frontmatter graduated.
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"] meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
graduated = entry_mod.parse(meta_text) graduated = entry_mod.parse(meta_text)
assert graduated.state == "active" assert graduated.state == "active"
assert graduated.id == "RFC-0042" assert graduated.id == "RFC-0042"
assert graduated.repo == "wiggleverse/rfc-0042-ohm" assert graduated.repo is None
assert graduated.graduated_by == "ben" assert graduated.graduated_by == "ben"
assert graduated.graduated_at # non-empty ISO date assert graduated.graduated_at # non-empty ISO date
assert graduated.body.strip() == "" assert "Open Human Model is a framework" in graduated.body
# 5. cached_rfcs.state flipped to active via the inline refresh.
# cached_rfcs flipped to active via the inline refresh; body intact.
cached = db.conn().execute( cached = db.conn().execute(
"SELECT state, rfc_id, repo, body FROM cached_rfcs WHERE slug = 'ohm'" "SELECT state, rfc_id, repo, body FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone() ).fetchone()
assert cached["state"] == "active" assert cached["state"] == "active"
assert cached["rfc_id"] == "RFC-0042" assert cached["rfc_id"] == "RFC-0042"
assert cached["repo"] == "wiggleverse/rfc-0042-ohm" assert cached["repo"] is None
# cached body now mirrors RFC.md from the per-RFC repo.
assert "Open Human Model is a framework" in cached["body"] assert "Open Human Model is a framework" in cached["body"]
# Audit log: graduate_start, graduate_repo_create, graduate_repo_seed,
# graduate_pr_open, graduate_pr_merge, graduate_complete, in order.
kinds = [ kinds = [
r["action_kind"] row["action_kind"]
for r in db.conn().execute( for row in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id" "SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
) )
] ]
for needed in ("graduate_start", "graduate_repo_create", for needed in ("graduate_start", "graduate_pr_open",
"graduate_repo_seed", "graduate_pr_open",
"graduate_pr_merge", "graduate_complete"): "graduate_pr_merge", "graduate_complete"):
assert needed in kinds, f"missing audit row {needed}: {kinds}" assert needed in kinds, f"missing audit row {needed}: {kinds}"
# The retired per-repo steps must NOT appear.
for gone in ("graduate_repo_create", "graduate_repo_seed",
"graduate_repo_delete", "graduate_rollback"):
assert gone not in kinds, f"retired audit row present: {gone}"
def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea): def test_graduate_coexists_with_open_body_edit_pr(app_with_fake_gitea):
"""§9.8: an open meta-repo body-edit PR against rfcs/<slug>.md blocks """§9.8 (meta-only): an open meta-repo body-edit PR no longer blocks
graduation before the bot starts the sequence §13.3's rollback graduation the body is kept, so they coexist. /check stays
complexity does not grow.""" submittable and the flip succeeds."""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app import db from app import db
@@ -249,13 +237,11 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
provision_user_row(user_id=2, login="alice", role="contributor") provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM", seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"]) pitch=PITCH, owners=["ben"])
# v0.16.0 (item #12): ben is the RFC owner; alice needs a per-RFC
# contributor invitation to cut an edit branch on the super-draft.
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor") grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor") display_name="Alice", role="contributor")
# Cut an edit branch and open a body-edit PR (full Slice 4 path). # Cut an edit branch and open a body-edit PR.
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"] branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json() view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
thread_id = view["main_thread_id"] thread_id = view["main_thread_id"]
@@ -279,94 +265,31 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
f"/api/rfcs/ohm/branches/{branch}/open-pr", f"/api/rfcs/ohm/branches/{branch}/open-pr",
json={"title": "Add harm", "description": "Adds harm dimension."}, json={"title": "Add harm", "description": "Adds harm dimension."},
).json()["pr_number"] ).json()["pr_number"]
assert pr_number # PR is open
# /blocking-prs surfaces it. # /check stays submittable despite the open body-edit PR.
sign_in_as(client, user_id=1, gitea_login="ben", sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner") display_name="Ben", role="owner")
r = client.get("/api/rfcs/ohm/blocking-prs") d = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"}).json()
items = r.json()["items"] assert "blocking_prs" not in d
assert len(items) == 1 assert d["can_submit"] is True
assert items[0]["pr_number"] == pr_number
# /check refuses can_submit. # The flip succeeds — coexists with the open body-edit PR.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
d = r.json()
assert d["blocking_prs"]["ok"] is False
assert d["can_submit"] is False
# POST refuses with 409 — the bot never starts the sequence.
r = client.post( r = client.post(
"/api/rfcs/ohm/graduate?_sync=1", "/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm", json={"rfc_id": "RFC-0001", "owners": ["ben"]},
"owners": ["ben"]},
) )
assert r.status_code == 409
assert "blocking graduation" in r.text or "block" in r.text
def test_graduate_rollback_on_step_2_seed_failure(app_with_fake_gitea):
"""Step 2 (seed files) fails partway → the orchestrator rolls back
step 1 (delete the repo) and records the rollback in the audit log.
The cached_rfcs row stays at 'super-draft'."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
from app.gitea import Gitea, GiteaError
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Monkey-patch the bot to fail on seed_graduated_rfc. The repo
# has already been created in step 1; the rollback must delete it.
orig_seed = Bot.seed_graduated_rfc
async def boom(self, *args, **kwargs):
raise GiteaError(500, "simulated seed failure for rollback test")
Bot.seed_graduated_rfc = boom
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0003", "repo_name": "rfc-0003-ohm",
"owners": ["ben"]},
)
finally:
Bot.seed_graduated_rfc = orig_seed
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
d = r.json() assert r.json()["succeeded"] is True
assert d["finished"] is True
assert d["succeeded"] is False
# Repo deleted as the rollback inverse.
assert ("wiggleverse", "rfc-0003-ohm") not in fake.repos
# Meta entry unchanged.
cached = db.conn().execute( cached = db.conn().execute(
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'" "SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone() ).fetchone()
assert cached["state"] == "super-draft" assert cached["state"] == "active"
assert cached["rfc_id"] is None
# Audit log carries the rollback row.
kinds = [
r["action_kind"]
for r in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
assert "graduate_start" in kinds
assert "graduate_repo_create" in kinds
assert "graduate_repo_delete" in kinds
assert "graduate_rollback" in kinds
assert "graduate_complete" not in kinds
def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea): def test_graduate_open_pr_failure_leaves_super_draft(app_with_fake_gitea):
"""Step 3 (open PR) fails → the orchestrator rolls back steps 2 and """An open-PR failure creates nothing — the entry stays a super-draft
1 (deleting the repo, which reclaims the seed commits at the same with its body intact and no graduation PR on the meta repo."""
time). The meta-repo entry is untouched."""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app import db from app import db
from app.bot import Bot from app.bot import Bot
@@ -387,19 +310,92 @@ def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
try: try:
r = client.post( r = client.post(
"/api/rfcs/ohm/graduate?_sync=1", "/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0007", "repo_name": "rfc-0007-ohm", json={"rfc_id": "RFC-0007", "owners": ["ben"]},
"owners": ["ben"]},
) )
finally: finally:
Bot.open_graduation_pr = orig_open_pr Bot.open_graduation_pr = orig_open_pr
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert r.json()["succeeded"] is False assert r.json()["succeeded"] is False
# Repo torn down.
assert ("wiggleverse", "rfc-0007-ohm") not in fake.repos # Entry untouched: still super-draft, body intact on main.
# Meta entry's body still has the pitch (not stripped). cached = db.conn().execute(
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "super-draft"
assert cached["rfc_id"] is None
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"] meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
assert "Open Human Model is a framework" in meta_text assert "Open Human Model is a framework" in meta_text
kinds = [
row["action_kind"]
for row in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
assert "graduate_start" in kinds
assert "graduate_failed" in kinds
assert "graduate_complete" not in kinds
def test_graduate_merge_failure_cleans_up_pr(app_with_fake_gitea):
"""A merge failure leaves the flip PR open on its dash-suffixed
branch; the orchestrator closes the PR and deletes the branch so
failed attempts don't accumulate. The entry stays a super-draft —
the flip PR's commit was on a branch, not on main."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
from app.gitea import GiteaError
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
orig_merge = Bot.merge_graduation_pr
async def boom(self, *args, **kwargs):
raise GiteaError(502, "simulated merge failure")
Bot.merge_graduation_pr = boom
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0009", "owners": ["ben"]},
)
finally:
Bot.merge_graduation_pr = orig_merge
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is False
# Entry stays super-draft on main (the flip never merged).
cached = db.conn().execute(
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "super-draft"
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
assert "state: super-draft" in meta_text
# The dash-suffixed graduation branch was cleaned up.
grad_branches = [
name for (o, repo), branches in fake.branches.items()
if (o, repo) == ("wiggleverse", "meta")
for name in branches
if name.startswith("graduate-ohm-")
]
assert grad_branches == [], f"leftover graduation branch: {grad_branches}"
kinds = [
row["action_kind"]
for row in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
assert "graduate_pr_open" in kinds
assert "graduate_failed" in kinds
assert "graduate_complete" not in kinds
def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea): def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
"""A second graduation request for a slug already in-flight is refused.""" """A second graduation request for a slug already in-flight is refused."""
@@ -414,17 +410,14 @@ def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
sign_in_as(client, user_id=1, gitea_login="ben", sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner") display_name="Ben", role="owner")
# Seed a synthetic in-flight state so the registry refuses the second.
st = api_graduation._new_active( st = api_graduation._new_active(
"ohm", rfc_id="RFC-0001", repo_name="rfc-0001-ohm", "ohm", rfc_id="RFC-0001", owners=["ben"], arbiters=["ben"],
repo_full="wiggleverse/rfc-0001-ohm", owners=["ben"], arbiters=["ben"],
) )
st.finished = False st.finished = False
try: try:
r = client.post( r = client.post(
"/api/rfcs/ohm/graduate?_sync=1", "/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm", json={"rfc_id": "RFC-0001", "owners": ["ben"]},
"owners": ["ben"]},
) )
assert r.status_code == 409 assert r.status_code == 409
finally: finally:
@@ -432,11 +425,9 @@ def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_gitea): def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_gitea):
"""§13.4: chat threads on the super-draft's canonical-body view """§13.4: chat threads on the entry's main view (`branch_name='main'`)
(`branch_name='main'`) are interpreted as the new RFC's main-thread stay put across the flip the rfc_slug is canonical per §2.3 so the
after graduation. The rows don't move — the rfc_slug is canonical same thread surfaces from /branches/main before and after graduation."""
per §2.3 so the same thread surfaces from both before and after
the graduation."""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app import db from app import db
@@ -448,9 +439,6 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git
sign_in_as(client, user_id=1, gitea_login="ben", sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner") display_name="Ben", role="owner")
# Materialize a whole-doc main thread + a message on it. This
# mirrors what reading the canonical-body view would create
# lazily (§8.12 / api_branches._ensure_branch_chat_thread).
cur = db.conn().execute( cur = db.conn().execute(
""" """
INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, created_by) INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, created_by)
@@ -466,32 +454,26 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git
(thread_id,), (thread_id,),
) )
# Graduate.
r = client.post( r = client.post(
"/api/rfcs/ohm/graduate?_sync=1", "/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm", json={"rfc_id": "RFC-0099", "owners": ["ben"]},
"owners": ["ben"]},
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
# The thread row's identity is unchanged.
row = db.conn().execute( row = db.conn().execute(
"SELECT id, branch_name FROM threads WHERE id = ?", (thread_id,), "SELECT id, branch_name FROM threads WHERE id = ?", (thread_id,),
).fetchone() ).fetchone()
assert row["branch_name"] == "main" assert row["branch_name"] == "main"
# The new RFC's main view surfaces the same thread id as its
# whole-doc main thread (the entry is now active, the branch
# 'main' now points at the per-RFC repo's main, but the
# `(rfc_slug, branch_name)` key remains the canonical anchor).
r = client.get("/api/rfcs/ohm/branches/main") r = client.get("/api/rfcs/ohm/branches/main")
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert r.json()["main_thread_id"] == thread_id assert r.json()["main_thread_id"] == thread_id
def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea): def test_edit_branch_surfaces_normally_after_graduation(app_with_fake_gitea):
"""§9.8: after graduation, threads on meta-repo edit branches stay """§13.4 (meta-only): after graduation an edit branch is a *current*
attached to their original branch_name and surface from the new branch of the now-active RFC it surfaces in the normal `branches`
RFC's /main response under `pre_graduation_history`.""" list, and there is no separate `pre_graduation_history` set (that
affordance is legacy per-repo only)."""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app import db from app import db
@@ -501,10 +483,8 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
provision_user_row(user_id=2, login="alice", role="contributor") provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM", seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"]) pitch=PITCH, owners=["ben"])
# v0.16.0 (item #12): alice needs per-RFC contributor access.
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor") grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
# Alice cuts an edit branch and starts chatting on it.
sign_in_as(client, user_id=2, gitea_login="alice", sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor") display_name="Alice", role="contributor")
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"] branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
@@ -513,30 +493,25 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
db.conn().execute( db.conn().execute(
""" """
INSERT INTO thread_messages (thread_id, role, author_user_id, text) INSERT INTO thread_messages (thread_id, role, author_user_id, text)
VALUES (?, 'user', 2, 'pre-graduation note on an edit branch') VALUES (?, 'user', 2, 'note on an edit branch')
""", """,
(thread_id,), (thread_id,),
) )
# Ben graduates.
sign_in_as(client, user_id=1, gitea_login="ben", sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner") display_name="Ben", role="owner")
r = client.post( r = client.post(
"/api/rfcs/ohm/graduate?_sync=1", "/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0100", "repo_name": "rfc-0100-ohm", json={"rfc_id": "RFC-0100", "owners": ["ben"]},
"owners": ["ben"]},
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
# /main on the now-active RFC surfaces the pre-graduation history. d = client.get("/api/rfcs/ohm/main").json()
r = client.get("/api/rfcs/ohm/main")
d = r.json()
assert d["state"] == "active" assert d["state"] == "active"
hist = d["pre_graduation_history"] # The edit branch is a current branch; no pre-graduation hop.
assert len(hist) >= 1 assert d["pre_graduation_history"] == []
assert any(h["branch_name"] == branch for h in hist) assert any(b["name"] == branch for b in d["branches"]), \
target = next(h for h in hist if h["branch_name"] == branch) f"edit branch not in branches: {[b['name'] for b in d['branches']]}"
assert target["message_count"] >= 1
def test_claim_opens_meta_pr(app_with_fake_gitea): def test_claim_opens_meta_pr(app_with_fake_gitea):
@@ -560,12 +535,10 @@ def test_claim_opens_meta_pr(app_with_fake_gitea):
d = r.json() d = r.json()
assert d["branch_name"] == "claim/ohm" assert d["branch_name"] == "claim/ohm"
# The PR body's diff carries Alice in owners.
text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"] text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"]
ent = entry_mod.parse(text) ent = entry_mod.parse(text)
assert "alice" in ent.owners assert "alice" in ent.owners
# cached_prs records pr_kind='meta_claim' via refresh_meta_pulls.
row = db.conn().execute( row = db.conn().execute(
"SELECT pr_kind FROM cached_prs WHERE pr_number = ?", (d["pr_number"],), "SELECT pr_kind FROM cached_prs WHERE pr_number = ?", (d["pr_number"],),
).fetchone() ).fetchone()
+9 -10
View File
@@ -339,10 +339,10 @@ def test_hygiene_action_kinds_fire_no_notifications(app_with_fake_gitea):
def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea): def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea):
"""§19.2 candidate Slice 8 settles: when graduation rolls back """Meta-only (§13.3): when the flip's merge fails after the PR is
after step 3 (open_pr), the `graduate-<slug>-<6hex>` branch is open, the orchestrator closes the PR and deletes its
deleted alongside the PR close so failed-graduation branches `graduate-<slug>-<6hex>` branch so failed attempts don't accumulate
don't accumulate on the meta repo across retries.""" on the meta repo across retries."""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app import db from app import db
from app.bot import Bot from app.bot import Bot
@@ -359,24 +359,23 @@ def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea):
sign_in_as(client, user_id=1, gitea_login="ben", sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner") display_name="Ben", role="owner")
# Force a step-4 (merge_pr) failure so step 3 (open_pr) has # Force a merge_pr failure so the flip PR (open_pr) has already
# already landed and the rollback exercises the branch cleanup. # landed and the cleanup exercises the branch deletion.
orig_merge = Bot.merge_graduation_pr orig_merge = Bot.merge_graduation_pr
async def boom(self, *args, **kwargs): async def boom(self, *args, **kwargs):
raise GiteaError(502, "simulated merge failure for rollback test") raise GiteaError(502, "simulated merge failure for cleanup test")
Bot.merge_graduation_pr = boom Bot.merge_graduation_pr = boom
try: try:
r = client.post( r = client.post(
"/api/rfcs/ohm/graduate?_sync=1", "/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm", json={"rfc_id": "RFC-0099", "owners": ["ben"]},
"owners": ["ben"]},
) )
finally: finally:
Bot.merge_graduation_pr = orig_merge Bot.merge_graduation_pr = orig_merge
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert r.json()["succeeded"] is False assert r.json()["succeeded"] is False
# The dash-suffixed graduation branch was deleted on rollback. # The dash-suffixed graduation branch was deleted on cleanup.
meta_branches = fake.branches[("wiggleverse", "meta")] meta_branches = fake.branches[("wiggleverse", "meta")]
graduation_branches = [n for n in meta_branches if n.startswith("graduate-ohm-")] graduation_branches = [n for n in meta_branches if n.startswith("graduate-ohm-")]
assert graduation_branches == [], ( assert graduation_branches == [], (
+91
View File
@@ -577,6 +577,97 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
assert ("merge_proposal", "ben") in kinds 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): def test_slug_uniqueness_enforced(app_with_fake_gitea):
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea app, _fake = app_with_fake_gitea
+38
View File
@@ -94,6 +94,44 @@ def test_longest_match_wins():
assert out[-1]["label"] == "Open Human Model" assert out[-1]["label"] == "Open Human Model"
def test_pending_term_emits_contribute_segment():
# Part 3: a super-draft match is an `rfc-pending` segment carrying the
# owner display name, not a plain link.
idx = rfc_links.LinkIndex([
rfc_links.Term(key="open human model", kind="pending",
slug="open-human-model", title="Open Human Model", owner="Alice"),
])
out = idx.segment("see Open Human Model please")
assert out[1] == {
"type": "rfc-pending", "slug": "open-human-model",
"label": "Open Human Model", "title": "Open Human Model", "owner": "Alice",
}
def test_candidate_term_emits_create_segment():
# Part 2: a candidate term carries its canonical spelling for the
# propose pre-fill; no slug (no RFC exists yet).
idx = rfc_links.LinkIndex([
rfc_links.Term(key="memory model", kind="candidate", term="Memory Model"),
])
out = idx.segment("the memory model is unspecified")
assert out[1] == {"type": "rfc-candidate", "label": "memory model", "term": "Memory Model"}
def test_kind_precedence_active_beats_pending_beats_candidate():
# All three buckets contribute the same key; the highest-precedence
# kind (active) must win at the position.
key = "open human model"
idx = rfc_links.LinkIndex([
rfc_links.Term(key=key, kind="candidate", term="Open Human Model"),
rfc_links.Term(key=key, kind="pending", slug="ohm-draft", title="Open Human Model", owner="A"),
rfc_links.Term(key=key, kind="active", slug="open-human-model", title="Open Human Model"),
])
out = idx.segment("the Open Human Model")
assert out[-1]["type"] == "rfc"
assert out[-1]["slug"] == "open-human-model"
def test_keys_for_gating(): def test_keys_for_gating():
keys = lambda **kw: set(rfc_links._keys_for(**kw)) keys = lambda **kw: set(rfc_links._keys_for(**kw))
# rfc_id always contributes. # rfc_id always contributes.
+38 -7
View File
@@ -55,13 +55,19 @@ def _outbound_otc_envelopes(to_address: str | None = None) -> list[dict]:
def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | None = None): def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | None = None):
"""Replace `httpx.post` inside `app.turnstile` with a stub that """Replace `turnstile._siteverify_post` with an async stub that
returns the requested success shape. The stub does not touch the returns the requested success shape. The stub does not touch the
real CloudFlare endpoint and never sees a real secret. real CloudFlare endpoint and never sees a real secret.
I4 (security-audit-0026): the siteverify call is now awaited on an
`httpx.AsyncClient`, isolated behind the `_siteverify_post` seam.
Patching that narrow function (rather than the shared
`httpx.AsyncClient`, which gitea/docs also construct) keeps app boot
intact.
""" """
captured = {} captured = {}
def fake_post(url, *, data=None, timeout=None, **kwargs): async def fake_post(url, data):
captured["url"] = url captured["url"] = url
captured["data"] = data captured["data"] = data
body = {"success": bool(success)} body = {"success": bool(success)}
@@ -70,7 +76,7 @@ def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | No
return SimpleNamespace(json=lambda: body) return SimpleNamespace(json=lambda: body)
from app import turnstile as turnstile_mod from app import turnstile as turnstile_mod
monkeypatch.setattr(turnstile_mod.httpx, "post", fake_post) monkeypatch.setattr(turnstile_mod, "_siteverify_post", fake_post)
return captured return captured
@@ -170,14 +176,15 @@ def test_otc_request_admits_when_secret_unset_and_not_required(app_with_fake_git
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False) monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False) monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
# The httpx.post inside turnstile must not be called in this path — # The siteverify call inside turnstile must not be made in this path —
# patch it to a sentinel that explodes if it ever runs. # patch the seam to a sentinel that explodes if it ever runs
# (I4: the seam is now `_siteverify_post`, not module-level httpx.post).
from app import turnstile as turnstile_mod from app import turnstile as turnstile_mod
def must_not_be_called(*a, **kw): async def must_not_be_called(*a, **kw):
raise AssertionError("siteverify should not run when no secret is configured") raise AssertionError("siteverify should not run when no secret is configured")
monkeypatch.setattr(turnstile_mod.httpx, "post", must_not_be_called) monkeypatch.setattr(turnstile_mod, "_siteverify_post", must_not_be_called)
app, _fake = app_with_fake_gitea app, _fake = app_with_fake_gitea
with TestClient(app) as client: with TestClient(app) as client:
@@ -218,3 +225,27 @@ def test_otc_request_refuses_when_required_but_secret_unset(app_with_fake_gitea,
) )
assert r.status_code == 500, r.text assert r.status_code == 500, r.text
assert _outbound_otc_envelopes("alice@example.com") == [] assert _outbound_otc_envelopes("alice@example.com") == []
# ---------------------------------------------------------------------------
# I4 (security-audit-0026): verify_token is a coroutine — calling it returns
# an awaitable, not a VerifyOutcome. Locks the async contract so a revert to
# the synchronous event-loop-blocking shape fails here, not just in the
# integration paths.
# ---------------------------------------------------------------------------
def test_verify_token_is_async_and_soft_skips_without_secret(monkeypatch):
import asyncio
from app import turnstile as turnstile_mod
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
coro = turnstile_mod.verify_token("any-token")
assert asyncio.iscoroutine(coro), "verify_token must be a coroutine (I4)"
outcome = asyncio.run(coro)
# No secret + not required → the gate stays open without any network call.
assert outcome.ok is True
assert outcome.reason == "skipped"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "rfc-app-frontend", "name": "rfc-app-frontend",
"private": true, "private": true,
"version": "0.27.0", "version": "0.31.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+91 -11
View File
@@ -189,9 +189,27 @@
padding: 32px 48px; padding: 32px 48px;
} }
.welcome { max-width: 640px; } /* `.main-pane` is the §8 flex shell and carries no padding (the bare
.welcome h1 { font-size: var(--text-2xl); font-weight: 600; margin: 0 0 16px; } `.main-pane` override below shadows the padded read-view rule), so the
.welcome p { line-height: 1.7; color: var(--c-gray-600); } welcome surface owns its own breathing room top offset, comfortable
side gutters, and a capped measure for readable line length. */
.welcome {
max-width: 680px;
padding: 56px 48px 64px;
}
.welcome h1 {
font-size: var(--text-3xl); font-weight: 600;
letter-spacing: -0.01em;
margin: 0 0 var(--space-8);
}
.welcome p {
font-size: var(--text-lg);
line-height: var(--leading-relaxed);
color: var(--c-gray-600);
margin: 0 0 var(--space-8);
}
.welcome p:last-child { margin-bottom: 0; }
.welcome strong { color: var(--c-gray-800); }
/* --- RFC / Proposal view (read-only for slice 1) --- */ /* --- RFC / Proposal view (read-only for slice 1) --- */
@@ -542,7 +560,10 @@
font-size: var(--text-md); font-weight: 600; text-decoration: none; font-size: var(--text-md); font-weight: 600; text-decoration: none;
} }
.beta-pending-actions .btn-primary:hover { background: var(--c-gray-700); } .beta-pending-actions .btn-primary:hover { background: var(--c-gray-700); }
.btn-link-quiet { color: var(--c-gray-500); text-decoration: none; font-size: var(--text-base); } .btn-link-quiet {
background: none; border: none; padding: 0; cursor: pointer;
color: var(--c-gray-500); text-decoration: none; font-size: var(--text-base);
}
.btn-link-quiet:hover { color: var(--c-ink); text-decoration: underline; } .btn-link-quiet:hover { color: var(--c-ink); text-decoration: underline; }
/* v0.8.0 thin "your beta access is in review" banner. Shown on every /* v0.8.0 thin "your beta access is in review" banner. Shown on every
@@ -1365,6 +1386,30 @@
font-weight: 500; font-weight: 500;
} }
.rfc-autolink:hover { text-decoration-style: solid; } .rfc-autolink:hover { text-decoration-style: solid; }
/* #28 Parts 23: a matched term that isn't a live link but carries an
offer (contribute to a pending RFC / create a new one). The term reads
as enriched (dotted underline, no link colour); the offer is a small
trailing chip so the prose stays readable. */
.rfc-pending, .rfc-candidate {
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
.rfc-offer {
margin-left: 4px;
padding: 0 5px;
font-size: 0.74em;
font-weight: 600;
line-height: 1.5;
border-radius: 6px;
white-space: nowrap;
text-decoration: none;
border: 1px solid var(--color-border, #ccc);
color: var(--color-link);
}
.rfc-offer:hover { background: var(--color-surface-alt, rgba(0,0,0,0.04)); }
.rfc-offer-create { border-style: dashed; }
.pr-header-edit { display: flex; flex-direction: column; gap: 8px; } .pr-header-edit { display: flex; flex-direction: column; gap: 8px; }
.pr-header-right { .pr-header-right {
display: flex; flex-direction: column; align-items: flex-end; gap: 8px; display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
@@ -1583,12 +1628,18 @@
/* ---- §15 / Slice 6: inbox, badge, toasts ---- */ /* ---- §15 / Slice 6: inbox, badge, toasts ---- */
/* Lives on the dark header so it speaks the nav-link vocabulary
(.header-about et al.): borderless, gray-300 icon brightening to white
on a faint translucent-white hover. The old light-gray border + gray-50
hover were styled for a light surface and rendered as a pale box that
went white-on-white (invisible icon) on hover. */
.inbox-trigger { .inbox-trigger {
position: relative; background: transparent; border: 1px solid var(--c-gray-200); position: relative; display: inline-flex; align-items: center; justify-content: center;
border-radius: var(--radius-md); padding: 4px 10px; cursor: pointer; font-size: var(--text-lg); background: transparent; border: none;
margin-right: 12px; color: var(--c-gray-300); cursor: pointer;
padding: 5px 8px; border-radius: var(--radius-sm);
} }
.inbox-trigger:hover { background: var(--c-gray-50); } .inbox-trigger:hover { color: var(--c-white); background: rgba(255,255,255,0.08); }
.inbox-trigger .badge { .inbox-trigger .badge {
position: absolute; top: -6px; right: -6px; position: absolute; top: -6px; right: -6px;
background: #dc2626; color: white; font-size: var(--text-2xs); background: #dc2626; color: white; font-size: var(--text-2xs);
@@ -1832,7 +1883,7 @@
} }
.settings-table th, .admin-table th { .settings-table th, .admin-table th {
text-align: left; padding: 6px 8px; text-align: left; padding: 6px 8px;
font-size: var(--text-xs); text-transform: uppercase; font-size: var(--text-xs); text-transform: uppercase; white-space: nowrap;
color: var(--c-gray-500); letter-spacing: 0.05em; font-weight: 600; color: var(--c-gray-500); letter-spacing: 0.05em; font-weight: 600;
border-bottom: 1px solid var(--c-gray-200); border-bottom: 1px solid var(--c-gray-200);
} }
@@ -1913,7 +1964,21 @@
.admin-tab-header h2 { .admin-tab-header h2 {
margin: 0 0 4px; font-size: var(--text-xl); font-weight: 700; margin: 0 0 4px; font-size: var(--text-xl); font-weight: 700;
} }
.admin-tab-header p { margin: 0 0 24px; font-size: var(--text-base); } .admin-tab-header p { margin: 0 0 24px; font-size: var(--text-base); max-width: 70ch; line-height: var(--leading-normal); }
/* Title row: heading on the left, primary action flush right. */
.admin-tab-heading {
display: flex; align-items: flex-start; justify-content: space-between;
gap: var(--space-7); margin-bottom: var(--space-2);
}
.admin-tab-heading h2 { margin: 0; }
.admin-tab-actions { flex-shrink: 0; }
/* Inline DB-column references in admin copy read as quiet chips, not raw
monospace runs jammed against the sans body. */
.admin-tab-header code {
font-family: var(--font-mono); font-size: var(--text-sm);
background: var(--c-gray-100); color: var(--c-gray-700);
padding: 1px 5px; border-radius: var(--radius-sm);
}
.admin-section-h { .admin-section-h {
font-size: var(--text-base); text-transform: uppercase; font-size: var(--text-base); text-transform: uppercase;
letter-spacing: 0.05em; color: var(--c-gray-500); letter-spacing: 0.05em; color: var(--c-gray-500);
@@ -1953,8 +2018,23 @@
.allowlist-add .btn-primary:hover:not(:disabled) { background: var(--c-gray-700); } .allowlist-add .btn-primary:hover:not(:disabled) { background: var(--c-gray-700); }
.allowlist-add .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } .allowlist-add .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.user-cell { display: flex; flex-direction: column; gap: 1px; } .user-cell { display: flex; flex-direction: column; gap: 2px; }
.user-cell-handle { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.user-handle { font-weight: 500; color: var(--c-gray-900); } .user-handle { font-weight: 500; color: var(--c-gray-900); }
/* "(pending invite)" an unclaimed admin-created row. A quiet amber pill
so the admin spots it at a glance without it shouting. */
.invite-badge {
font-size: var(--text-2xs); font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em;
padding: 1px 6px; border-radius: var(--radius-pill);
background: var(--c-warning-bg); color: var(--c-warning-fg);
white-space: nowrap;
}
/* Timestamps: an intentional date-over-time stack rather than a ragged
mid-value wrap. nowrap keeps each line whole. */
.user-when { white-space: nowrap; }
.user-when-date { display: block; color: var(--c-gray-700); }
.user-when-time { display: block; font-size: var(--text-xs); }
.mute-toggle { .mute-toggle {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; gap: 6px;
font-size: var(--text-base); cursor: pointer; font-size: var(--text-base); cursor: pointer;
+27 -4
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom' import { Routes, Route, Link, Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { getMe, subscribeToNotifications } from './api' import { getMe, subscribeToNotifications } from './api'
import { anonymize, EVENTS, identify, track } from './lib/analytics' import { anonymize, EVENTS, identify, track } from './lib/analytics'
import { useLastState } from './lib/useLastState' import { useLastState } from './lib/useLastState'
@@ -9,6 +9,7 @@ import RFCView from './components/RFCView.jsx'
import PRView from './components/PRView.jsx' import PRView from './components/PRView.jsx'
import ProposalView from './components/ProposalView.jsx' import ProposalView from './components/ProposalView.jsx'
import ProposeModal from './components/ProposeModal.jsx' import ProposeModal from './components/ProposeModal.jsx'
import ContributeRequestForm from './components/ContributeRequestForm.jsx'
import Landing from './components/Landing.jsx' import Landing from './components/Landing.jsx'
import Login from './components/Login.jsx' import Login from './components/Login.jsx'
import BetaPending from './components/BetaPending.jsx' import BetaPending from './components/BetaPending.jsx'
@@ -51,6 +52,19 @@ export default function App() {
const [identifyReady, setIdentifyReady] = useState(false) const [identifyReady, setIdentifyReady] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
// #28 Parts 23: the LinkedText create/contribute affordances route via
// query params so they need no prop-threading from deep in a comment
// list. `?propose=<term>` opens the propose modal pre-filled;
// `?contribute=<slug>&term=<term>` opens the contribute-request form.
const [searchParams, setSearchParams] = useSearchParams()
const proposeParam = searchParams.get('propose')
const contributeSlug = searchParams.get('contribute')
const contributeTerm = searchParams.get('term')
const clearParams = (...keys) => {
const next = new URLSearchParams(searchParams)
keys.forEach(k => next.delete(k))
setSearchParams(next, { replace: true })
}
// v0.15.0 Page Viewed event taxonomy. We fire on every // v0.15.0 Page Viewed event taxonomy. We fire on every
// route change; the analytics wrapper itself decides whether // route change; the analytics wrapper itself decides whether
// anything ships out (consent + key check). The first fire is // anything ships out (consent + key check). The first fire is
@@ -199,7 +213,7 @@ export default function App() {
wonders why a conversation is public can reach the answer wonders why a conversation is public can reach the answer
in two clicks. Anonymous viewers see it too. */} in two clicks. Anonymous viewers see it too. */}
<Link to="/philosophy" className="header-about" title="Why this exists (§14)"> <Link to="/philosophy" className="header-about" title="Why this exists (§14)">
Philosophy About
</Link> </Link>
<Link to="/docs" className="header-about" title="User guide"> <Link to="/docs" className="header-about" title="User guide">
Docs Docs
@@ -313,17 +327,26 @@ export default function App() {
} /> } />
</Routes> </Routes>
</div> </div>
{proposeOpen && viewer && ( {(proposeOpen || proposeParam != null) && viewer && (
<ProposeModal <ProposeModal
viewer={viewer} viewer={viewer}
onClose={() => setProposeOpen(false)} initialTitle={proposeParam || ''}
onClose={() => { setProposeOpen(false); clearParams('propose') }}
onSubmitted={({ pr_number }) => { onSubmitted={({ pr_number }) => {
setProposeOpen(false) setProposeOpen(false)
clearParams('propose')
setCatalogVersion(v => v + 1) setCatalogVersion(v => v + 1)
navigate(`/proposals/${pr_number}`) navigate(`/proposals/${pr_number}`)
}} }}
/> />
)} )}
{contributeSlug && viewer && (
<ContributeRequestForm
slug={contributeSlug}
term={contributeTerm || ''}
onClose={() => clearParams('contribute', 'term')}
/>
)}
{inboxOpen && viewer && ( {inboxOpen && viewer && (
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} /> <Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
)} )}
+39 -4
View File
@@ -226,6 +226,40 @@ export async function suggestTags({ title, pitch, useCase }) {
} }
} }
// Roadmap #28 Part 3: offer-to-contribute-to-a-pending-RFC.
// `contributionTarget` feeds the contribute form (RFC title, owner
// display, the viewer's eligibility); `requestContribution` submits the
// ask; accept/decline are the owner's inbox actions.
export async function contributionTarget(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/contribution-target`))
}
export async function requestContribution(slug, { matchedTerm, whoIAm, why, useCase }) {
const res = await fetch(`/api/rfcs/${slug}/contribution-requests`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
matched_term: matchedTerm,
who_i_am: whoIAm,
why,
use_case: useCase || null,
}),
})
return jsonOrThrow(res)
}
export async function acceptContributionRequest(slug, requestId) {
return jsonOrThrow(await fetch(
`/api/rfcs/${slug}/contribution-requests/${requestId}/accept`, { method: 'POST' },
))
}
export async function declineContributionRequest(slug, requestId) {
return jsonOrThrow(await fetch(
`/api/rfcs/${slug}/contribution-requests/${requestId}/decline`, { method: 'POST' },
))
}
export async function mergeProposal(prNumber) { export async function mergeProposal(prNumber) {
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' }) const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
return jsonOrThrow(res) return jsonOrThrow(res)
@@ -496,18 +530,19 @@ export async function listBlockingPRs(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/blocking-prs`)) return jsonOrThrow(await fetch(`/api/rfcs/${slug}/blocking-prs`))
} }
export async function graduateCheck(slug, { id, repo }) { export async function graduateCheck(slug, { id }) {
// Meta-only topology (§13.2): two fields — integer id + owners. No
// repo name to validate.
const params = new URLSearchParams() const params = new URLSearchParams()
if (id != null) params.set('id', id) if (id != null) params.set('id', id)
if (repo != null) params.set('repo', repo)
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/graduate/check?${params}`)) return jsonOrThrow(await fetch(`/api/rfcs/${slug}/graduate/check?${params}`))
} }
export async function startGraduation(slug, { rfcId, repoName, owners }) { export async function startGraduation(slug, { rfcId, owners }) {
const res = await fetch(`/api/rfcs/${slug}/graduate`, { const res = await fetch(`/api/rfcs/${slug}/graduate`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rfc_id: rfcId, repo_name: repoName, owners }), body: JSON.stringify({ rfc_id: rfcId, owners }),
}) })
return jsonOrThrow(res) return jsonOrThrow(res)
} }
+45 -22
View File
@@ -181,7 +181,20 @@ function UsersTab() {
return ( return (
<div className="admin-tab"> <div className="admin-tab">
<header className="admin-tab-header"> <header className="admin-tab-header">
<h2>Users</h2> <div className="admin-tab-heading">
<h2>Users</h2>
{/* v0.17.0 roadmap item #16. The "Create user + invite"
affordance opens a modal that provisions a fresh users row
with the chosen role and sends an invite email with a
single-use claim link. */}
<div className="admin-tab-actions">
<button
type="button"
className="btn-primary"
onClick={() => setInviteModalOpen(true)}
>Create user + invite</button>
</div>
</div>
<p className="muted"> <p className="muted">
The pending bucket is the beta-access review queue (§6.1 / The pending bucket is the beta-access review queue (§6.1 /
v0.8.0). Grant or revoke writes to <code>permission_events</code> v0.8.0). Grant or revoke writes to <code>permission_events</code>
@@ -190,17 +203,6 @@ function UsersTab() {
retain their v0.7.0 semantics promote to admin to remove a retain their v0.7.0 semantics promote to admin to remove a
user's ability to write without silencing them. user's ability to write without silencing them.
</p> </p>
{/* v0.17.0 roadmap item #16. The "Create user + invite"
affordance opens a modal that provisions a fresh users row
with the chosen role and sends an invite email with a
single-use claim link. */}
<div className="admin-tab-actions">
<button
type="button"
className="btn-primary"
onClick={() => setInviteModalOpen(true)}
>Create user + invite</button>
</div>
</header> </header>
{error && <p className="settings-note warning">{error}</p>} {error && <p className="settings-note warning">{error}</p>}
{inviteModalOpen && ( {inviteModalOpen && (
@@ -270,21 +272,27 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
// the admin sees at a glance which rows are real users vs. unclaimed // the admin sees at a glance which rows are real users vs. unclaimed
// invites. // invites.
const pendingInvite = u.pending_invite const pendingInvite = u.pending_invite
// When there's no gitea_login the handle already IS the email, so the
// subline would otherwise repeat it. Only append the email when it adds
// something the handle doesn't already show.
const showEmail = u.email && u.email !== handle
return ( return (
<> <>
<tr> <tr>
<td> <td>
<div className="user-cell"> <div className="user-cell">
<span className="user-handle">{handle}</span> <div className="user-cell-handle">
{pendingInvite && ( <span className="user-handle">{handle}</span>
<span {pendingInvite && (
className="invite-badge" <span
title={`Admin-created invite; expires ${pendingInvite.expires_at}`} className="invite-badge"
>(pending invite)</span> title={`Admin-created invite; expires ${pendingInvite.expires_at}`}
)} >pending invite</span>
)}
</div>
<span className="muted"> <span className="muted">
{fullName || u.display_name} {fullName || u.display_name}
{u.email ? ` · ${u.email}` : ''} {showEmail ? ` · ${u.email}` : ''}
</span> </span>
</div> </div>
</td> </td>
@@ -317,8 +325,8 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
<span className="muted">N/A</span> <span className="muted">N/A</span>
)} )}
</td> </td>
<td className="muted">{u.created_at || '—'}</td> <TimeCell value={u.created_at} />
<td className="muted">{u.last_seen_at || '—'}</td> <TimeCell value={u.last_seen_at} />
</tr> </tr>
{state === 'pending' && u.beta_request_reason ? ( {state === 'pending' && u.beta_request_reason ? (
<tr className="user-row-reason"> <tr className="user-row-reason">
@@ -334,6 +342,21 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
) )
} }
// Render a "YYYY-MM-DD HH:MM:SS" timestamp as an intentional date-over-time
// stack (date prominent, time quiet below) rather than letting a narrow
// column wrap the value mid-string. Falls back to an em-dash when absent.
function TimeCell({ value }) {
if (!value) return <td className="muted"></td>
const [date, ...rest] = String(value).split(' ')
const time = rest.join(' ')
return (
<td className="user-when">
<span className="user-when-date">{date}</span>
{time && <span className="user-when-time muted">{time}</span>}
</td>
)
}
function PermissionCell({ user: u, busy, onFlipPermission }) { function PermissionCell({ user: u, busy, onFlipPermission }) {
const state = u.permission_state || 'granted' const state = u.permission_state || 'granted'
const decidedSuffix = u.permission_decided_at const decidedSuffix = u.permission_decided_at
@@ -0,0 +1,163 @@
// ContributeRequestForm.jsx roadmap #28 Part 3.
//
// The "ask to contribute" popover, opened from an `rfc-pending` affordance
// in LinkedText (App reads `?contribute=<slug>&term=<term>`). It loads the
// contribution target (RFC title + owner display + the viewer's
// eligibility), shows the framing line "<owner> is working on an RFC for
// '<term>'", and collects the three #15/#26-vocabulary fields:
//
// * Who I am (required, free-text)
// * Why I'm asking (required, free-text)
// * What I'd use it for (optional, mirrors #26)
//
// Submitting POSTs the request, which lands in each owner's §15 inbox.
// When the viewer isn't eligible (anonymous, already a collaborator, or
// has a pending ask) the form shows the backend's reason instead.
import { useEffect, useState } from 'react'
import { contributionTarget, requestContribution } from '../api'
export default function ContributeRequestForm({ slug, term, onClose }) {
const [target, setTarget] = useState(null)
const [loadError, setLoadError] = useState(null)
const [whoIAm, setWhoIAm] = useState('')
const [why, setWhy] = useState('')
const [useCase, setUseCase] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
const [done, setDone] = useState(false)
useEffect(() => {
let live = true
contributionTarget(slug)
.then(t => { if (live) setTarget(t) })
.catch(err => { if (live) setLoadError(err.message || 'Could not load this RFC.') })
return () => { live = false }
}, [slug])
async function handleSubmit(e) {
e.preventDefault()
if (!whoIAm.trim() || !why.trim()) return
setSubmitting(true)
setError(null)
try {
await requestContribution(slug, {
matchedTerm: term || target?.title || slug,
whoIAm: whoIAm.trim(),
why: why.trim(),
useCase: useCase.trim() || null,
})
setDone(true)
} catch (err) {
setError(err.message || 'Could not send your request.')
} finally {
setSubmitting(false)
}
}
const owner = target?.owner || 'The owner'
const label = term || target?.title || slug
return (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal">
<div className="modal-header">
<h2>Ask to contribute</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
{loadError && (
<div className="modal-body"><p className="field-error">{loadError}</p></div>
)}
{!loadError && done && (
<>
<div className="modal-body">
<p>
Your request has been sent to <strong>{owner}</strong>. You'll hear back
in your inbox; if it's accepted you'll get an invitation by email to
join the RFC.
</p>
</div>
<div className="modal-actions">
<button type="button" className="btn-primary" onClick={onClose}>Done</button>
</div>
</>
)}
{!loadError && !done && target && !target.eligible && (
<>
<div className="modal-body">
<p className="field-help" style={{ marginTop: 0 }}>
{owner} is working on an RFC for <strong>'{label}'</strong>.
</p>
<p>{target.already_requested
? "You've already asked to contribute to this RFC — the owner has your request."
: (target.reason || 'You cannot ask to contribute to this RFC right now.')}</p>
</div>
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={onClose}>Close</button>
</div>
</>
)}
{!loadError && !done && target && target.eligible && (
<form onSubmit={handleSubmit}>
<div className="modal-body">
<p className="field-help" style={{ marginTop: 0 }}>
<strong>{owner}</strong> is working on an RFC for <strong>'{label}'</strong>.
Tell them a little about why you'd like to contribute.
</p>
<label htmlFor="contribute-who">Who I am</label>
<textarea
id="contribute-who"
value={whoIAm}
onChange={e => setWhoIAm(e.target.value)}
placeholder="Your name and a sentence of context."
rows={2}
autoFocus
required
/>
<label htmlFor="contribute-why">Why I'm asking to contribute</label>
<textarea
id="contribute-why"
value={why}
onChange={e => setWhy(e.target.value)}
placeholder="What you'd bring, or what draws you to this RFC."
rows={3}
required
/>
<label htmlFor="contribute-use-case">What I'd use the RFC for (optional)</label>
<textarea
id="contribute-use-case"
value={useCase}
onChange={e => setUseCase(e.target.value)}
placeholder="The concrete thing you intend to build or do with it. Optional."
rows={2}
/>
{error && <p className="field-error">{error}</p>}
</div>
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
<button
type="submit"
className="btn-primary"
disabled={!whoIAm.trim() || !why.trim() || submitting}
>
{submitting ? 'Sending…' : 'Send request'}
</button>
</div>
</form>
)}
{!loadError && !done && !target && (
<div className="modal-body"><p className="field-help">Loading</p></div>
)}
</div>
</div>
)
}
+36 -118
View File
@@ -1,70 +1,55 @@
// GraduateDialog.jsx the §13.2 Graduate dialog and the §13.3 step stack. // GraduateDialog.jsx the §13.2 Graduate dialog and the §13.3 flip.
// //
// Renders three editable fields (integer ID, repo name, initial owners) // Meta-only topology (SPEC §1): graduation is an in-place state flip on
// with debounced server-side validation per §13.2 and a precondition // the meta entry no per-RFC repo is created and the body is kept. The
// popover backed by /blocking-prs for the §9.8 open-body-edit-PR gate. // dialog renders two editable fields (integer ID + initial owners) with
// // debounced server-side validation per §13.2. On confirm it opens the
// On confirm, opens the §13.3 SSE stream and renders the five named // §13.3 SSE stream and renders the two named steps (open the flip PR,
// steps with per-step states. On failure, the rollback step's events // merge it). There is no rollback step and no repo-name field.
// append to the stack and a "What happened" panel renders below until
// the admin dismisses it.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
graduateCheck, graduateCheck,
listBlockingPRs,
openGraduationProgress, openGraduationProgress,
startGraduation, startGraduation,
} from '../api' } from '../api'
const CHECK_DEBOUNCE_MS = 250 const CHECK_DEBOUNCE_MS = 250
const STEP_KEY_ORDER = ['create_repo', 'seed_files', 'open_pr', 'merge_pr', 'refresh_cache'] const STEP_KEY_ORDER = ['open_pr', 'merge_pr']
export default function GraduateDialog({ slug, entry, onClose, onCompleted }) { export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
// Suggest defaults from the catalog. // Suggest defaults from the catalog.
const suggestedId = useMemo(() => suggestNextRfcId(entry?.allKnownIds || []), [entry]) const suggestedId = useMemo(() => suggestNextRfcId(entry?.allKnownIds || []), [entry])
const [rfcId, setRfcId] = useState(suggestedId) const [rfcId, setRfcId] = useState(suggestedId)
const [repoName, setRepoName] = useState(`rfc-${stripPrefix(suggestedId)}-${slug}`)
const [owners, setOwners] = useState(entry?.owners?.length ? entry.owners : []) const [owners, setOwners] = useState(entry?.owners?.length ? entry.owners : [])
const [newOwner, setNewOwner] = useState('') const [newOwner, setNewOwner] = useState('')
const [checkResult, setCheckResult] = useState(null) const [checkResult, setCheckResult] = useState(null)
const [blockingPRs, setBlockingPRs] = useState([]) const [phase, setPhase] = useState('idle') // idle | running | done | failed | error
const [precondPopover, setPrecondPopover] = useState(false)
const [phase, setPhase] = useState('idle') // idle | running | done | rolled_back | error
const [streamState, setStreamState] = useState(null) const [streamState, setStreamState] = useState(null)
const [submitError, setSubmitError] = useState(null) const [submitError, setSubmitError] = useState(null)
const esRef = useRef(null) const esRef = useRef(null)
// Initial blocking-PRs probe + ongoing /check polling.
useEffect(() => {
listBlockingPRs(slug).then(({ items }) => setBlockingPRs(items || [])).catch(() => {})
}, [slug])
useEffect(() => { useEffect(() => {
const t = setTimeout(() => { const t = setTimeout(() => {
graduateCheck(slug, { id: rfcId, repo: repoName }) graduateCheck(slug, { id: rfcId })
.then(setCheckResult) .then(setCheckResult)
.catch(() => {}) .catch(() => {})
}, CHECK_DEBOUNCE_MS) }, CHECK_DEBOUNCE_MS)
return () => clearTimeout(t) return () => clearTimeout(t)
}, [slug, rfcId, repoName]) }, [slug, rfcId])
useEffect(() => () => { esRef.current?.close() }, []) useEffect(() => () => { esRef.current?.close() }, [])
const idError = checkResult?.id?.error || null const idError = checkResult?.id?.error || null
const repoError = checkResult?.repo?.error || null
const ownersOk = owners.length > 0 const ownersOk = owners.length > 0
const ownersError = ownersOk ? null : 'Add at least one initial owner' const ownersError = ownersOk ? null : 'Add at least one initial owner'
const blockingError = blockingPRs.length > 0
? `${blockingPRs.length} open body-edit PR${blockingPRs.length === 1 ? '' : 's'} blocking graduation`
: null
// First-blocker tooltip text per §13.2. // First-blocker tooltip text per §13.2.
const firstBlocker = idError || repoError || ownersError || blockingError const firstBlocker = idError || ownersError
const canSubmit = !firstBlocker && phase === 'idle' && checkResult?.id?.ok && checkResult?.repo?.ok const canSubmit = !firstBlocker && phase === 'idle' && checkResult?.id?.ok && ownersOk
const handleAddOwner = useCallback(() => { const handleAddOwner = useCallback(() => {
const v = newOwner.trim().toLowerCase() const v = newOwner.trim().toLowerCase()
@@ -81,7 +66,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
setSubmitError(null) setSubmitError(null)
setPhase('running') setPhase('running')
try { try {
await startGraduation(slug, { rfcId, repoName, owners }) await startGraduation(slug, { rfcId, owners })
} catch (err) { } catch (err) {
setPhase('idle') setPhase('idle')
setSubmitError(err.message) setSubmitError(err.message)
@@ -96,7 +81,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
// Short hold per §13.3, then dismiss. // Short hold per §13.3, then dismiss.
setTimeout(() => onCompleted?.(payload), 1500) setTimeout(() => onCompleted?.(payload), 1500)
} else { } else {
setPhase('rolled_back') setPhase('failed')
} }
} }
}, },
@@ -105,7 +90,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
setPhase('error') setPhase('error')
}, },
}) })
}, [slug, rfcId, repoName, owners, onCompleted]) }, [slug, rfcId, owners, onCompleted])
// ----- Render ----- // ----- Render -----
@@ -122,10 +107,10 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
{!showStack && ( {!showStack && (
<div className="modal-body"> <div className="modal-body">
<p className="modal-intro"> <p className="modal-intro">
§13: graduate the super-draft to its own repo. The meta-repo entry §13: graduate the super-draft to active. This is an in-place
becomes frontmatter-only; the canonical body moves to `RFC.md` in state flip the entry keeps its body and stays in the meta
the new repo. The sequence runs as five transactional steps with repo; only the frontmatter changes (state, integer ID, and the
rollback per §13.3. graduation stamps). No new repository is created.
</p> </p>
<div className="form-row"> <div className="form-row">
@@ -141,19 +126,6 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
{idError && <p className="field-error">{idError}</p>} {idError && <p className="field-error">{idError}</p>}
</div> </div>
<div className="form-row">
<label>Repo name</label>
<input
type="text"
value={repoName}
onChange={(e) => setRepoName(e.target.value.trim())}
placeholder="rfc-NNNN-slug"
disabled={phase !== 'idle'}
/>
<p className="field-help">Becomes `&lt;org&gt;/{repoName || 'rfc-…'}` on Gitea.</p>
{repoError && <p className="field-error">{repoError}</p>}
</div>
<div className="form-row"> <div className="form-row">
<label>Initial owners</label> <label>Initial owners</label>
<div className="owner-list"> <div className="owner-list">
@@ -189,68 +161,24 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
</div> </div>
{ownersError && <p className="field-error">{ownersError}</p>} {ownersError && <p className="field-error">{ownersError}</p>}
</div> </div>
{blockingPRs.length > 0 && (
<div className="precondition-block">
<button
type="button"
className="precondition-toggle"
onClick={() => setPrecondPopover(p => !p)}
>
{blockingPRs.length} open body-edit PR{blockingPRs.length === 1 ? '' : 's'} blocking graduation
&nbsp;{precondPopover ? '▾' : '▸'}
</button>
{precondPopover && (
<div className="precondition-popover">
{blockingPRs.map(pr => (
<div key={pr.pr_number} className="precondition-row">
<div className="precondition-row-main">
<strong>PR #{pr.pr_number}</strong> {pr.title || '(no title)'}
<div className="precondition-row-meta">
{pr.author ? `by @${pr.author}` : ''}
{pr.last_activity_at ? ` · ${pr.last_activity_at.slice(0, 10)}` : ''}
</div>
</div>
<div className="precondition-row-actions">
<a
className="btn-link"
href={`/rfc/${slug}/pr/${pr.pr_number}`}
target="_blank"
rel="noreferrer"
>Open </a>
</div>
</div>
))}
<p className="precondition-help">
§9.8: open body-edit PRs would attempt to re-introduce a
body to a frontmatter-only entry after step 3. Resolve
them (merge or withdraw) and re-open this dialog.
</p>
</div>
)}
</div>
)}
</div> </div>
)} )}
{showStack && ( {showStack && (
<div className="modal-body"> <div className="modal-body">
<StepStack <StepStack steps={streamState?.steps || []} />
steps={streamState?.steps || []} {phase === 'failed' && (
rollbackSteps={streamState?.rollback_steps || []}
/>
{phase === 'rolled_back' && (
<div className="what-happened"> <div className="what-happened">
<h3>What happened</h3> <h3>What happened</h3>
<p> <p>
The graduation could not complete. The app rolled back the The graduation could not complete. Because it is a single
steps that had already run; nothing was left half-applied on in-place flip, nothing was left half-applied `{slug}` stays
Gitea. Error: <code>{streamState?.error || 'unknown'}</code>. a super-draft. Error: <code>{streamState?.error || 'unknown'}</code>.
</p> </p>
<p> <p>
Read the failure detail next to the red step above. Resolve Read the failure detail next to the red step above, resolve
the underlying cause (a repo-name collision, a network flake, the underlying cause (a concurrent PR landing on{' '}
a concurrent PR landing on `rfcs/{slug}.md`) and try again. `rfcs/{slug}.md`, a network flake), and try again.
</p> </p>
</div> </div>
)} )}
@@ -258,9 +186,8 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
<div className="graduation-complete"> <div className="graduation-complete">
<h3>Graduation complete</h3> <h3>Graduation complete</h3>
<p> <p>
`{slug}` is now active as <strong>{streamState?.rfc_id}</strong>{' '} `{slug}` is now active as <strong>{streamState?.rfc_id}</strong>.
at <code>{streamState?.repo_full}</code>. The catalog and the The catalog and the RFC view reflect the new state.
RFC view reflect the new state.
</p> </p>
</div> </div>
)} )}
@@ -277,23 +204,23 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
disabled={!canSubmit} disabled={!canSubmit}
title={canSubmit ? '' : firstBlocker || ''} title={canSubmit ? '' : firstBlocker || ''}
> >
Graduate to RFC repo Graduate
</button> </button>
</> </>
)} )}
{phase === 'running' && ( {phase === 'running' && (
<span className="modal-progress-note">Running graduation sequence</span> <span className="modal-progress-note">Graduating</span>
)} )}
{(phase === 'rolled_back' || phase === 'error') && ( {(phase === 'failed' || phase === 'error') && (
<button className="btn-secondary" onClick={onClose}>Close</button> <button className="btn-secondary" onClick={onClose}>Close</button>
)} )}
{phase === 'done' && ( {phase === 'done' && (
<button className="btn-primary" onClick={() => onCompleted?.(streamState)}> <button className="btn-primary" onClick={() => onCompleted?.(streamState)}>
View the new RFC View the RFC
</button> </button>
)} )}
</div> </div>
{submitError && phase !== 'rolled_back' && ( {submitError && phase !== 'failed' && (
<div className="modal-error">Error: {submitError}</div> <div className="modal-error">Error: {submitError}</div>
)} )}
</div> </div>
@@ -302,14 +229,10 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
} }
function StepStack({ steps, rollbackSteps }) { function StepStack({ steps }) {
return ( return (
<div className="step-stack"> <div className="step-stack">
{steps.map(s => <StepRow key={s.key} step={s} />)} {steps.map(s => <StepRow key={s.key} step={s} />)}
{rollbackSteps.length > 0 && (
<div className="rollback-divider">Rollback</div>
)}
{rollbackSteps.map(s => <StepRow key={s.key} step={s} />)}
</div> </div>
) )
} }
@@ -350,8 +273,3 @@ function suggestNextRfcId(existing) {
const next = used.size === 0 ? 1 : (Math.max(...used) + 1) const next = used.size === 0 ? 1 : (Math.max(...used) + 1)
return `RFC-${String(next).padStart(4, '0')}` return `RFC-${String(next).padStart(4, '0')}`
} }
function stripPrefix(rfcId) {
return rfcId?.startsWith('RFC-') ? rfcId.slice(4) : rfcId
}
+10
View File
@@ -126,3 +126,13 @@
line-height: var(--leading-normal); line-height: var(--leading-normal);
color: var(--color-text-muted); color: var(--color-text-muted);
} }
/* #28 Part 3 actionable contribute-request row: the requester's
who/why/use-case detail plus an Accept/Decline pair. */
.inbox-row-action { display: flex; flex-direction: column; gap: 8px; padding: 12px; }
.inbox-row-action .inbox-row-main { display: flex; align-items: center; gap: 8px; }
.inbox-request-detail { margin-left: 18px; font-size: var(--text-sm); }
.inbox-request-detail p { margin: 2px 0; color: var(--color-text-muted); }
.inbox-request-detail strong { color: var(--color-text); }
.inbox-request-actions { display: flex; gap: 8px; margin-left: 18px; }
.inbox-request-outcome { margin: 0 0 0 18px; }
+66
View File
@@ -11,6 +11,8 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
acceptContributionRequest,
declineContributionRequest,
listNotifications, listNotifications,
markNotificationRead, markNotificationRead,
markNotificationsReadByFilter, markNotificationsReadByFilter,
@@ -164,7 +166,71 @@ export default function Inbox({ onClose, lastChangeTick }) {
) )
} }
// #28 Part 3: the contribute-request row is the first actionable inbox
// kind it renders the requester's who/why/use-case inline and an
// Accept/Decline pair that fire the owner's decision (accept reuses #12's
// invite flow on the backend).
function ContributionRequestRow({ item, onMarkRead }) {
const unread = !item.read_at
const x = item.extras || {}
const [outcome, setOutcome] = useState(null) // 'accepted' | 'declined'
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
async function act(accept) {
if (busy || outcome) return
setBusy(true)
setError(null)
try {
if (accept) await acceptContributionRequest(item.rfc_slug, x.request_id)
else await declineContributionRequest(item.rfc_slug, x.request_id)
setOutcome(accept ? 'accepted' : 'declined')
await onMarkRead(item)
} catch (err) {
setError(err.message || 'Action failed.')
} finally {
setBusy(false)
}
}
return (
<li className={`inbox-row inbox-row-action ${unread ? 'unread' : 'read'}`}>
<div className="inbox-row-main">
<span className="inbox-unread-dot" aria-hidden />
<span className={`inbox-cat cat-${item.category || 'unknown'}`}>{item.category || '·'}</span>
<span className="inbox-summary">{item.summary}</span>
<span className="inbox-when">{formatWhen(item.created_at)}</span>
</div>
<div className="inbox-request-detail">
{x.who_i_am && <p><strong>Who:</strong> {x.who_i_am}</p>}
{x.why && <p><strong>Why:</strong> {x.why}</p>}
{x.use_case && <p><strong>Use case:</strong> {x.use_case}</p>}
</div>
{error && <p className="field-error">{error}</p>}
{outcome ? (
<p className="inbox-request-outcome muted">
{outcome === 'accepted'
? 'Accepted — an invitation has been sent.'
: 'Declined.'}
</p>
) : (
<div className="inbox-request-actions">
<button type="button" className="btn-primary" disabled={busy || !x.request_id} onClick={() => act(true)}>
Accept
</button>
<button type="button" className="btn-secondary" disabled={busy || !x.request_id} onClick={() => act(false)}>
Decline
</button>
</div>
)}
</li>
)
}
function InboxRow({ item, onClick, onMarkRead, onClose }) { function InboxRow({ item, onClick, onMarkRead, onClose }) {
if (item.event_kind === 'contribution_request_on_pending_rfc') {
return <ContributionRequestRow item={item} onMarkRead={onMarkRead} />
}
const unread = !item.read_at const unread = !item.read_at
const target = deepLink(item) const target = deepLink(item)
const handle = async () => { const handle = async () => {
+61 -8
View File
@@ -1,21 +1,40 @@
// LinkedText.jsx roadmap #28 Part 1. // LinkedText.jsx roadmap #28 (Parts 13).
// //
// Renders a backend-provided list of text/rfc-link segments (see // Renders a backend-provided list of text/link segments (see
// backend/app/rfc_links.py). RFC references in PR descriptions and // backend/app/rfc_links.py). References in PR descriptions and comments
// comments arrive pre-scanned as structured segments this component // arrive pre-scanned as structured segments this component maps them
// maps them onto plain text runs and anchor elements. It never renders // onto plain text runs, anchors, and inline affordances. It never renders
// HTML from the server (no dangerouslySetInnerHTML), so the surface is // HTML from the server (no dangerouslySetInnerHTML), so the surface is
// XSS-safe regardless of what a comment author typed. // XSS-safe regardless of what a comment author typed.
// //
// Segment types:
// * `rfc` Part 1: a link to an accepted (active) RFC.
// * `rfc-pending` Part 3: the term names a pending (super-draft)
// RFC; a signed-in viewer who isn't its owner gets
// an inline "ask to contribute" affordance routing
// to the contribute form (App reads `?contribute=`).
// * `rfc-candidate` Part 2: a strong-candidate term with no RFC yet;
// a viewer with create rights (`canCreate`) gets a
// "create RFC" affordance routing to the propose
// flow pre-filled (App reads `?propose=`).
//
// Affordances degrade to plain text when the viewer lacks the relevant
// right, so the visible prose is identical for everyone only the
// offered actions differ.
//
// `segments` is the enriched array; `text` is the raw fallback used when // `segments` is the enriched array; `text` is the raw fallback used when
// the field is absent (an older cached response, or a caller that didn't // the field is absent (an older cached response, or a caller that didn't
// pass segments). Either way the visible text is identical only the // pass segments).
// links differ.
export default function LinkedText({ segments, text }) { import { Link } from 'react-router-dom'
export default function LinkedText({ segments, text, viewer, canCreate }) {
if (!Array.isArray(segments) || segments.length === 0) { if (!Array.isArray(segments) || segments.length === 0) {
return <>{text ?? ''}</> return <>{text ?? ''}</>
} }
// A signed-in, beta-granted viewer can ask to contribute; the backend
// re-checks ownership/collaborator status and rejects self-requests.
const canContribute = !!viewer && viewer.permission_state === 'granted'
return ( return (
<> <>
{segments.map((seg, i) => { {segments.map((seg, i) => {
@@ -31,6 +50,40 @@ export default function LinkedText({ segments, text }) {
</a> </a>
) )
} }
if (seg.type === 'rfc-pending') {
const who = seg.owner || 'Someone'
return (
<span key={i} className="rfc-pending">
{seg.label}
{canContribute && (
<Link
className="rfc-offer rfc-offer-contribute"
to={`?contribute=${encodeURIComponent(seg.slug)}&term=${encodeURIComponent(seg.label)}`}
title={`${who} is working on an RFC for '${seg.label}' — ask to contribute`}
>
ask to contribute
</Link>
)}
</span>
)
}
if (seg.type === 'rfc-candidate') {
const term = seg.term || seg.label
return (
<span key={i} className="rfc-candidate">
{seg.label}
{canCreate && (
<Link
className="rfc-offer rfc-offer-create"
to={`?propose=${encodeURIComponent(term)}`}
title={`Create RFC for '${term}'`}
>
+ create RFC
</Link>
)}
</span>
)
}
return <span key={i}>{seg.text}</span> return <span key={i}>{seg.text}</span>
})} })}
</> </>
+2 -2
View File
@@ -220,7 +220,7 @@ export default function PRView({ viewer }) {
<h1 className="pr-title">{pr.title}</h1> <h1 className="pr-title">{pr.title}</h1>
{pr.description && ( {pr.description && (
<p className="pr-description"> <p className="pr-description">
<LinkedText segments={pr.description_segments} text={pr.description} /> <LinkedText segments={pr.description_segments} text={pr.description} viewer={viewer} canCreate={viewer?.permission_state === 'granted'} />
</p> </p>
)} )}
{/* #26: the optional ground-truth use case for this change, {/* #26: the optional ground-truth use case for this change,
@@ -444,7 +444,7 @@ function PRConversation({ threads, messagesByThread, threadsByKind, seenMsgId })
</div> </div>
{m.quote && <pre className="chat-msg-quote">{m.quote}</pre>} {m.quote && <pre className="chat-msg-quote">{m.quote}</pre>}
<div className="chat-msg-body"> <div className="chat-msg-body">
<LinkedText segments={m.text_segments} text={m.text} /> <LinkedText segments={m.text_segments} text={m.text} viewer={viewer} canCreate={viewer?.permission_state === 'granted'} />
</div> </div>
</li> </li>
) )
+5 -2
View File
@@ -28,8 +28,11 @@ function slugify(title) {
.replace(/^-+|-+$/g, '') .replace(/^-+|-+$/g, '')
} }
export default function ProposeModal({ viewer, onClose, onSubmitted }) { export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '' }) {
const [title, setTitle] = useState('') // #28 Part 2: a "create RFC for '<term>'" affordance pre-fills the title
// (App passes the `?propose=<term>` value here); the slug derives from it
// via the same effect that drives manual typing.
const [title, setTitle] = useState(initialTitle)
const [slug, setSlug] = useState('') const [slug, setSlug] = useState('')
const [slugEdited, setSlugEdited] = useState(false) const [slugEdited, setSlugEdited] = useState(false)
const [pitch, setPitch] = useState('') const [pitch, setPitch] = useState('')
@@ -191,7 +191,7 @@ export default function RFCDiscussionPanel({ slug, viewer }) {
</div> </div>
)} )}
{activeMessages.map(msg => ( {activeMessages.map(msg => (
<DiscussionMessage key={msg.id} message={msg} /> <DiscussionMessage key={msg.id} message={msg} viewer={viewer} />
))} ))}
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </div>
@@ -258,7 +258,7 @@ export default function RFCDiscussionPanel({ slug, viewer }) {
) )
} }
function DiscussionMessage({ message }) { function DiscussionMessage({ message, viewer }) {
const isSystem = message.role === 'system' const isSystem = message.role === 'system'
if (isSystem) { if (isSystem) {
return ( return (
@@ -281,7 +281,7 @@ function DiscussionMessage({ message }) {
<div className="discussion-message-quote">"{message.quote}"</div> <div className="discussion-message-quote">"{message.quote}"</div>
)} )}
<div className="discussion-message-body"> <div className="discussion-message-body">
<LinkedText segments={message.text_segments} text={message.text} /> <LinkedText segments={message.text_segments} text={message.text} viewer={viewer} canCreate={viewer?.permission_state === 'granted'} />
</div> </div>
</div> </div>
) )