Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d716d0cb8 | |||
| f96883506e | |||
| 0c972c8af5 | |||
| d581010063 | |||
| 732b23b156 | |||
| 1558cc3a8b | |||
| 8a94e26f75 | |||
| 3c9109c392 | |||
| 019c8a9185 | |||
| 79a447c77b | |||
| fe044ed3db | |||
| bd3ef269d4 | |||
| 698821f065 | |||
| e794523079 | |||
| 28015ed1a2 | |||
| 376a6daddc | |||
| fb9b4fa422 | |||
| daebb54f47 | |||
| a598221812 | |||
| bada72f87e | |||
| 7d8371dea1 | |||
| 3a51425ec7 | |||
| 7c6c906db2 | |||
| 2ac20b1621 | |||
| 493d6b6eee | |||
| 959fc906de | |||
| b648b3ed45 | |||
| 54736de91c | |||
| adb5d25715 | |||
| cbf02d5507 | |||
| 317738ed79 | |||
| 5be2c48afe |
+654
@@ -23,6 +23,660 @@ skip versions are the composition of each intervening adjacent
|
||||
release's steps in order — no A-to-B path is pre-computed beyond
|
||||
that.
|
||||
|
||||
## 0.31.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.0–v0.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
|
||||
|
||||
**Minor — security-hardening release (Session-0026 audit remediation).
|
||||
One auto-applied migration (023); one behavior change that re-prompts
|
||||
device-trust; deployments MUST re-apply the nginx + systemd files.**
|
||||
This is the work cut as the "v0.25.0 security-hardening" branch; it
|
||||
reversioned to 0.27.0 because v0.26.0 (#28) took the next slot while it
|
||||
was in flight. Shipped from driver session 0030.0.
|
||||
|
||||
- **C1 (Critical) — stored-XSS closed.** Every markdown→HTML sink now
|
||||
routes through one chokepoint, `frontend/src/lib/sanitizeHtml.js`
|
||||
(DOMPurify), before any `innerHTML` / `dangerouslySetInnerHTML` write:
|
||||
`MarkdownPreview`, both `ProposalView` sinks (entry body +
|
||||
`proposed_use_case`), and `Editor`. A hook adds
|
||||
`rel="noopener noreferrer"` to `target=_blank` links. `marked` no
|
||||
longer passes raw HTML / `javascript:` URIs to the DOM, so a
|
||||
contributor can no longer plant a payload that runs in an admin/owner
|
||||
session during review.
|
||||
- **H1 — OTC verify is rate-limited.** New `backend/app/ratelimit.py`
|
||||
(per-IP token buckets) gates `/auth/otc/verify`, `/auth/otc/request`,
|
||||
and the passcode check/verify paths; a per-account OTC-verify lockout
|
||||
(migration `023_otc_verify_lockout.sql`) mirrors the passcode lockout.
|
||||
- **M1 — device-trust lookup no longer table-scans.** The device-trust
|
||||
cookie value is now `"<row_id>.<raw_token>"`; `device_trust.lookup`
|
||||
reads the one indexed row and bcrypt-checks only it, instead of
|
||||
bcrypt-checking every row in the table on each unauthenticated
|
||||
`/auth/device-trust/start`.
|
||||
- **M2 — HTTP security headers** (CSP, HSTS, X-Frame-Options,
|
||||
X-Content-Type-Options, Referrer-Policy) added to the nginx server
|
||||
block. **L8/I1**: `server_tokens off` + legacy TLS1.0/1.1 removed.
|
||||
- **M4 — session cookie `Secure` by default** (`SESSION_COOKIE_SECURE`,
|
||||
defaults on; a dev box on plain http sets it `false`).
|
||||
- **M5 — bounce webhook fails closed.** An unset
|
||||
`WEBHOOK_EMAIL_BOUNCE_SECRET` now **disables** `/api/webhooks/email-bounce`
|
||||
(503) instead of leaving it open; a dev opts back in with
|
||||
`RFC_APP_INSECURE_BOUNCE_WEBHOOK=1`.
|
||||
- **L2/L3** per-IP cooldown + check-endpoint throttle. **L4** systemd
|
||||
sandbox knobs (`CapabilityBoundingSet=`, `ProtectKernel*`,
|
||||
`RestrictAddressFamilies`, `SystemCallFilter`, …).
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. **Migration** — none manual; `023_otc_verify_lockout.sql` auto-applies
|
||||
at startup via `db.run_migrations`.
|
||||
2. **Device trust (MUST expect re-prompt)** — the cookie format changed,
|
||||
so existing "trusted device" cookies no longer match; affected users
|
||||
are re-prompted for device verification once. No data migration; old
|
||||
rows are simply never matched and age out.
|
||||
3. **nginx + systemd (MUST apply out-of-band)** — the deploy gesture does
|
||||
**not** install `deploy/nginx/ohm.wiggleverse.org.conf` or
|
||||
`deploy/systemd/rfc-app.service`. After deploying the code, copy both
|
||||
to their system locations, then `nginx -t && systemctl reload nginx`
|
||||
and `systemctl daemon-reload && systemctl restart <unit>`. (M2 headers
|
||||
and L4 sandboxing do not take effect until this is done.)
|
||||
4. **Bounce webhook (SHOULD)** — bind `WEBHOOK_EMAIL_BOUNCE_SECRET` (or
|
||||
set `RFC_APP_INSECURE_BOUNCE_WEBHOOK=1` for dev). Unset → the endpoint
|
||||
returns 503 (closed). No legitimate bounce source is wired today, so
|
||||
503 is the safe default.
|
||||
5. **Session cookie (SHOULD, dev only)** — a deployment served over plain
|
||||
http MUST set `SESSION_COOKIE_SECURE=false` or the session cookie
|
||||
won't be sent. Production over HTTPS leaves it unset (Secure on).
|
||||
|
||||
## 0.26.0 — 2026-05-28
|
||||
|
||||
**Minor — no schema migration, no new secret, no config, no upgrade
|
||||
steps. Additive read-time enrichment; deployments inherit it on deploy
|
||||
with nothing to set.** Roadmap item #28 **Part 1**: references to existing
|
||||
**accepted** RFCs inside PR descriptions and comment text now render as
|
||||
inline links to the referenced RFC. Shipped from driver session 0029.0,
|
||||
in parallel with the v0.25.0 security-hardening session — hence the
|
||||
version-slot gap (0.25.0 is that session's; this took the next free slot
|
||||
per the roadmap's "claims the next available version number" rule).
|
||||
|
||||
Parts 2 (offer-to-create-RFC for strong-candidate terms) and 3
|
||||
(offer-to-contribute-to-a-pending-RFC) of item #28 are deliberately **not**
|
||||
in this release — Part 1 ships first as the easy win, exactly as the
|
||||
roadmap row anticipates.
|
||||
|
||||
- **Where it links.** The PR review page's description and review
|
||||
comments (`GET /api/rfcs/{slug}/prs/{n}`), and the PR-less per-RFC
|
||||
discussion comments (`GET /api/rfcs/{slug}/discussion/threads/{id}/messages`).
|
||||
Branch-chat (the AI-collaboration surface) is intentionally out of
|
||||
scope for Part 1 — a noted follow-up.
|
||||
- **Read-time, not submit-time.** The roadmap row phrases the scan as
|
||||
happening "at submit/post time"; this ships it as **read-time**
|
||||
enrichment instead. The intent the row actually names — "not as live
|
||||
compose preview" — holds (drafts are never scanned, only submitted
|
||||
content on read). Read-time was chosen because (a) links track the
|
||||
*live* accepted-RFC set — a newly-accepted RFC starts linking in older
|
||||
comments, a withdrawn one stops linking everywhere — rather than
|
||||
freezing stale at submit; (b) it needs **no migration** (no derived
|
||||
data to persist); (c) the active-RFC corpus is small and cache-resident,
|
||||
so per-read scanning is cheap. (Recorded as a §19.3-rule-2 spec note in
|
||||
the session transcript.)
|
||||
- **XSS-safe by construction.** The backend returns structured *segments*
|
||||
(a list of `{type:"text"}` / `{type:"rfc", slug, label, title}` items),
|
||||
never HTML. The new `LinkedText` frontend component maps segments onto
|
||||
React text nodes and anchors — no `dangerouslySetInnerHTML` — so a
|
||||
comment author cannot inject markup through this path. Every enriched
|
||||
field keeps its raw `text`/`description` alongside the `*_segments`, so
|
||||
any non-segment-aware caller is unaffected.
|
||||
- **Conservative matching (false-positive-averse).** A reference links
|
||||
only when it is unlikely to be coincidental: an `rfc_id` token
|
||||
(`RFC-0001`), a **multi-word** title (`Open Human Model`), or a
|
||||
**hyphenated** slug (`open-human-model`). A single common-word title or
|
||||
slug (a hypothetical RFC titled "Human") is **not** auto-linked — that
|
||||
would turn every prose "human" into a link. Matching is case-insensitive,
|
||||
word-boundary-anchored, longest-match-wins, and suppresses an RFC's
|
||||
self-references inside its own PR/discussion. The roadmap's "curated
|
||||
canonical-terms list" remains an explicit future opt-in rather than a
|
||||
guessed-at default. New module: `backend/app/rfc_links.py`.
|
||||
|
||||
Tests: 12 new (`test_rfc_links_vertical.py`) — 9 scanner units (gating
|
||||
rules, word boundaries, longest-match, case-insensitivity, casing
|
||||
preservation, the rfc_id/multi-word/hyphenated gates) plus 3 end-to-end
|
||||
(PR description + review comment + discussion comment all surface
|
||||
`*_segments`; self-reference suppression). Full suite 363 green; frontend
|
||||
builds clean.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. None. This release is purely additive: no migration, no new
|
||||
environment variable, no secret, no overlay. A deployment picks up RFC
|
||||
auto-linking the moment it deploys this version. (RFCs only link once
|
||||
they are in the `active` state — proposed/super-draft and withdrawn
|
||||
RFCs are never link targets, matching the §11.3 universal-public read
|
||||
rule.)
|
||||
|
||||
## 0.24.0 — 2026-05-28
|
||||
|
||||
**Minor — one new secret required before this version serves tag
|
||||
suggestions; no schema migration; no behavior change for deployments
|
||||
that do not bind the key.** Roadmap item #27: as a contributor fills in
|
||||
the propose-RFC form, the backend asks Claude Haiku to recommend tags
|
||||
drawn from the collection's existing tag set, surfaced as clickable
|
||||
suggestion chips. Shipped from driver session 0025.0. This is the §9.1
|
||||
"Slice 2" AI-suggested chips that the propose modal has carried a
|
||||
deferred placeholder for since Slice 1.
|
||||
|
||||
- **`POST /api/rfcs/suggest-tags`** (contributor-gated, per-user
|
||||
rate-limited). Takes the partial draft (`title`, `pitch`,
|
||||
`use_case`) and returns `{ "suggestions": [{ "tag", "confidence" },
|
||||
…] }`. The model is constrained to the corpus's existing tags — v1
|
||||
tags are free-form chip input, so "the taxonomy" is the de-facto set
|
||||
of distinct tags the existing RFCs carry. The model MUST NOT invent
|
||||
tags; taxonomy extension is a deliberate out-of-scope follow-up.
|
||||
- **Always Claude Haiku, for cost.** Tag suggestion uses Haiku
|
||||
regardless of the `ENABLED_MODELS` chat-picker universe, via the new
|
||||
`providers.construct_haiku()` factory. There is no RFC slug at propose
|
||||
time, so the §6.7 per-RFC funder credential path does not apply — the
|
||||
surface runs on the operator's own `ANTHROPIC_API_KEY`.
|
||||
- **Degrades to silence, never error.** No key bound, an empty corpus,
|
||||
an empty draft, a rate-limited caller, or an unparseable model reply
|
||||
all yield an empty list; the propose modal hides its suggestion row,
|
||||
and the rest of the app is unaffected. So a deployment that does not
|
||||
set the key sees no change at all.
|
||||
- **Privacy disclosure.** The modal carries an inline note that the
|
||||
draft text is sent to Anthropic to generate the suggestions. This is
|
||||
required for honesty (the text leaves the deployment before the RFC
|
||||
is submitted); cookie-consent does not gate it because the user has
|
||||
actively typed into a draft surface. The exact wording wants a
|
||||
counsel pass before OHM's deploy, per the #22 drafting discipline.
|
||||
- **Frontend.** `ProposeModal` debounce-posts the draft (700 ms, with a
|
||||
stale-response guard); suggestion chips are clickable and add to the
|
||||
tag list (nothing auto-applies). `api.suggestTags()` is forgiving —
|
||||
any non-OK response resolves to `[]`.
|
||||
|
||||
Tests: 11 new (`test_tag_suggest_vertical.py`) — contributor-gating,
|
||||
universe-constrained filtering, no-key / empty-corpus / rate-limit
|
||||
paths, plus units for the universe gather, the tolerant reply parser,
|
||||
the max cap, the empty-draft short-circuit, and provider-failure
|
||||
fallback. Full suite 351 green.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. You **MUST** bind `ANTHROPIC_API_KEY` for the suggestion surface to
|
||||
work. On OHM this is an operator gesture — the key never touches the
|
||||
conversation. Set it from your own terminal via stdin (so the bytes
|
||||
never land in shell history):
|
||||
|
||||
```bash
|
||||
printf '%s' "$(pbpaste)" | .venv/bin/ohm-rfc-app-flotilla \
|
||||
secret set ohm-rfc-app ANTHROPIC_API_KEY
|
||||
```
|
||||
|
||||
(The §18 chat stack already reads this same key, so a deployment
|
||||
that has chat configured **MAY** already have it bound — confirm with
|
||||
`flotilla secret list ohm-rfc-app`.) If the key is absent the app
|
||||
still boots and serves normally; tag suggestions are simply
|
||||
unavailable until it is set.
|
||||
2. You **MAY** tune the per-user rate limit via `TAG_SUGGEST_RATE_MAX`
|
||||
(default 30) and `TAG_SUGGEST_RATE_WINDOW_SECONDS` (default 60). The
|
||||
defaults apply when unset.
|
||||
3. You **SHOULD** have counsel review the modal's disclosure wording
|
||||
before exposing the surface to users, per the #22 drafting
|
||||
discipline — the copy is honest as written, but the legal review is
|
||||
the right call for any "your text is sent to a third party" notice.
|
||||
|
||||
## 0.23.0 — 2026-05-28
|
||||
|
||||
Roadmap item #29: signing in lands the user on their most recently
|
||||
viewed app state instead of the empty-state home view. Shipped from
|
||||
driver session 0022.0. Per-user, server-side, on by default; first-ever
|
||||
sign-ins still land on home.
|
||||
|
||||
1. **Server-side last-state.** A new `user_session_state` table holds one
|
||||
row per user: `last_route TEXT`, `last_route_state TEXT` (light view
|
||||
state encoded as JSON — SQLite has no native JSONB — never draft-buffer
|
||||
contents), `resume_enabled INTEGER NOT NULL DEFAULT 1`, and
|
||||
`last_updated_at`. Per-user (not per-device), matching the
|
||||
"go to where I was last" intent.
|
||||
2. **Route-change capture.** A new frontend hook (`frontend/src/lib/
|
||||
useLastState.js`) debounce-posts the current route + light state to
|
||||
`PUT /api/me/last-state` for authenticated users only; anonymous
|
||||
sessions are a no-op. The handler upserts the user's row and no-ops
|
||||
when `resume_enabled` is 0.
|
||||
3. **Sign-in redirect.** The stored `last_route` (+ decoded state +
|
||||
`resume_enabled`) is folded onto the existing `/api/auth/me` payload
|
||||
(no new GET endpoint), so the frontend reads it on boot. On a hard
|
||||
sign-in landing (app booted on `/`), the app redirects to the stored
|
||||
route. The redirect is gated on the #21-Part-C Amplitude `identify`
|
||||
having fired (`identifyReady`), preserving identify-then-track
|
||||
ordering — the first event on the resumed route carries the user_id.
|
||||
4. **Edge cases.** Stale routes (an RFC since withdrawn or now
|
||||
unreadable) are a graceful no-op — existing routing falls through to
|
||||
the catalog/empty-state. Opt-out ships at the column level
|
||||
(`resume_enabled`); the profile-settings toggle UI is a follow-up.
|
||||
Stored state is route + light view state only, never draft contents
|
||||
(documented in `SPEC.md` §6.8).
|
||||
|
||||
Migration `022_user_session_state.sql` creates the table. No new
|
||||
environment variables, overlay keys, or secrets.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. Deployments **MUST** apply database migrations; `022_user_session_state.sql`
|
||||
runs automatically on next boot (the migration runner globs
|
||||
`backend/migrations/*.sql` and applies any not yet recorded in
|
||||
`schema_migrations`). The migration is additive — a new table — and
|
||||
requires no data backfill.
|
||||
2. No new environment variables, overlay keys, or secrets. No operator
|
||||
action beyond the standard `flotilla deploy ohm-rfc-app`.
|
||||
|
||||
## 0.22.0 — 2026-05-28
|
||||
|
||||
Roadmap item #26: an optional **"What will you be using this for?"**
|
||||
field on both propose surfaces. Shipped from driver session 0022.0.
|
||||
Additive and backward-compatible — no behavior changes for anyone who
|
||||
leaves the field blank.
|
||||
|
||||
1. **Propose-RFC modal.** Below the required "Why is this RFC needed?"
|
||||
field (`pitch`) sits a new optional **"What will you be using this
|
||||
RFC for?"** textarea. "Needed" is the abstract justification; "using
|
||||
it for" is the concrete ground-truth use case — captured without
|
||||
being forced.
|
||||
2. **Propose-PR modal.** Below the required "Why is this change needed?"
|
||||
field (`description`) sits a new optional **"What will you be using
|
||||
this change for?"** textarea.
|
||||
3. **Display.** The RFC view (`RFCView` / `ProposalView`) and PR review
|
||||
view (`PRView`) render the captured use case alongside the existing
|
||||
"why" text, with a muted "left blank" treatment when absent.
|
||||
4. **Persistence (deployment note).** In this deployment the framework's
|
||||
`rfcs` / PR surfaces are the Gitea-backed cache tables `cached_rfcs`
|
||||
/ `cached_prs`, rebuilt by the reconciler. Because the propose write
|
||||
path is endpoint → Gitea → reconcile (and the reconciler doesn't
|
||||
carry the new field), the durable home is a canonical, reconcile-proof
|
||||
side table `proposed_use_cases` (keyed by `(scope, pr_number)`) that
|
||||
the propose/open endpoints write directly and the view endpoints read
|
||||
back. The literal nullable `proposed_use_case` columns named by the
|
||||
roadmap are also added to `cached_rfcs` / `cached_prs` for parity and
|
||||
any future reconciler that learns to carry the field. NULL/blank use
|
||||
cases simply never write a side-table row — absence is "left blank".
|
||||
|
||||
Migration `021_proposed_use_case.sql` adds the two cache columns
|
||||
(`ALTER TABLE … ADD COLUMN proposed_use_case TEXT`), the
|
||||
`proposed_use_cases` canonical table, and its lookup indexes. The
|
||||
reconciler's upsert (`ON CONFLICT DO UPDATE`) sets only known columns,
|
||||
so the added cache columns survive reconciles. Backend validators accept
|
||||
the field as NULL/omitted (no required-validation) with an 8000-char cap
|
||||
matching the existing PR `description` bound; blank/whitespace is treated
|
||||
as absent.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. Deployments **MUST** apply database migrations; `021_proposed_use_case.sql`
|
||||
runs automatically on next boot (the migration runner globs
|
||||
`backend/migrations/*.sql` and applies any not yet recorded in
|
||||
`schema_migrations`). The migration is additive — nullable cache
|
||||
columns plus a new table — and requires no data backfill.
|
||||
2. No new environment variables, overlay keys, or secrets. No operator
|
||||
action beyond the standard `flotilla deploy ohm-rfc-app`.
|
||||
|
||||
## 0.21.0 — 2026-05-28
|
||||
|
||||
UX-polish wave. Roadmap items #31 (comprehensive UX polish — foundation
|
||||
slice), #24 (header "About" → "Philosophy"), #25 (inbox icon + light UX),
|
||||
and #32 (session/transcript page polish). Pure frontend; no schema, no
|
||||
backend changes, no new secret. Shipped from one driver session (0019.0)
|
||||
via three parallel subagents working on disjoint surfaces.
|
||||
|
||||
1. **Design-token foundation (#31).** New `frontend/src/styles/tokens.css`
|
||||
establishes the app's first coherent design system — semantic color
|
||||
palette, type scale, spacing scale, radius scale, elevation, and a
|
||||
motion vocabulary (with `prefers-reduced-motion` honored) — as CSS
|
||||
custom properties, imported first in `main.jsx`. Before this the app
|
||||
carried ~98 distinct hardcoded hex colors, font sizes across 16
|
||||
unscaled values, and radii across 13. `App.css` and `index.css` were
|
||||
swept to the tokens (~630 color / 280 font-size / 128 radius
|
||||
references), consolidating near-duplicate grays to the nearest ramp
|
||||
step and rounding off-scale type to the nearest step. No CSS class was
|
||||
renamed or removed; an additive `:focus-visible` ring and a subtle
|
||||
hover/transition layer were added. 35 special-purpose hexes (true
|
||||
blues/violets, status dots, deep diff-contrast shades) were
|
||||
deliberately left as literals. This is the polish *foundation*; a
|
||||
follow-up (#31b) covers the bespoke per-surface re-spacing that wants
|
||||
operator review against screenshots.
|
||||
|
||||
2. **Header: "About" → "Philosophy" (#24).** The persistent header link
|
||||
now reads "Philosophy" (the route `/philosophy` and its page already
|
||||
existed; only the label changed).
|
||||
|
||||
3. **Inbox icon + light UX (#25).** The header inbox trigger's `📮`
|
||||
emoji is replaced with a dependency-free inline-SVG envelope icon
|
||||
(`aria-label="Inbox"`); no icon library was added. The inbox panel
|
||||
got a light pass — clearer unread/read distinction, mark-all-read and
|
||||
per-row affordances surfaced, better empty state, tokenized spacing in
|
||||
a new component-scoped `Inbox.css`. Behavior, filters, deep-links, and
|
||||
§15 notification data flow are unchanged. A full inbox redesign is
|
||||
deferred to a #25 follow-up (the operator's reference screenshot did
|
||||
not transmit).
|
||||
|
||||
4. **Session/transcript page polish (#32).** `/docs/sessions/<NNNN>` no
|
||||
longer dead-ends on a "select a transcript" placeholder: a
|
||||
single-transcript session renders that transcript inline at the
|
||||
session root; a multi-transcript session renders its `.0` driver
|
||||
transcript inline and lists the siblings. Each rendered transcript now
|
||||
carries a metadata header — session title, Started/Ended (parsed from
|
||||
the filename's ISO segments), derived Duration, an optional one-line
|
||||
TL;DR, and a "View source on git.wiggleverse.org" external link to the
|
||||
canonical raw transcript. The TL;DR reads an optional `tldr` string on
|
||||
the per-session `sessions.json` manifest entry and degrades gracefully
|
||||
when absent.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
MAY: add a `tldr` string to any per-session entry in
|
||||
`wiggleverse/ohm-session-history`'s `sessions.json`
|
||||
(e.g. `"0019": { "title": "…", "tldr": "one-line summary" }`) to surface
|
||||
a summary in each transcript's metadata header. Absent `tldr` renders
|
||||
nothing — no deployment action is required. This is a data edit in the
|
||||
session-history repo, not a `flotilla` gesture.
|
||||
|
||||
## 0.20.0 — 2026-05-28
|
||||
|
||||
Wave 9 follow-up to roadmap item #30. Three changes bundled into one minor:
|
||||
|
||||
@@ -39,23 +39,44 @@ thread. Every write affordance is replaced with a sign-in prompt.
|
||||
|
||||
## Signing in
|
||||
|
||||
While the framework is in private beta, only invited email addresses
|
||||
can complete sign-in. If your email is on the allowlist, the
|
||||
"Sign in" button in the header completes the flow and lands you on
|
||||
the catalog with full read and write access. If your email is not on
|
||||
the allowlist, you'll be sent to a short "pending" page explaining
|
||||
the gate.
|
||||
Anyone can start the sign-in flow with their own email address — there
|
||||
is no invite-only allowlist. Sign-in is passwordless:
|
||||
|
||||
Once you have an account, you're a **contributor** by default — the
|
||||
role that grants every write affordance the app exposes, scoped by
|
||||
the per-RFC and per-branch rules described below.
|
||||
1. **Enter your email.** If the deployment has human verification
|
||||
enabled (a Cloudflare Turnstile challenge), you complete it here.
|
||||
2. **Enter the one-time code.** The app emails you a short numeric
|
||||
code; entering it signs you in. Codes expire after a few minutes,
|
||||
and repeated wrong entries briefly lock the email.
|
||||
3. **Set a passcode (optional).** After your first code sign-in you
|
||||
can set a passcode. On later visits you sign in with email +
|
||||
passcode, with the one-time code as the forgot-passcode fallback.
|
||||
4. **Trust this device (optional).** You can mark a device trusted for
|
||||
30 days to skip the code/passcode step on it. Trusted devices are
|
||||
listed in your settings and can be revoked individually or all at
|
||||
once.
|
||||
|
||||
### Getting write access
|
||||
|
||||
Signing in gives you an account, but write access is gated. The first
|
||||
time you sign in you're asked for your first name, last name, and a
|
||||
short note on why you'd like access; you then land on a "request in
|
||||
review" page. While your account is **pending**, you can read
|
||||
everything an anonymous visitor can but cannot write — no chat,
|
||||
propose, branch, PR, or discussion post. Once an admin **grants** your
|
||||
account you become a **contributor**, the role that carries every
|
||||
write affordance the app exposes, scoped by the per-RFC and per-branch
|
||||
rules described below.
|
||||
|
||||
An admin can also create your account ahead of time and email you an
|
||||
invite link. Clicking it claims the account and signs you in with the
|
||||
role the admin assigned, skipping the one-time-code step.
|
||||
|
||||
---
|
||||
|
||||
## Proposing a new RFC
|
||||
|
||||
A new RFC begins as a proposal. The "+ Propose new RFC" button at
|
||||
the bottom of the catalog opens a small modal that collects four
|
||||
the bottom of the catalog opens a small modal that collects five
|
||||
things:
|
||||
|
||||
- **Title.** The word, concept, or topic this RFC would define.
|
||||
@@ -65,8 +86,13 @@ things:
|
||||
inline.
|
||||
- **Pitch.** One or two paragraphs answering *why this RFC is
|
||||
needed*. This becomes the body of the entry.
|
||||
- **Tags.** Optional. The AI suggests tags from the pitch; you can
|
||||
accept, dismiss, or type your own.
|
||||
- **Use case.** Optional. *What will you be using this RFC for?* —
|
||||
the concrete application driving the proposal, as distinct from the
|
||||
abstract case for it. Leaving it blank is fine.
|
||||
- **Tags.** Optional. If the deployment has AI tag suggestion
|
||||
enabled, suggested tags appear as you fill the form (with an inline
|
||||
note that the text you've entered is sent to the model that
|
||||
generates them); you can accept, dismiss, or type your own.
|
||||
|
||||
Submitting the modal does one concrete thing: it opens a pull
|
||||
request against the framework's meta repository, adding one new
|
||||
@@ -167,6 +193,51 @@ either requires a contributor account.
|
||||
|
||||
---
|
||||
|
||||
## Invitations, cross-references, and contribution requests
|
||||
|
||||
Three connected surfaces help the right people find and join the
|
||||
right RFC.
|
||||
|
||||
### Owner invitations
|
||||
|
||||
An RFC's owner (or an app-wide admin or owner) can invite a specific
|
||||
person to that RFC from the "Invitations" control in the RFC header.
|
||||
The invite names an email and a role for *this RFC*:
|
||||
|
||||
- **contributor** — can open PRs and join the discussion;
|
||||
- **discussant** — can join the discussion only.
|
||||
|
||||
The invitee gets an email with an accept link; accepting adds them as
|
||||
a collaborator on that RFC. The invitations panel lists every invite
|
||||
with its status (pending / accepted / expired / revoked); pending
|
||||
invites can be revoked. An invitation is per-RFC — it does not change
|
||||
the invitee's app-wide role, and it cannot lift the pending gate: the
|
||||
invitee still needs a granted account to write.
|
||||
|
||||
### RFC cross-links in PRs and comments
|
||||
|
||||
When a PR description or a comment mentions an existing active RFC —
|
||||
by its ID, its multi-word title, or its slug — the framework renders
|
||||
that mention as a link to the RFC. The matching is conservative by
|
||||
design (single common words are never auto-linked), and the links are
|
||||
computed at read time, so nothing is rewritten in what you typed.
|
||||
|
||||
### "Create" and "ask to contribute" offers
|
||||
|
||||
The same scan surfaces two affordances inline:
|
||||
|
||||
- If a term looks like it should have an RFC but none exists yet, a
|
||||
reader who has create rights sees a **"create RFC for '<term>'"**
|
||||
link that opens the propose modal with the title pre-filled.
|
||||
- If a term matches a *pending* RFC (a super-draft someone already
|
||||
owns), a signed-in reader sees an **"ask to contribute"** offer
|
||||
naming the owner. It opens a short request form — who you are, why
|
||||
you're asking, and optionally what you'd use the RFC for. The
|
||||
request lands in the owner's inbox; the owner can **accept** (which
|
||||
sends you an owner invitation) or **decline** (which notifies you).
|
||||
|
||||
---
|
||||
|
||||
## Working on a branch
|
||||
|
||||
Contribute mode flips one branch into edit-enabled. The centre
|
||||
@@ -505,6 +576,14 @@ Each role is a strict superset of the one below it.
|
||||
entirely. The framework names a single "owner zero" at
|
||||
bootstrap.
|
||||
|
||||
Between anonymous and contributor sits one transient state:
|
||||
**pending**. A freshly signed-in account that hasn't been granted
|
||||
access yet (see [Signing in](#signing-in)) reads everything an
|
||||
anonymous visitor can, but no write affordance unlocks until an admin
|
||||
grants it. Granting promotes the account to contributor; an admin can
|
||||
also revoke a granted account back to a no-write state. These
|
||||
transitions are recorded in the `permission_events` log.
|
||||
|
||||
The practical difference between admin and owner is narrow but
|
||||
load-bearing: admin is the operational tier — it does the day-to-
|
||||
day moderation and stewardship work; owner is the tier that
|
||||
@@ -594,6 +673,20 @@ review.
|
||||
|
||||
---
|
||||
|
||||
## Privacy and cookies
|
||||
|
||||
A consent banner appears on your first visit and lets you choose
|
||||
which cookie categories to allow — essential always, with analytics
|
||||
and other categories opt-in. The choice is remembered and can be
|
||||
changed any time from the privacy/cookies controls in settings.
|
||||
|
||||
Analytics only load if you opt in: the framework defers the analytics
|
||||
SDK behind your consent, so declining means it is never initialized.
|
||||
The `/privacy` and `/cookies` pages describe what's collected and
|
||||
why; a deployment can point those pages at its own fuller policy.
|
||||
|
||||
---
|
||||
|
||||
## Where to learn more
|
||||
|
||||
- The framework's *why* lives in [the philosophy
|
||||
|
||||
@@ -30,13 +30,44 @@ providers (Anthropic, Google, OpenAI / GitHub Copilot).
|
||||
|
||||
## 1. Repository topology
|
||||
|
||||
Each RFC is its own Gitea repository. There is in addition exactly one **meta
|
||||
repository** that serves as the authoritative directory of all RFCs in the
|
||||
system — drafts, active work, and retired entries alike.
|
||||
There is exactly one **meta repository** that holds every RFC in the
|
||||
system as a single markdown entry under `rfcs/` — drafts, active work,
|
||||
and retired entries alike — regardless of state. An RFC is a single
|
||||
canonical document, and its document *is* its meta entry: the body
|
||||
lives in the entry file at every state, from idea through active. There
|
||||
are **no per-RFC repositories**.
|
||||
|
||||
All Git operations across all repositories are performed by a single **bot
|
||||
For a deployment, this single repository is its **content repository**:
|
||||
the one place every RFC document lives (under `rfcs/`), alongside the
|
||||
framework's `PHILOSOPHY.md`, `README.md`, and `CONTRIBUTING.md` (§2). A
|
||||
deployment names it concretely — `<deployment>-content` reads cleanly
|
||||
— and the `META_REPO` setting carries the name. The term *meta
|
||||
repository* persists for the config surface and
|
||||
historical continuity, but under the meta-only topology this repo is,
|
||||
functionally, the deployment's content repo: "one repo, your RFCs are
|
||||
in `rfcs/`" is the whole mental model a new deployer needs.
|
||||
|
||||
> **Topology change (v0.31.0, meta-only — supersedes the original
|
||||
> per-RFC-repo model).** This spec originally said "each RFC is its own
|
||||
> Gitea repository," and graduation created a dedicated `rfc-NNNN-<slug>`
|
||||
> repo and moved the body into its `RFC.md`. That model is **retired**.
|
||||
> The per-RFC-repo machinery never paid for itself under this design:
|
||||
> authorization is decided in app data before a single bot acts (below),
|
||||
> not by per-repo Gitea permissions; raw `git clone`+`push` was never a
|
||||
> supported contribution path; and the super-draft phase already ran
|
||||
> meta-only. So an RFC now lives in its meta entry for its whole life,
|
||||
> and graduation is an in-place state flip rather than a repo-creation
|
||||
> transaction (see §13). Where later sections still say "the RFC's repo"
|
||||
> or "RFC.md on the new repo," read it as "the RFC's meta entry body" —
|
||||
> the editing, branch, PR, and chat machinery is unchanged; only the
|
||||
> location collapses onto the meta repo. The one RFC graduated under the
|
||||
> old model, **RFC-0001 `human`**, was folded back into its meta entry
|
||||
> and its `wiggleverse/rfc-0001-human` repo archived (see §13.6). The
|
||||
> decision record is OHM ROADMAP #36.
|
||||
|
||||
All Git operations on the meta repository are performed by a single **bot
|
||||
service account** in Gitea. Real human users do not have meaningful Gitea
|
||||
permissions on the repos themselves; their accounts exist for OAuth identity
|
||||
permissions on the repo itself; their accounts exist for OAuth identity
|
||||
only. The bot is the author of every commit, the opener of every PR, and the
|
||||
merger of every merge. Authorization decisions are made by the app, in app
|
||||
data, *before* the bot acts on the user's behalf.
|
||||
@@ -67,8 +98,8 @@ The meta repo's `main` branch contains:
|
||||
arriving at the meta-repo via Git rather than via the app. The **index
|
||||
below the header is regenerated by CI on every merge to main** and lists
|
||||
active RFCs, super-drafts, and (eventually) retired entries with links
|
||||
into the corresponding entry files and, when present, the RFC's own
|
||||
repository.
|
||||
into the corresponding entry files. (There is no per-RFC repository to
|
||||
link to under the meta-only topology, §1.)
|
||||
- `CONTRIBUTING.md` — explains how to propose, claim, and contribute.
|
||||
- A workflow file (Gitea Actions) that regenerates the README index.
|
||||
|
||||
@@ -84,7 +115,9 @@ slug: human
|
||||
title: Human
|
||||
state: super-draft # super-draft | active | withdrawn
|
||||
id: null # null until graduated; then "RFC-0042"
|
||||
repo: null # null until graduated; then "wiggleverse/rfc-0042-human"
|
||||
repo: null # always null under the meta-only topology (§1).
|
||||
# Retained for schema stability + historical
|
||||
# entries; never populated by graduation (§13).
|
||||
proposed_by: ben@wiggleverse.org
|
||||
proposed_at: 2026-05-22
|
||||
graduated_at: null
|
||||
@@ -103,8 +136,9 @@ tags: [identity, schema]
|
||||
## Why this RFC is needed
|
||||
|
||||
(One- or two-paragraph pitch from the proposer. While the entry is a
|
||||
super-draft, the body may grow into the actual draft document. On
|
||||
graduation, this body migrates to RFC.md in the new repo; see §13.)
|
||||
super-draft, the body grows into the actual draft document. The body
|
||||
stays in this entry at every state — graduation is an in-place state
|
||||
flip and does not move it (§13).)
|
||||
```
|
||||
|
||||
### 2.2 Idea submission as PR
|
||||
@@ -129,11 +163,14 @@ an idea costs nothing in identifier space.
|
||||
There are three canonical states stored in entry frontmatter:
|
||||
|
||||
- **`super-draft`** — the entry exists in the meta repo's `rfcs/`
|
||||
directory. No dedicated repo yet. Anyone signed in can chat on it; anyone
|
||||
can claim ownership; an owner is required before graduation.
|
||||
- **`active`** — the entry has been graduated. A dedicated RFC repo
|
||||
exists, `repo:` points to it, and real branches/PRs/conversation happen
|
||||
there.
|
||||
directory. Anyone signed in can chat on it; anyone can claim ownership;
|
||||
an owner is required before graduation.
|
||||
- **`active`** — the entry has been graduated: it carries an integer
|
||||
`id` (`RFC-NNNN`) and `graduated_at`/`graduated_by`. It lives in the
|
||||
same `rfcs/<slug>.md` entry it always did — graduation is an in-place
|
||||
state flip (§13), not a move. Branches, PRs, and conversation happen on
|
||||
the meta repo against that entry, exactly as they did while it was a
|
||||
super-draft. `repo:` stays null (§1).
|
||||
- **`withdrawn`** — pulled before becoming canonical. Stays in the
|
||||
directory as historical record, hidden from default views, filterable in.
|
||||
|
||||
@@ -163,20 +200,23 @@ for "who clicked the button" (see §6.5).
|
||||
|
||||
### 3.2 State change side-effects
|
||||
|
||||
For now, changing state in the meta repo entry is the *only* required
|
||||
operation for a state transition. Graduation has additional side effects
|
||||
(creating the new repo, seeding it); those are covered in §13. We
|
||||
deliberately do not tag commits, lock branches, or post notices on
|
||||
state change for now — the entry frontmatter is the single source of
|
||||
truth and any further automation is a later refinement.
|
||||
Changing state in the meta repo entry is the *only* required operation
|
||||
for a state transition — graduation included. Graduation additionally
|
||||
assigns the integer `id` and stamps `graduated_at`/`graduated_by` in the
|
||||
same commit (§13); it has no other side effects under the meta-only
|
||||
topology (no repo to create, nothing to seed). We deliberately do not
|
||||
tag commits, lock branches, or post notices on state change for now —
|
||||
the entry frontmatter is the single source of truth and any further
|
||||
automation is a later refinement.
|
||||
|
||||
---
|
||||
|
||||
## 4. Storage architecture: Git is truth, app keeps a cache
|
||||
|
||||
Gitea remains the source of truth for everything Git-shaped: meta repo
|
||||
content, RFC repo content, branches, PRs, commits. Nothing in this system
|
||||
overrides Gitea on those concerns.
|
||||
content, branches, PRs, commits — all of which live on the single meta
|
||||
repo under the meta-only topology (§1). Nothing in this system overrides
|
||||
Gitea on those concerns.
|
||||
|
||||
The app maintains a **SQLite database**, colocated with the FastAPI
|
||||
process, that serves three purposes:
|
||||
@@ -187,10 +227,11 @@ process, that serves three purposes:
|
||||
assignments, per-branch grants, branch visibility settings, chat
|
||||
history, audit logs. This data is canonical; it is not cached, it
|
||||
is owned by the app.
|
||||
3. **Cached bodies** — the main-branch body of each RFC's `RFC.md` (and
|
||||
each super-draft's entry body) is cached for left-pane previews and
|
||||
read-without-roundtrip. Branch bodies are *not* cached; the editor
|
||||
fetches them live from Gitea when opened.
|
||||
3. **Cached bodies** — the main-branch body of each RFC, read from its
|
||||
`rfcs/<slug>.md` meta entry (the same source for super-drafts and
|
||||
active RFCs alike under the meta-only topology), is cached for
|
||||
left-pane previews and read-without-roundtrip. Branch bodies are
|
||||
*not* cached; the editor fetches them live from Gitea when opened.
|
||||
|
||||
### 4.1 Cache freshness
|
||||
|
||||
@@ -201,7 +242,7 @@ Two paths keep the cache current, running in parallel:
|
||||
A webhook handler does a focused re-read of just what changed.
|
||||
Typical latency: sub-second.
|
||||
- **A periodic reconciler** runs every five minutes and does a full
|
||||
sweep — list meta-repo entries, list each RFC repo's branches and
|
||||
sweep — list meta-repo entries, list the meta repo's branches and
|
||||
PRs, diff against the cache, fix drift. This is the safety net for
|
||||
missed webhooks and downtime.
|
||||
|
||||
@@ -714,6 +755,47 @@ The lighter half ships the structural shape — frontmatter, consent,
|
||||
resolution, revocation. The heavier half ships the runtime
|
||||
hardening.
|
||||
|
||||
### 6.8 Sign-in state resume (roadmap item #29, v0.23.0)
|
||||
|
||||
Signing in lands the user back on their most recently-viewed app
|
||||
state instead of always on the empty-state home view. The model is
|
||||
**per-user**, not per-device — the safe default the roadmap calls
|
||||
for: a sign-in on any device resumes the most-recently-recorded
|
||||
route. A per-device split and a profile-settings toggle UI are
|
||||
follow-ups; v0.23.0 ships the column-level opt-out flag
|
||||
(`resume_enabled`, default on) and the default-on behavior.
|
||||
|
||||
**Storage.** A single row per user in `user_session_state`
|
||||
(migration 022): `user_id` (PK, FK → `users.id`, cascade-delete),
|
||||
`last_route` (TEXT, the frontend pathname), `last_route_state`
|
||||
(TEXT, JSON-encoded — SQLite has no native JSONB, so JSON-as-TEXT
|
||||
matches how the app stores every other JSON blob), `resume_enabled`
|
||||
(INTEGER, default 1), and `last_updated_at`.
|
||||
|
||||
**Wiring.** A debounced (~1s) frontend route-change hook posts the
|
||||
current route to `PUT /api/me/last-state` for authenticated users
|
||||
(anonymous: no-op). The stored `last_route` is folded onto the
|
||||
existing `/api/auth/me` payload (no extra round-trip); on sign-in,
|
||||
after the §21-Part-C Amplitude `identify` fires, the frontend
|
||||
`navigate()`s to it. The identify-then-redirect ordering is
|
||||
preserved: the redirect is gated on identify having fired.
|
||||
|
||||
**Stale state.** If the stored route is an RFC since withdrawn or
|
||||
one the user lost rights to read, the redirect is a graceful no-op:
|
||||
`navigate(last_route)` lands on whatever that route renders today,
|
||||
and the existing routing already falls through to the
|
||||
catalog/empty-state for a missing/unreadable RFC. No special-casing
|
||||
on the server.
|
||||
|
||||
**Privacy (binding).** The stored state is **route + light view
|
||||
state ONLY** — scroll anchors, open-tab selection, filter chips, and
|
||||
the like. It **MUST NOT** carry draft-buffer contents, PR/comment
|
||||
draft text, or any user-typed content. The frontend never sends such
|
||||
content; the `last_route_state` column comment in migration 022 and
|
||||
this paragraph are the contract. Resume state is purposely cheap to
|
||||
discard: a deliberate "clear" (or `resume_enabled = 0`) drops the
|
||||
user back to today's empty-state behavior.
|
||||
|
||||
---
|
||||
|
||||
## 7. The left pane
|
||||
@@ -1566,42 +1648,43 @@ contributor — identical to an active RFC's main chat per §11.4 plus
|
||||
|
||||
### 9.8 Graduation handoff additions
|
||||
|
||||
§13's graduation sequence was written before this section's
|
||||
machinery existed. The mechanics §13 needs to absorb fold inline
|
||||
into §13.2 and §13.4 in their respective sections; the substantive
|
||||
additions are captured here for cross-reference:
|
||||
> **Meta-only update (v0.31.0).** This section originally reconciled
|
||||
> §13's per-RFC-repo graduation transaction with the §9-era editing
|
||||
> machinery. Under the meta-only topology (§1, §13) the entry never
|
||||
> moves and its body is never stripped, so the frictions this section
|
||||
> existed to manage **dissolve**. The bullets are retained, struck
|
||||
> through, for the audit trail of what the per-repo model required.
|
||||
|
||||
- **Open body-edit PRs block graduation.** §13.3's step 3 removes
|
||||
Under meta-only, graduation is a single frontmatter-flipping commit
|
||||
to `rfcs/<slug>.md` (§13.3). Nothing else changes: the body stays in
|
||||
the entry; branches, edit-PRs, threads, flags, and `changes` rows all
|
||||
remain exactly where they were, keyed by the slug per §2.3, and keep
|
||||
working against the same meta entry after the flip as before it. There
|
||||
is no entry move, no body strip, no per-repo seed, and therefore no
|
||||
"handoff" to coordinate.
|
||||
|
||||
- ~~**Open body-edit PRs block graduation.** §13.3's step 3 removes
|
||||
the meta-repo entry's body field, and an open body-edit PR
|
||||
post-graduation would attempt to re-introduce a body to a
|
||||
frontmatter-only entry. The Graduate dialog disables the confirm
|
||||
button if any meta-repo PR is open against `rfcs/<slug>.md`. The
|
||||
precondition is enforced before the bot starts §13.3's sequence,
|
||||
so §13.3's rollback complexity does not grow.
|
||||
- **Bare edit branches survive graduation.** Edit branches without
|
||||
an open PR are not blocked. They remain on the meta repo subject
|
||||
to §12's hygiene timers. The contributor can re-cut against the
|
||||
new RFC repo's main if they still want the work. The branch chat
|
||||
persists per §8.4 as historical record even after auto-close, so
|
||||
the argument that produced the work is preserved regardless of
|
||||
whether the work itself merges.
|
||||
- **Chat migration includes range and paragraph sub-threads.**
|
||||
§13.4's chat-follows-the-work rule covers the whole-doc main
|
||||
thread; it extends to range and paragraph sub-threads on the
|
||||
super-draft's main view, which migrate as part of the same
|
||||
movement. Anchors re-resolve against `RFC.md` on the new repo;
|
||||
since §13.3's step 2 seeds `RFC.md` from the super-draft body
|
||||
verbatim, anchors typically locate the same content. Where they
|
||||
do not, §8.12's stale mechanic engages.
|
||||
- **Pre-graduation history surfaces from the new RFC view.**
|
||||
Meta-repo edit-branch chats, flag threads, and `changes` rows
|
||||
stay attached to their original `branch_name` on the meta repo;
|
||||
they do not migrate. A **"Pre-graduation history"** affordance on
|
||||
the new RFC view surfaces these — the slug remains the canonical
|
||||
key per §2.3, so the query is a straightforward lookup of
|
||||
`threads` and `changes` rows where `rfc_slug = <slug>` and
|
||||
`branch_name` begins with `edit/<slug>/`. UI affordance; no data
|
||||
movement, no rollback cost.
|
||||
frontmatter-only entry.~~ **No longer applies** — the body is kept,
|
||||
so an open body-edit PR coexists with graduation. Graduation touches
|
||||
only the frontmatter; a body-edit PR that merges after graduation
|
||||
edits the same entry's body just as it would have before. The
|
||||
Graduate dialog no longer gates on open body-edit PRs (§13.2).
|
||||
- ~~**Bare edit branches survive graduation.**~~ Trivially true now —
|
||||
no repo boundary is crossed, so every edit branch simply remains a
|
||||
meta-repo branch on the same slug, subject to §12's hygiene timers,
|
||||
with no "re-cut against the new repo" step.
|
||||
- ~~**Chat migration includes range and paragraph sub-threads.**~~ No
|
||||
migration occurs: whole-doc, range, and paragraph threads stay on
|
||||
their `(rfc_slug, branch_name)` rows. Their anchors resolve against
|
||||
the same entry body, which did not move, so §8.12's stale mechanic is
|
||||
not provoked by graduation.
|
||||
- ~~**Pre-graduation history surfaces from the new RFC view.**~~ There
|
||||
is no "new RFC view" distinct from the entry's own view, so there is
|
||||
no pre-graduation hop to bridge. Edit-branch threads, flags, and
|
||||
`changes` rows surface on the active RFC the same way they did on the
|
||||
super-draft — same slug, same surface.
|
||||
|
||||
---
|
||||
|
||||
@@ -1912,16 +1995,23 @@ email request to an owner.
|
||||
|
||||
---
|
||||
|
||||
## 13. The graduation flow (super-draft → active RFC repo)
|
||||
## 13. The graduation flow (super-draft → active, in place)
|
||||
|
||||
Graduation is initiated by an owner or admin clicking "Graduate to RFC
|
||||
repo" on a super-draft's page. The button is disabled with a tooltip
|
||||
when the super-draft has no owners (see §13.1) or when any meta-repo
|
||||
body-edit PR is open against `rfcs/<slug>.md` (see §9.8 — open
|
||||
body-edit PRs would attempt to re-introduce a body to a frontmatter-
|
||||
only entry after step 3 of §13.3). Bare edit branches without an open
|
||||
PR do not block graduation; they remain on the meta repo subject to
|
||||
§12's hygiene timers.
|
||||
Graduation is initiated by an owner or admin clicking "Graduate" on a
|
||||
super-draft's page. The button is disabled with a tooltip when the
|
||||
super-draft has no owners (see §13.1). Open meta-repo body-edit PRs no
|
||||
longer block graduation: under the meta-only topology (§1) the body is
|
||||
kept in the entry, so graduation touches only frontmatter and coexists
|
||||
with body edits (see §9.8). Bare edit branches are likewise unaffected;
|
||||
they remain on the meta repo subject to §12's hygiene timers.
|
||||
|
||||
> **Meta-only rewrite (v0.31.0).** §13 originally described a
|
||||
> transactional create-repo-seed-flip sequence (`super-draft → active
|
||||
> RFC repo`) with rollback. That is **retired** (§1). Graduation is now
|
||||
> a single in-place state flip on the entry: no repo is created, the
|
||||
> body is not moved or stripped, and there is nothing to roll back. The
|
||||
> subsections below are rewritten to the new model; §13.3 records what
|
||||
> the old transaction did, struck through, for the audit trail.
|
||||
|
||||
### 13.1 Claim ownership (prerequisite)
|
||||
|
||||
@@ -1942,137 +2032,117 @@ broadening rather than a precondition for the proposer's own RFC.)
|
||||
|
||||
### 13.2 The Graduate dialog
|
||||
|
||||
Clicking "Graduate to RFC repo" opens a small dialog with three
|
||||
editable fields:
|
||||
Clicking "Graduate" opens a small dialog with two editable fields:
|
||||
|
||||
- **Integer ID** — pre-filled as `max(existing integer IDs) + 1`,
|
||||
formatted as `RFC-NNNN`. Editable to allow gap reservations but the
|
||||
default is just the next number.
|
||||
- **Repo name** — pre-filled as `rfc-NNNN-<slug>`, editable but
|
||||
constrained to valid Gitea repo names.
|
||||
- **Initial owners** — pre-filled from the entry's `owners:`, with an
|
||||
"add owner" picker. Must have at least one.
|
||||
|
||||
(The old **Repo name** field is gone — there is no repo to name under
|
||||
the meta-only topology.)
|
||||
|
||||
Each field validates inline as the admin types, with a short
|
||||
debounce, against the catalog cache and a regex — integer-ID
|
||||
collision against existing IDs, repo-name pattern against valid
|
||||
Gitea name rules, the at-least-one-owner constraint on the picker.
|
||||
Errors render as a short line of text beneath the offending field.
|
||||
The repo-name collision check is re-issued atomically server-side
|
||||
on confirm, since a concurrent graduation could land between
|
||||
dialog-open and submit. While any field is invalid, the confirm
|
||||
button is disabled and its tooltip names the first blocker
|
||||
specifically — "Integer ID 42 is already taken," "Repo name must be
|
||||
lowercase letters, digits, and dashes," "Add at least one initial
|
||||
owner" — the same grammar the precondition popover below uses, so
|
||||
the dialog and the gate read as one surface rather than two
|
||||
competing styles.
|
||||
collision against existing IDs, the at-least-one-owner constraint on
|
||||
the picker. Errors render as a short line of text beneath the
|
||||
offending field. The integer-ID collision check is re-issued
|
||||
atomically server-side on confirm, since a concurrent graduation
|
||||
could land between dialog-open and submit. While any field is
|
||||
invalid, the confirm button is disabled and its tooltip names the
|
||||
first blocker specifically — "Integer ID 42 is already taken," "Add
|
||||
at least one initial owner" — the same grammar the precondition
|
||||
popover below uses, so the dialog and the gate read as one surface
|
||||
rather than two competing styles.
|
||||
|
||||
The dialog's confirm button is also disabled when the preconditions
|
||||
from §13's opening paragraph fail — no owners on the entry, or any
|
||||
open meta-repo PR against `rfcs/<slug>.md`. The disabled button
|
||||
opens a small popover on hover or click that lists each failing
|
||||
precondition as its own line item with an inline remediation
|
||||
affordance per item. "No owners claimed yet" surfaces a "Copy share
|
||||
link" affordance for surfacing the super-draft to a would-be
|
||||
claimer, plus a secondary "Claim ownership yourself" — admins are
|
||||
contributors per §6.1, so they can claim if they intend to graduate
|
||||
solo. "N open body-edit PRs" expands inline within the popover to a
|
||||
list of the offending PRs, one per row, carrying each PR's title,
|
||||
author, and last-activity timestamp plus inline merge, withdraw,
|
||||
and open-in-new-tab affordances; admins hold §6.3 authority on
|
||||
those PRs and can resolve the precondition from the popover without
|
||||
leaving the Graduate context.
|
||||
The dialog's confirm button is also disabled when the entry has no
|
||||
owners. The disabled button opens a small popover on hover or click
|
||||
listing the failing precondition with an inline remediation
|
||||
affordance: "No owners claimed yet" surfaces a "Copy share link"
|
||||
affordance for surfacing the super-draft to a would-be claimer, plus
|
||||
a secondary "Claim ownership yourself" — admins are contributors per
|
||||
§6.1, so they can claim if they intend to graduate solo. Open
|
||||
body-edit PRs are **not** a precondition anymore (§9.8): the body is
|
||||
kept, so they coexist with graduation.
|
||||
|
||||
The preconditions are enforced before the bot starts §13.3's
|
||||
sequence, so §13.3's rollback complexity is unchanged.
|
||||
### 13.3 The flip
|
||||
|
||||
### 13.3 The transactional sequence
|
||||
Confirming the dialog runs a single operation as the bot: open a PR
|
||||
against the meta repo that re-serializes `rfcs/<slug>.md` with
|
||||
`state: active`, `id: RFC-NNNN`, `graduated_at: <timestamp>`,
|
||||
`graduated_by: <admin username>`, and the `owners:` from the dialog —
|
||||
**leaving the body unchanged** — then auto-merge it (the admin who
|
||||
clicked is the merge actor). The webhook flow updates the SQLite cache
|
||||
and the catalog row transitions per §7.2.
|
||||
|
||||
Confirming the dialog runs this sequence as the bot:
|
||||
```
|
||||
super-draft entry ──[graduate]──▶ same entry, state: active, id assigned
|
||||
(body unchanged, repo: null, lives in rfcs/<slug>.md throughout)
|
||||
```
|
||||
|
||||
1. Create the new Gitea repo.
|
||||
2. Seed it with an initial commit on `main` containing:
|
||||
- `README.md` (header pointing at the meta-repo entry, plus the
|
||||
super-draft's pitch body migrated over).
|
||||
- `RFC.md` (the actual document, starting from the super-draft body
|
||||
or a template if the body is empty).
|
||||
- `.rfc/metadata.yaml` — mirror of the meta-repo frontmatter for
|
||||
future tooling.
|
||||
3. Open a PR against the meta repo updating the entry: `state: active`,
|
||||
`id: RFC-NNNN`, `repo: <new repo URL>`, `graduated_at: <timestamp>`,
|
||||
`graduated_by: <admin username>`. The meta-repo entry's body field
|
||||
is removed (frontmatter only, plus a generated "see the full RFC at
|
||||
<repo>" link).
|
||||
4. Auto-merge the PR (the same admin who clicked the button is the
|
||||
merge actor).
|
||||
5. Webhook flow updates the SQLite cache; left pane reflects the new
|
||||
state immediately.
|
||||
There is no repo to create, nothing to seed, and the body is neither
|
||||
moved nor stripped, so there is **no multi-step transaction and no
|
||||
rollback**. If opening or merging the flip PR fails, the entry simply
|
||||
stays a super-draft and the admin sees the error — nothing partial was
|
||||
created that needs cleaning up. The dialog reports a single in-flight
|
||||
"Graduating…" state that resolves to success (the PR merged) or a
|
||||
plain error (the PR could not be opened or merged), rather than the
|
||||
old five-step stack. On success, a brief "Graduation complete" frame
|
||||
holds for a moment before the dialog closes.
|
||||
|
||||
The dialog renders the sequence in flight as a stack of the five
|
||||
named steps with per-step states — `pending`, `running`, `done`,
|
||||
`failed`, `not reached` — and a one-line caption beneath the current
|
||||
step naming the concrete operation ("Creating repository
|
||||
wiggleverse/rfc-0042-human…"). The stack streams from
|
||||
the server via the SSE surface in §17, one event per step
|
||||
transition. On success, a brief "Graduation complete" frame holds
|
||||
for a moment before the dialog closes and the catalog row
|
||||
transitions per §7.2.
|
||||
> ~~**The old transactional sequence (per-RFC-repo model, retired).**
|
||||
> Confirming created a new Gitea repo; seeded it with `README.md`,
|
||||
> `RFC.md` (body migrated), and `.rfc/metadata.yaml`; opened a meta-repo
|
||||
> PR flipping `state`/`id`/`repo`/`graduated_*` **and stripping the
|
||||
> entry body** to frontmatter-only with a "see the full RFC at <repo>"
|
||||
> link; auto-merged it; refreshed the cache. A five-step SSE stack
|
||||
> rendered progress, and any mid-sequence failure rolled back (delete
|
||||
> the half-created repo, abandon the PR). All of that machinery is
|
||||
> removed — the body strip was the only reason most of it existed.~~
|
||||
|
||||
If any step fails partway, the app rolls back: deletes the
|
||||
half-created repo, abandons the unmerged PR, surfaces a clear error
|
||||
to the admin. The rollback is itself a visible step appended to the
|
||||
stack on failure — the admin sees that cleanup ran, not just that
|
||||
the act failed. The failed step turns red, later original-sequence
|
||||
steps mark "not reached," and a "What happened" panel renders below
|
||||
the stack explaining what was rolled back, what wasn't (if anything
|
||||
is unrecoverable), and what to do next. The panel persists until the
|
||||
admin dismisses it — a failure surface is not auto-dismissed.
|
||||
Graduation is rare enough to afford this level of care.
|
||||
### 13.4 Chat, branches, and history stay put
|
||||
|
||||
### 13.4 Chat history follows the work
|
||||
Nothing moves at graduation. The whole-doc main thread (§8.4), range
|
||||
and paragraph sub-threads (§8.12), edit-branch chats, flag threads,
|
||||
and `changes` rows all remain on their existing `(rfc_slug,
|
||||
branch_name)` rows — the slug is the canonical key per §2.3 and does
|
||||
not change. Their anchors resolve against the same entry body, which
|
||||
did not move, so §8.12's stale mechanic is not provoked by graduation.
|
||||
|
||||
The chat thread attached to the super-draft moves to the new repo's
|
||||
main-branch chat at graduation. This covers both the whole-doc main
|
||||
thread per §8.4 and any range or paragraph sub-threads per §8.12
|
||||
anchored to the super-draft's main view; anchors re-resolve against
|
||||
`RFC.md` on the new repo and, where they fail, §8.12's stale
|
||||
mechanic engages. The meta-repo entry retains a generated link
|
||||
"Conversation continues at <repo URL>." The chat is about the RFC,
|
||||
not the meta-repo entry, and it should travel with the work.
|
||||
Because there is no repo boundary to cross, there is no
|
||||
"pre-graduation history" hop: the active RFC's view is the same view
|
||||
the super-draft had, listing the same `main`, open branches, and open
|
||||
PRs in the §8.1 breadcrumb dropdown. Edit branches that closed during
|
||||
the super-draft phase surface through the ordinary "Show closed
|
||||
branches" filter — there is no separate "lived on the meta repo before
|
||||
the repo existed" set to distinguish, because the repo never existed.
|
||||
|
||||
Meta-repo edit-branch chats, flag threads, and `changes` rows from
|
||||
the super-draft phase **do not migrate**. They stay attached to
|
||||
their original `branch_name` on the meta repo and surface from the
|
||||
new RFC view via a **"Pre-graduation history"** affordance — a
|
||||
straightforward lookup of `threads` and `changes` rows where
|
||||
`rfc_slug = <slug>` and `branch_name` begins with `edit/<slug>/`
|
||||
(the slug remains the canonical key per §2.3, before and after
|
||||
graduation). UI affordance; no data movement, no rollback cost.
|
||||
### 13.5 Reversing graduation
|
||||
|
||||
The affordance renders as a section in the §8.1 breadcrumb dropdown
|
||||
on the new RFC view, alongside `main`, open branches, and open PRs,
|
||||
headed "Pre-graduation history (N)" with each pre-graduation edit
|
||||
branch listed as its own row. Selecting a row swaps the center
|
||||
column to a read-only render of that branch's body at its last
|
||||
commit and the right column to that branch's chat, with associated
|
||||
change-cards and flags inline — the same machinery a closed branch
|
||||
on an active RFC uses per §10.7 and §11.5. Anchors on pre-graduation
|
||||
threads resolve against the pre-graduation body, not against
|
||||
`RFC.md` on the new repo. The pre-graduation set is kept distinct
|
||||
from the post-graduation "Show closed branches" filter in the same
|
||||
dropdown — "branches that closed normally on this repo" and
|
||||
"branches that lived on the meta repo before this repo existed" are
|
||||
semantically different sets, and conflating them would obscure the
|
||||
graduation hop.
|
||||
The canonical forward path from `active` is still `withdrawn` (§3.1),
|
||||
and `withdrawn → super-draft` reopens an entry. Under the meta-only
|
||||
topology, reversing a graduation is no longer operationally messy —
|
||||
there are no repo commits to orphan, only a frontmatter flip — but the
|
||||
state graph in §3.1 remains the authority: `active → withdrawn →
|
||||
super-draft` is the supported route, and the integer `id`, once
|
||||
assigned, is not reclaimed (gap-free allocation per §2.3 tolerates
|
||||
gaps from withdrawals). A direct `active → super-draft` un-graduate is
|
||||
not exposed in v1; withdraw-and-reopen covers the need.
|
||||
|
||||
### 13.5 Graduation is not reversible
|
||||
### 13.6 RFC-0001 fold-back (migration record)
|
||||
|
||||
Once an entry is graduated to `active`, the path forward is
|
||||
`withdrawn`, not back to `super-draft`. Reversing graduation cleanly
|
||||
is operationally messy (existing commits in the new repo, etc.) and
|
||||
the cost of not having it is low — withdraw and re-graduate as a
|
||||
fresh idea if needed.
|
||||
RFC-0001 `human` was graduated under the original per-RFC-repo model
|
||||
(2026-05-26) into `wiggleverse/rfc-0001-human`, with its body in that
|
||||
repo's `RFC.md` and its meta entry stripped to frontmatter. When the
|
||||
meta-only topology landed (v0.31.0, OHM ROADMAP #36, driver session
|
||||
0041.0), RFC-0001 was folded back to the single model: the full
|
||||
`RFC.md` body was restored into `rfcs/human.md` in the meta repo
|
||||
(`repo:` set null, `state: active` and `id: RFC-0001` retained), and
|
||||
`wiggleverse/rfc-0001-human` was archived with a `README` pointing at
|
||||
the canonical home in the app. RFC-0001 is therefore an ordinary
|
||||
meta-only active RFC like any other; no grandfathered per-repo path
|
||||
remains in the code.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -38,10 +38,22 @@ GITEA_WEBHOOK_SECRET=change-me-to-a-shared-secret
|
||||
# Comma-separated list of provider keys to enable. Per the §19.2
|
||||
# per-RFC-model topic, this is app-wide until that topic lands.
|
||||
ENABLED_MODELS=claude
|
||||
# ANTHROPIC_API_KEY also powers the §9.1 propose-RFC tag suggestions
|
||||
# (roadmap #27) — that surface always uses Claude Haiku for cost,
|
||||
# independent of ENABLED_MODELS. With no key set, tag suggestions are
|
||||
# simply unavailable (the modal hides the row); the rest of the app is
|
||||
# unaffected.
|
||||
ANTHROPIC_API_KEY=
|
||||
GOOGLE_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# --- Tag suggestions (§9.1 / roadmap #27) ---
|
||||
# Per-user rate limit on the suggest-tags endpoint (cost backstop; the
|
||||
# modal debounces and the endpoint is contributor-gated). Optional —
|
||||
# these defaults apply when unset.
|
||||
TAG_SUGGEST_RATE_MAX=30
|
||||
TAG_SUGGEST_RATE_WINDOW_SECONDS=60
|
||||
|
||||
# --- Email (§15.4) ---
|
||||
# Leave SMTP_HOST unset to use the stdout fallback — the integration
|
||||
# tests rely on it, and a dev environment without a real SMTP provider
|
||||
|
||||
+180
-1
@@ -21,6 +21,7 @@ from pydantic import BaseModel, Field
|
||||
from . import (
|
||||
api_admin,
|
||||
api_branches,
|
||||
api_contributions,
|
||||
api_discussion,
|
||||
api_graduation,
|
||||
api_invitations,
|
||||
@@ -39,6 +40,7 @@ from . import (
|
||||
notify,
|
||||
philosophy,
|
||||
providers as providers_mod,
|
||||
tag_suggest,
|
||||
)
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
@@ -51,6 +53,22 @@ class ProposeBody(BaseModel):
|
||||
slug: str = Field(min_length=1, max_length=80)
|
||||
pitch: str = Field(min_length=1)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
# Roadmap #26: optional "What will you be using this RFC for?" — the
|
||||
# concrete ground-truth use case, distinct from the `pitch`'s abstract
|
||||
# "why is this needed." Optional (NULL/omitted accepted), no minimum,
|
||||
# generous cap matching the pitch's free-text body bound.
|
||||
proposed_use_case: str | None = Field(default=None, max_length=8000)
|
||||
|
||||
|
||||
class SuggestTagsBody(BaseModel):
|
||||
# Roadmap #27: the partial propose-RFC draft, sent as the user types
|
||||
# (debounced on the frontend). All fields optional — suggestions
|
||||
# refine as the draft fills in. `pitch` is the "why is this needed"
|
||||
# rationale; `use_case` is the #26 optional ground-truth field.
|
||||
# Bounds mirror the propose body's free-text caps.
|
||||
title: str = Field(default="", max_length=200)
|
||||
pitch: str = Field(default="", max_length=8000)
|
||||
use_case: str = Field(default="", max_length=8000)
|
||||
|
||||
|
||||
class DeclineBody(BaseModel):
|
||||
@@ -62,6 +80,18 @@ class FunderCredentialBody(BaseModel):
|
||||
api_key: str = Field(min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class LastStateBody(BaseModel):
|
||||
# v0.23.0 / roadmap item #29: server-side sign-in state resume.
|
||||
# `route` is a frontend pathname the user was last on (bounded so a
|
||||
# hostile client can't stuff arbitrary blobs through). `state` is an
|
||||
# optional bag of *light* view state (scroll anchors, open tab,
|
||||
# filter chips). PRIVACY: it MUST NOT carry draft-buffer contents —
|
||||
# the frontend only ever sends ephemeral view state, and the column
|
||||
# comment in migration 022 + SPEC §6.2 are the binding contract.
|
||||
route: str = Field(min_length=1, max_length=2048)
|
||||
state: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class BetaRequestBody(BaseModel):
|
||||
# v0.8.0 — captured on the first OTC sign-in. All three fields are
|
||||
# required so the admin queue has a coherent triage shape.
|
||||
@@ -112,6 +142,10 @@ def make_router(
|
||||
# invited users keep read access but cannot write (v0.6.0
|
||||
# contract extended to per-RFC scope).
|
||||
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.
|
||||
@@ -349,6 +383,28 @@ def make_router(
|
||||
)
|
||||
has_passcode = bool(row and row["passcode_hash"])
|
||||
passcode_set_at = row["passcode_set_at"] if (row and has_passcode) else None
|
||||
# v0.23.0 / item #29: fold the sign-in-resume state onto the
|
||||
# same round-trip the frontend already makes on boot. When
|
||||
# resume is disabled (resume_enabled = 0) we hand back a null
|
||||
# route so the client never redirects; the stored row stays put
|
||||
# so re-enabling later resumes the last-known route.
|
||||
state_row = db.conn().execute(
|
||||
"SELECT last_route, last_route_state, resume_enabled "
|
||||
"FROM user_session_state WHERE user_id = ?",
|
||||
(user.user_id,),
|
||||
).fetchone()
|
||||
resume_enabled = bool(state_row["resume_enabled"]) if state_row else True
|
||||
last_route = (
|
||||
state_row["last_route"]
|
||||
if (state_row and resume_enabled)
|
||||
else None
|
||||
)
|
||||
last_route_state = None
|
||||
if state_row and resume_enabled and state_row["last_route_state"]:
|
||||
try:
|
||||
last_route_state = json.loads(state_row["last_route_state"])
|
||||
except (ValueError, TypeError):
|
||||
last_route_state = None
|
||||
return {
|
||||
"authenticated": True,
|
||||
"user": {
|
||||
@@ -365,6 +421,10 @@ def make_router(
|
||||
"needs_profile": needs_profile,
|
||||
"has_passcode": has_passcode,
|
||||
"passcode_set_at": passcode_set_at,
|
||||
# v0.23.0 / item #29 — sign-in state resume.
|
||||
"resume_enabled": resume_enabled,
|
||||
"last_route": last_route,
|
||||
"last_route_state": last_route_state,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -434,6 +494,52 @@ def make_router(
|
||||
notify.fan_out_new_beta_request(requester_user_id=user.user_id)
|
||||
return {"ok": True}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.23.0 (§6.2, roadmap item #29): server-side sign-in state
|
||||
# resume. The frontend debounce-posts the user's current route +
|
||||
# a small bag of light view state here on every route change; the
|
||||
# next sign-in reads `last_route` off `/api/auth/me` and redirects.
|
||||
#
|
||||
# Per-user (NOT per-device) — one row per user, keyed on user_id.
|
||||
# `resume_enabled` is the opt-out flag (default on); when it's 0
|
||||
# this endpoint no-ops so a user who turned resume off doesn't keep
|
||||
# silently rewriting their stored route. PRIVACY: the body carries
|
||||
# route + light state ONLY, never draft-buffer contents (migration
|
||||
# 022 column comment + SPEC §6.2 are the binding contract).
|
||||
#
|
||||
# `require_user` (not `require_contributor`) — a pending/granted
|
||||
# distinction is irrelevant for "remember where I was", and a
|
||||
# pending user navigating read-only surfaces should still resume.
|
||||
# Anonymous callers get the 401 `require_user` raises.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.put("/api/me/last-state")
|
||||
async def put_last_state(body: LastStateBody, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_user(request)
|
||||
# Respect the opt-out: if a row already exists with resume
|
||||
# disabled, leave it untouched and report the no-op. A first-
|
||||
# ever POST (no row yet) defaults to enabled and stores.
|
||||
existing = db.conn().execute(
|
||||
"SELECT resume_enabled FROM user_session_state WHERE user_id = ?",
|
||||
(user.user_id,),
|
||||
).fetchone()
|
||||
if existing is not None and not existing["resume_enabled"]:
|
||||
return {"ok": True, "stored": False}
|
||||
state_json = json.dumps(body.state) if body.state is not None else None
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO user_session_state
|
||||
(user_id, last_route, last_route_state, last_updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
last_route = excluded.last_route,
|
||||
last_route_state = excluded.last_route_state,
|
||||
last_updated_at = excluded.last_updated_at
|
||||
""",
|
||||
(user.user_id, body.route, state_json),
|
||||
)
|
||||
return {"ok": True, "stored": True}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
|
||||
#
|
||||
@@ -557,12 +663,36 @@ def make_router(
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
return _serialize_rfc(row)
|
||||
payload = _serialize_rfc(row)
|
||||
# Roadmap #26: surface the optional propose-time use case on the
|
||||
# RFC view. The idea PR closes on merge, but the canonical row in
|
||||
# `proposed_use_cases` persists; look it up by slug (the latest
|
||||
# 'rfc'-scope row for this slug). NULL == "left blank".
|
||||
uc = db.conn().execute(
|
||||
"""
|
||||
SELECT use_case FROM proposed_use_cases
|
||||
WHERE scope = 'rfc' AND rfc_slug = ?
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
||||
return payload
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §7.3 / §9.3: pending ideas
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def _proposal_use_case(pr_number: int) -> str | None:
|
||||
"""Roadmap #26: read the optional use case for an idea PR from the
|
||||
canonical side table. Returns None when none was supplied (the
|
||||
"left blank" sentinel the frontend renders tastefully)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT use_case FROM proposed_use_cases WHERE scope = 'rfc' AND pr_number = ?",
|
||||
(pr_number,),
|
||||
).fetchone()
|
||||
return row["use_case"] if row else None
|
||||
|
||||
@router.get("/api/proposals")
|
||||
async def list_proposals() -> dict[str, Any]:
|
||||
rows = db.conn().execute(
|
||||
@@ -582,6 +712,7 @@ def make_router(
|
||||
"description": r["description"],
|
||||
"opened_by": r["opened_by"],
|
||||
"opened_at": r["opened_at"],
|
||||
"proposed_use_case": _proposal_use_case(r["pr_number"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -630,6 +761,7 @@ def make_router(
|
||||
"opened_at": row["opened_at"],
|
||||
"entry": entry_payload,
|
||||
"affordances": affordances,
|
||||
"proposed_use_case": _proposal_use_case(pr_number),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
@@ -706,8 +838,55 @@ def make_router(
|
||||
# cache write is idempotent.)
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
|
||||
# Roadmap #26: persist the optional use case to the canonical,
|
||||
# reconcile-proof side table keyed by the idea PR number. NULL/
|
||||
# blank simply writes no row (absence == "left blank"). Done after
|
||||
# the refresh so the cache row exists; the mirror onto cached_prs
|
||||
# keeps the cache column in parity for any read that uses it.
|
||||
use_case = (payload.proposed_use_case or "").strip()
|
||||
if use_case:
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
|
||||
VALUES ('rfc', ?, ?, ?)
|
||||
ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
||||
""",
|
||||
(slug, pr["number"], use_case),
|
||||
)
|
||||
db.conn().execute(
|
||||
"UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ?",
|
||||
(use_case, pr["number"]),
|
||||
)
|
||||
|
||||
return {"pr_number": pr["number"], "slug": slug}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §9.1 Slice 2 (roadmap #27): Claude Haiku tag suggestions as the
|
||||
# propose-RFC fields fill in. The modal debounce-posts the partial
|
||||
# draft; we constrain Haiku to the corpus's existing tag set and
|
||||
# return a short ranked list of clickable chips. Gated to
|
||||
# contributors (same gate as propose) so the cost surface is bounded
|
||||
# to people who can actually file an RFC; rate-limited per user as a
|
||||
# backstop. Degrades to an empty list (no error) when no Anthropic
|
||||
# key is bound, the corpus has no tags yet, or the draft is empty —
|
||||
# so the modal simply shows nothing extra.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/suggest-tags")
|
||||
async def suggest_rfc_tags(payload: SuggestTagsBody, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_contributor(request)
|
||||
if not tag_suggest.rate_limit_ok(user.user_id):
|
||||
raise HTTPException(429, "Too many tag-suggestion requests; please slow down.")
|
||||
provider = tag_suggest.haiku_provider(config)
|
||||
if provider is None:
|
||||
return {"suggestions": []}
|
||||
universe = tag_suggest.gather_tag_universe()
|
||||
draft = tag_suggest.Draft(
|
||||
title=payload.title, pitch=payload.pitch, use_case=payload.use_case
|
||||
)
|
||||
suggestions = tag_suggest.suggest(provider, draft, universe)
|
||||
return {"suggestions": suggestions}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §9.3: merge / decline / withdraw an idea PR
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
+59
-37
@@ -150,7 +150,7 @@ def make_router(
|
||||
# `refresh_meta_branches` writes is internal scaffolding for the
|
||||
# §10.1 has-commits-ahead check — the §9.4 dropdown's first
|
||||
# position is rendered separately as 'canonical body'.
|
||||
if _is_super_draft(rfc):
|
||||
if _is_meta_resident(rfc):
|
||||
branch_rows = db.conn().execute(
|
||||
"""
|
||||
SELECT branch_name, head_sha, state, last_commit_at, pinned
|
||||
@@ -180,7 +180,7 @@ def make_router(
|
||||
# per-RFC repo. For super-draft: meta_body_edit and meta_metadata
|
||||
# PRs on the meta repo. Same shape either way — the §9.4 dropdown
|
||||
# treats both as "open work against this entry."
|
||||
pr_kinds = ("meta_body_edit", "meta_metadata") if _is_super_draft(rfc) else ("rfc_branch",)
|
||||
pr_kinds = ("meta_body_edit", "meta_metadata") if _is_meta_resident(rfc) else ("rfc_branch",)
|
||||
placeholders = ",".join("?" * len(pr_kinds))
|
||||
pr_rows = db.conn().execute(
|
||||
f"""
|
||||
@@ -204,17 +204,18 @@ def make_router(
|
||||
for r in pr_rows
|
||||
]
|
||||
|
||||
# For super-drafts the cached body is entry.body already (see
|
||||
# cache._upsert_cached_rfc), so no extraction is needed.
|
||||
# §9.8 / §13.4 pre-graduation history: for active RFCs, surface
|
||||
# any `threads` or `changes` rows whose `branch_name` starts with
|
||||
# `edit-<slug>-` so the breadcrumb dropdown can render the
|
||||
# affordance as a distinct disclosure alongside main, open
|
||||
# branches, and open PRs. The slug is the canonical key per §2.3
|
||||
# before and after graduation, so the query is a straightforward
|
||||
# lookup — no data movement.
|
||||
# For meta-resident entries the cached body is entry.body already
|
||||
# (see cache._upsert_cached_rfc), so no extraction is needed.
|
||||
# Pre-graduation history is a LEGACY-only affordance: under the
|
||||
# meta-only topology (§1, §13.4) graduation moves nothing, so an
|
||||
# active RFC's edit branches are its *current* branches and already
|
||||
# surface in `branches` above — there is no separate pre-graduation
|
||||
# set. The disclosure is therefore computed only for a legacy
|
||||
# per-RFC-repo active entry (`repo` set), where edit branches on the
|
||||
# meta repo genuinely predate the per-RFC repo and would otherwise
|
||||
# not appear. After the RFC-0001 fold-back (§13.6) nothing matches.
|
||||
pre_grad: list[dict[str, Any]] = []
|
||||
if rfc["state"] == "active":
|
||||
if rfc["state"] == "active" and rfc["repo"]:
|
||||
pre_grad_rows = db.conn().execute(
|
||||
"""
|
||||
SELECT t.branch_name,
|
||||
@@ -292,8 +293,20 @@ def make_router(
|
||||
owner, repo = _repo_for(rfc)
|
||||
new_branch = (body.branch_name or "").strip()
|
||||
if not new_branch:
|
||||
new_branch = _auto_branch_name(viewer.gitea_login)
|
||||
_validate_branch_name(new_branch)
|
||||
# Meta-only topology (§1): an active RFC's branches live on the
|
||||
# shared meta repo, so the auto name must embed the slug for the
|
||||
# cache to attribute it (`edit-<slug>-<hex>`, recovered by
|
||||
# `_slug_from_branch_name`). A legacy per-RFC-repo entry can use
|
||||
# the slug-free `<login>-draft-<hex>` since every branch there
|
||||
# belongs to the one RFC. The auto name is trusted (it carries a
|
||||
# reserved `edit-` prefix by design); only a user-supplied name
|
||||
# is validated, mirroring `start_edit_branch`.
|
||||
new_branch = (
|
||||
_auto_edit_branch_name(slug) if _is_meta_resident(rfc)
|
||||
else _auto_branch_name(viewer.gitea_login)
|
||||
)
|
||||
else:
|
||||
_validate_branch_name(new_branch)
|
||||
try:
|
||||
await bot.cut_branch_from_main(
|
||||
viewer.as_actor(),
|
||||
@@ -326,8 +339,10 @@ def make_router(
|
||||
_ensure_branch_vis(slug, new_branch, creator_user_id=viewer.user_id)
|
||||
|
||||
# Make the cache aware immediately so the breadcrumb reflects
|
||||
# the new branch without waiting for the webhook hop.
|
||||
await cache.refresh_rfc_repo(config, gitea, slug)
|
||||
# the new branch without waiting for the webhook hop. Meta-resident
|
||||
# entries (§1) refresh meta branches; a legacy per-RFC repo refreshes
|
||||
# its own — `_refresh_cache_for` dispatches on residency.
|
||||
await _refresh_cache_for(rfc)
|
||||
|
||||
return {"branch_name": new_branch, "slug": slug}
|
||||
|
||||
@@ -1081,14 +1096,14 @@ def make_router(
|
||||
return row
|
||||
|
||||
def _require_rfc_with_repo(slug: str):
|
||||
"""Used by every branch-scoped endpoint. For active RFCs, a repo is
|
||||
required. For super-drafts, the meta repo is the implicit target —
|
||||
no per-RFC repo check needed."""
|
||||
"""Used by every branch-scoped endpoint. Under the meta-only
|
||||
topology (§1) the meta repo is the implicit target for every
|
||||
entry — super-draft and active alike — so there is no per-RFC
|
||||
repo check. The name is retained for call-site stability; a
|
||||
withdrawn entry is still rejected."""
|
||||
row = _require_rfc(slug)
|
||||
if row["state"] == "withdrawn":
|
||||
raise HTTPException(409, "RFC is withdrawn")
|
||||
if row["state"] == "active" and not row["repo"]:
|
||||
raise HTTPException(409, "RFC has no repo")
|
||||
return row
|
||||
|
||||
def _require_active_rfc(slug: str):
|
||||
@@ -1106,22 +1121,28 @@ def make_router(
|
||||
def _is_super_draft(rfc) -> bool:
|
||||
return rfc["state"] == "super-draft"
|
||||
|
||||
def _is_meta_resident(rfc) -> bool:
|
||||
"""Meta-only topology (§1): an entry lives in the meta repo's
|
||||
`rfcs/<slug>.md` (super-draft or active-in-place) unless it carries
|
||||
a legacy per-RFC `repo:` — which nothing does after the RFC-0001
|
||||
fold-back (§13.6)."""
|
||||
return not rfc["repo"]
|
||||
|
||||
def _is_meta_branch_name(name: str) -> bool:
|
||||
"""A branch name shaped like one of the bot's meta-repo prefixes.
|
||||
§9.8's pre-graduation history affordance points the new RFC view
|
||||
at branches matching `edit-<slug>-...` even after the entry is
|
||||
active; treating those names as meta-repo targets lets the read
|
||||
path dispatch correctly without a separate endpoint."""
|
||||
Retained for the legacy per-RFC-repo read path; under meta-only
|
||||
every entry is already a meta target via `_is_meta_resident`."""
|
||||
return name != "main" and name.startswith((
|
||||
"edit-", "edit/", "metadata-", "metadata/", "claim/", "propose/",
|
||||
"graduate-",
|
||||
))
|
||||
|
||||
def _is_meta_target(rfc, branch: str) -> bool:
|
||||
"""Either a super-draft branch (active edit branch or the
|
||||
canonical body) or an active RFC's pre-graduation meta-repo
|
||||
branch surfaced through the §9.8 history affordance."""
|
||||
if _is_super_draft(rfc):
|
||||
"""A meta-resident entry (super-draft or active-in-place, §1)
|
||||
targets the meta repo for every branch. The branch-name fallback
|
||||
covers the retired per-RFC-repo case for any legacy entry that
|
||||
still carries a `repo:`."""
|
||||
if _is_meta_resident(rfc):
|
||||
return True
|
||||
return _is_meta_branch_name(branch)
|
||||
|
||||
@@ -1161,7 +1182,7 @@ def make_router(
|
||||
return entry_mod.serialize(entry)
|
||||
|
||||
async def _refresh_cache_for(rfc) -> None:
|
||||
if _is_super_draft(rfc):
|
||||
if _is_meta_resident(rfc):
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
await cache.refresh_meta_branches(config, gitea)
|
||||
else:
|
||||
@@ -1270,12 +1291,13 @@ def make_router(
|
||||
return False
|
||||
if branch == "main":
|
||||
return False
|
||||
# §9.8: pre-graduation history branches are read-only on the
|
||||
# post-graduation surface. The contributor can re-cut against the
|
||||
# new repo's main if they still want the work, but the meta-repo
|
||||
# branches that lived on the super-draft are not editable from
|
||||
# the active-RFC view.
|
||||
if rfc["state"] == "active" and _is_meta_branch_name(branch):
|
||||
# §9.8 (LEGACY per-repo only): pre-graduation history branches are
|
||||
# read-only on the post-graduation surface of a per-RFC-repo active
|
||||
# entry. Under the meta-only topology (§1, §13.4) an active RFC's
|
||||
# `edit-<slug>-…` branches are its *current* editable branches, not
|
||||
# a frozen pre-graduation set, so this guard applies only when a
|
||||
# legacy `repo:` is set (nothing, after the RFC-0001 fold-back).
|
||||
if rfc["state"] == "active" and rfc["repo"] and _is_meta_branch_name(branch):
|
||||
return False
|
||||
if viewer.role in ("owner", "admin"):
|
||||
return True
|
||||
@@ -1302,7 +1324,7 @@ def make_router(
|
||||
|
||||
def _require_can_contribute(slug: str, branch: str, viewer) -> None:
|
||||
rfc = db.conn().execute(
|
||||
"SELECT state, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?",
|
||||
"SELECT state, repo, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if not _can_contribute(rfc, slug, branch, viewer):
|
||||
|
||||
@@ -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
|
||||
@@ -40,7 +40,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, chat as chat_layer, db
|
||||
from . import auth, chat as chat_layer, db, rfc_links
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -171,9 +171,17 @@ def make_router() -> APIRouter:
|
||||
""",
|
||||
(thread_id,),
|
||||
).fetchall()
|
||||
# Roadmap #28 Part 1: enrich each discussion comment with RFC
|
||||
# auto-link segments scanned against the live accepted-RFC corpus
|
||||
# (read-time; see rfc_links.py). exclude_slug suppresses self-links
|
||||
# to this RFC inside its own discussion.
|
||||
link_index = rfc_links.build_index(db.conn(), exclude_slug=slug)
|
||||
messages = [_serialize_message(r) for r in rows]
|
||||
for m in messages:
|
||||
m["text_segments"] = link_index.segment(m["text"])
|
||||
return {
|
||||
"thread": _serialize_thread(thread),
|
||||
"messages": [_serialize_message(r) for r in rows],
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
+132
-365
@@ -1,26 +1,30 @@
|
||||
"""Slice 5 API surface — the §13 graduation flow's endpoints and the
|
||||
in-process orchestrator that runs the §13.3 transactional sequence with
|
||||
rollback.
|
||||
"""§13 graduation flow — the meta-only in-place state flip.
|
||||
|
||||
Owns four routes per §17:
|
||||
Under the meta-only topology (SPEC §1), graduation no longer creates a
|
||||
per-RFC repo. It is a single frontmatter-flipping commit to the entry's
|
||||
`rfcs/<slug>.md` on the meta repo: open a PR that re-serializes the entry
|
||||
with `state: active`, the assigned integer `id`, `graduated_at` /
|
||||
`graduated_by`, and the dialog's owners — **leaving the body unchanged** —
|
||||
then auto-merge it. There is no repo to create, nothing to seed, and the
|
||||
body is neither moved nor stripped, so there is no multi-step transaction
|
||||
and no rollback (§13.3). If the open or merge fails, the entry stays a
|
||||
super-draft and we clean up the half-open PR/branch (the only artifact a
|
||||
mid-flip failure can leave behind).
|
||||
|
||||
Routes (§17):
|
||||
|
||||
- GET /api/rfcs/<slug>/blocking-prs (§13.2 precondition popover)
|
||||
- GET /api/rfcs/<slug>/graduate/check (§13.2 debounced validator)
|
||||
- POST /api/rfcs/<slug>/graduate (§13.3 kickoff)
|
||||
- POST /api/rfcs/<slug>/graduate (§13.3 the flip)
|
||||
- GET /api/rfcs/<slug>/graduate/progress (§13.3 SSE step stream)
|
||||
- GET /api/rfcs/<slug>/blocking-prs (informational; no longer a
|
||||
graduation precondition)
|
||||
|
||||
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim), which is
|
||||
graduation's prerequisite for non-admins per §13.1.
|
||||
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim).
|
||||
|
||||
The orchestrator runs in-process — each in-flight graduation lives in a
|
||||
small `GraduationState` keyed by slug, with an asyncio.Queue feeding the
|
||||
SSE handler. Per the §13.3 transactional contract, every forward step is
|
||||
paired with an undo; rollback runs the undos in reverse order from the
|
||||
last step that completed. §13.4's chat migration is a database semantic
|
||||
no-op (the threads' `(rfc_slug, branch_name='main')` rows are interpreted
|
||||
as super-draft canonical-body before graduation and as new-RFC main
|
||||
afterwards — same shape, different meaning), so the only DB work the
|
||||
sequence does is the audit-log rows the bot's `_log` writes per step.
|
||||
SSE handler. §13.4's chat/branch/history are a database no-op: every row
|
||||
is keyed by the slug per §2.3 and stays put across the flip.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -44,24 +48,18 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step machine
|
||||
# Step machine — two steps under meta-only: open the flip PR, merge it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
STEP_KEYS = (
|
||||
"create_repo",
|
||||
"seed_files",
|
||||
"open_pr",
|
||||
"merge_pr",
|
||||
"refresh_cache",
|
||||
)
|
||||
|
||||
STEP_LABELS = {
|
||||
"create_repo": "Create per-RFC repository",
|
||||
"seed_files": "Seed RFC.md, README.md, and .rfc/metadata.yaml",
|
||||
"open_pr": "Open meta-repo graduation PR",
|
||||
"open_pr": "Open graduation PR (flip state to active)",
|
||||
"merge_pr": "Merge graduation PR",
|
||||
"refresh_cache": "Refresh catalog and views",
|
||||
}
|
||||
|
||||
|
||||
@@ -77,8 +75,6 @@ class StepState:
|
||||
class GraduationState:
|
||||
slug: str
|
||||
rfc_id: str
|
||||
repo_name: str
|
||||
repo_full: str
|
||||
owners: list[str]
|
||||
arbiters: list[str]
|
||||
steps: list[StepState]
|
||||
@@ -86,8 +82,6 @@ class GraduationState:
|
||||
finished: bool = False
|
||||
succeeded: bool = False
|
||||
error: str | None = None
|
||||
rollback_started: bool = False
|
||||
rollback_steps: list[StepState] = field(default_factory=list)
|
||||
new_pr_number: int | None = None
|
||||
graduation_branch: str | None = None
|
||||
|
||||
@@ -95,12 +89,9 @@ class GraduationState:
|
||||
return {
|
||||
"slug": self.slug,
|
||||
"rfc_id": self.rfc_id,
|
||||
"repo_full": self.repo_full,
|
||||
"steps": [_step_payload(s) for s in self.steps],
|
||||
"rollback_steps": [_step_payload(s) for s in self.rollback_steps],
|
||||
"finished": self.finished,
|
||||
"succeeded": self.succeeded,
|
||||
"rolled_back": self.rollback_started,
|
||||
"error": self.error,
|
||||
"pr_number": self.new_pr_number,
|
||||
}
|
||||
@@ -114,7 +105,7 @@ def _step_payload(s: StepState) -> dict:
|
||||
# is fine; the registry is keyed by slug to refuse concurrent graduations
|
||||
# of the same entry (the §13.2 atomic re-check is a separate defense
|
||||
# against a concurrent attempt of a DIFFERENT slug claiming the same
|
||||
# integer ID or repo name).
|
||||
# integer ID).
|
||||
_active: dict[str, GraduationState] = {}
|
||||
|
||||
|
||||
@@ -122,10 +113,10 @@ def _get_active(slug: str) -> GraduationState | None:
|
||||
return _active.get(slug)
|
||||
|
||||
|
||||
def _new_active(slug: str, *, rfc_id: str, repo_name: str, repo_full: str,
|
||||
def _new_active(slug: str, *, rfc_id: str,
|
||||
owners: list[str], arbiters: list[str]) -> GraduationState:
|
||||
state = GraduationState(
|
||||
slug=slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full,
|
||||
slug=slug, rfc_id=rfc_id,
|
||||
owners=owners, arbiters=arbiters,
|
||||
steps=[StepState(key=k, label=STEP_LABELS[k]) for k in STEP_KEYS],
|
||||
)
|
||||
@@ -138,17 +129,9 @@ def _new_active(slug: str, *, rfc_id: str, repo_name: str, repo_full: str,
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# §13.2: Gitea repo name pattern. Gitea accepts alphanumerics, dashes,
|
||||
# dots, and underscores; cannot start with a dot. 100-char cap as a sane
|
||||
# upper bound — the spec doesn't pin a max but Gitea's enforcement does.
|
||||
_REPO_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$")
|
||||
_RFC_ID_RE = re.compile(r"^RFC-\d{4,}$")
|
||||
|
||||
|
||||
def _is_valid_repo_name(name: str) -> bool:
|
||||
return bool(_REPO_NAME_RE.match(name)) and ".." not in name
|
||||
|
||||
|
||||
def _is_valid_rfc_id(rfc_id: str) -> bool:
|
||||
return bool(_RFC_ID_RE.match(rfc_id))
|
||||
|
||||
@@ -167,13 +150,6 @@ def _suggest_next_rfc_id() -> str:
|
||||
return f"RFC-{nxt:04d}"
|
||||
|
||||
|
||||
def _suggest_repo_name(slug: str, rfc_id: str) -> str:
|
||||
# rfc-NNNN-<slug> per §13.2's default. Strip the 'RFC-' prefix and
|
||||
# lowercase the number-pad.
|
||||
num = rfc_id.split("-", 1)[1] if "-" in rfc_id else "0001"
|
||||
return f"rfc-{num}-{slug}"
|
||||
|
||||
|
||||
def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool:
|
||||
row = db.conn().execute(
|
||||
"SELECT slug FROM cached_rfcs WHERE rfc_id = ? AND slug != ?",
|
||||
@@ -189,7 +165,6 @@ def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool:
|
||||
|
||||
class GraduateBody(BaseModel):
|
||||
rfc_id: str = Field(min_length=5, max_length=40)
|
||||
repo_name: str = Field(min_length=1, max_length=100)
|
||||
owners: list[str] = Field(min_length=1)
|
||||
|
||||
|
||||
@@ -206,19 +181,17 @@ def make_router(
|
||||
router = APIRouter()
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §13.2: GET /api/rfcs/<slug>/blocking-prs
|
||||
# Lists open meta-repo PRs against rfcs/<slug>.md per the precondition
|
||||
# popover. Returns PR number, title, author, last-activity timestamp,
|
||||
# and the viewer's available actions (merge, withdraw, open-in-new-tab).
|
||||
# GET /api/rfcs/<slug>/blocking-prs
|
||||
# Lists open meta-repo body-edit PRs against rfcs/<slug>.md. Under the
|
||||
# meta-only topology (§9.8) these no longer block graduation — the body
|
||||
# is kept, so a body-edit PR coexists with the flip. Retained as an
|
||||
# informational surface (the dialog can show "N body-edit PRs open").
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/blocking-prs")
|
||||
async def list_blocking_prs(slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
rfc = _require_super_draft(slug)
|
||||
# §13's opening paragraph: only body-edit PRs block graduation.
|
||||
# Bare edit branches without an open PR do not block. The query
|
||||
# filters cached_prs to open meta_body_edit kinds for this slug.
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT pr_number, title, opened_by, opened_at, head_branch, pr_kind
|
||||
@@ -261,14 +234,15 @@ def make_router(
|
||||
"open_in_new_tab": True,
|
||||
},
|
||||
})
|
||||
return {"items": items}
|
||||
# `blocking` is a legacy field name kept for client compatibility;
|
||||
# under §9.8 these PRs do not block graduation.
|
||||
return {"items": items, "blocking": False}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §13.2: GET /api/rfcs/<slug>/graduate/check?id=&repo=
|
||||
# GET /api/rfcs/<slug>/graduate/check?id=
|
||||
# Inline validation for the Graduate dialog — debounced from the
|
||||
# client; the dialog calls this as the admin types. Returns per-field
|
||||
# collision/validity from the catalog cache plus a server-authoritative
|
||||
# repo-name collision check.
|
||||
# client. Two fields under meta-only: the integer ID and the owners
|
||||
# precondition. There is no repo name to validate (§13.2).
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/graduate/check")
|
||||
@@ -281,16 +255,7 @@ def make_router(
|
||||
# admins/owners, but the check itself is read-only.
|
||||
|
||||
candidate_id = (request.query_params.get("id") or "").strip()
|
||||
candidate_repo = (request.query_params.get("repo") or "").strip()
|
||||
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
blocking_count = db.conn().execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n FROM cached_prs
|
||||
WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit'
|
||||
""",
|
||||
(slug,),
|
||||
).fetchone()["n"]
|
||||
|
||||
# ID field
|
||||
id_payload: dict[str, Any] = {"value": candidate_id, "ok": True, "error": None}
|
||||
@@ -304,34 +269,6 @@ def make_router(
|
||||
id_payload["ok"] = False
|
||||
id_payload["error"] = f"Integer ID {candidate_id} is already taken"
|
||||
|
||||
# Repo field — validate pattern then probe Gitea for an existing
|
||||
# repo of that name under our org. The repo lookup is a single GET
|
||||
# so it's cheap to call on every keystroke (debounced from the
|
||||
# client per §13.2).
|
||||
repo_payload: dict[str, Any] = {"value": candidate_repo, "ok": True, "error": None}
|
||||
if not candidate_repo:
|
||||
repo_payload["ok"] = False
|
||||
repo_payload["error"] = "Repo name is required"
|
||||
elif not _is_valid_repo_name(candidate_repo):
|
||||
repo_payload["ok"] = False
|
||||
repo_payload["error"] = (
|
||||
"Repo name must be alphanumerics, dashes, dots, or underscores "
|
||||
"(start with alphanumeric)"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
existing = await gitea.get_repo(config.gitea_org, candidate_repo)
|
||||
except GiteaError as e:
|
||||
# Network/auth flake — surface as a non-fatal hint; the
|
||||
# atomic server-side check at POST time is the authority.
|
||||
existing = None
|
||||
log.warning("graduate_check: Gitea get_repo error: %s", e)
|
||||
if existing is not None:
|
||||
repo_payload["ok"] = False
|
||||
repo_payload["error"] = (
|
||||
f"Repo `{config.gitea_org}/{candidate_repo}` already exists"
|
||||
)
|
||||
|
||||
# Owners precondition — §13's opening paragraph.
|
||||
owners_payload: dict[str, Any] = {
|
||||
"ok": len(owners) > 0,
|
||||
@@ -340,28 +277,13 @@ def make_router(
|
||||
"error": None if len(owners) > 0 else "No owners claimed yet",
|
||||
}
|
||||
|
||||
# Blocking PR precondition — §9.8 / §13's opening paragraph.
|
||||
prs_payload: dict[str, Any] = {
|
||||
"ok": blocking_count == 0,
|
||||
"count": blocking_count,
|
||||
"error": (
|
||||
None if blocking_count == 0
|
||||
else f"{blocking_count} open body-edit PR{'' if blocking_count == 1 else 's'} blocking graduation"
|
||||
),
|
||||
}
|
||||
|
||||
in_flight = _get_active(slug)
|
||||
any_invalid = not (
|
||||
id_payload["ok"] and repo_payload["ok"]
|
||||
and owners_payload["ok"] and prs_payload["ok"]
|
||||
)
|
||||
any_invalid = not (id_payload["ok"] and owners_payload["ok"])
|
||||
|
||||
return {
|
||||
"slug": slug,
|
||||
"id": id_payload,
|
||||
"repo": repo_payload,
|
||||
"owners": owners_payload,
|
||||
"blocking_prs": prs_payload,
|
||||
"can_submit": (not any_invalid) and (in_flight is None or in_flight.finished),
|
||||
"in_flight": (
|
||||
None if in_flight is None
|
||||
@@ -370,8 +292,8 @@ def make_router(
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §13.3: POST /api/rfcs/<slug>/graduate
|
||||
# Atomic re-validation, then kicks off the sequence as an async task.
|
||||
# POST /api/rfcs/<slug>/graduate
|
||||
# Atomic re-validation, then kicks off the flip as an async task.
|
||||
# The client opens GET /graduate/progress on confirm to watch the SSE.
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@@ -392,9 +314,8 @@ def make_router(
|
||||
|
||||
# §13.2 atomic re-validation. The dialog's debounced check runs
|
||||
# client-side as the admin types; this is the authoritative check
|
||||
# that closes the dialog-open-to-confirm race.
|
||||
# that closes the dialog-open-to-confirm race on the integer ID.
|
||||
rfc_id = body.rfc_id.strip()
|
||||
repo_name = body.repo_name.strip()
|
||||
owners = [o.strip() for o in body.owners if o.strip()]
|
||||
if not owners:
|
||||
raise HTTPException(422, "Add at least one initial owner")
|
||||
@@ -402,35 +323,10 @@ def make_router(
|
||||
raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)")
|
||||
if _rfc_id_taken(rfc_id, excluding_slug=slug):
|
||||
raise HTTPException(409, f"Integer ID {rfc_id} is already taken")
|
||||
if not _is_valid_repo_name(repo_name):
|
||||
raise HTTPException(422, "Repo name must be alphanumerics, dashes, dots, or underscores")
|
||||
try:
|
||||
existing_repo = await gitea.get_repo(config.gitea_org, repo_name)
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
if existing_repo is not None:
|
||||
raise HTTPException(409, f"Repo `{config.gitea_org}/{repo_name}` already exists")
|
||||
|
||||
# §9.8 precondition gate — enforced before the bot starts the
|
||||
# sequence so the §13.3 rollback complexity does not grow. An
|
||||
# open body-edit PR against rfcs/<slug>.md would attempt to
|
||||
# re-introduce a body to a frontmatter-only entry after step 3.
|
||||
blocking = db.conn().execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n FROM cached_prs
|
||||
WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit'
|
||||
""",
|
||||
(slug,),
|
||||
).fetchone()["n"]
|
||||
if blocking > 0:
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"{blocking} open body-edit PR{'' if blocking == 1 else 's'} block graduation",
|
||||
)
|
||||
|
||||
# Read the meta-repo entry once — we need the file's sha for the
|
||||
# graduation PR's update_file call and the original body so the
|
||||
# bot can seed RFC.md on the new repo with the migrated body.
|
||||
# graduation PR's update_file call and the body to carry through
|
||||
# unchanged (meta-only keeps the body in the entry, §13.3).
|
||||
fetched = await gitea.read_file(
|
||||
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
|
||||
)
|
||||
@@ -442,19 +338,17 @@ def make_router(
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Meta entry malformed: {e}")
|
||||
|
||||
repo_full = f"{config.gitea_org}/{repo_name}"
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1]
|
||||
|
||||
# Compose the graduated frontmatter — body stripped, graduation
|
||||
# fields filled. The serializer is run now so the PR-open step
|
||||
# has the contents pre-rendered (single source of truth for the
|
||||
# body migration vs. the meta-entry update).
|
||||
# Compose the graduated frontmatter — body KEPT, graduation fields
|
||||
# filled, repo left null (§1). Serialized now so the PR-open step
|
||||
# has the contents pre-rendered.
|
||||
graduated_entry = entry_mod.Entry(
|
||||
slug=slug,
|
||||
title=super_draft_entry.title,
|
||||
state="active",
|
||||
id=rfc_id,
|
||||
repo=repo_full,
|
||||
repo=None,
|
||||
proposed_by=super_draft_entry.proposed_by,
|
||||
proposed_at=super_draft_entry.proposed_at,
|
||||
graduated_at=entry_mod.today(),
|
||||
@@ -462,37 +356,30 @@ def make_router(
|
||||
owners=owners,
|
||||
arbiters=arbiters,
|
||||
tags=list(super_draft_entry.tags),
|
||||
body="",
|
||||
models=super_draft_entry.models,
|
||||
funder=super_draft_entry.funder,
|
||||
body=super_draft_entry.body,
|
||||
)
|
||||
graduated_contents = entry_mod.serialize(graduated_entry)
|
||||
|
||||
state = _new_active(
|
||||
slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full,
|
||||
owners=owners, arbiters=arbiters,
|
||||
slug, rfc_id=rfc_id, owners=owners, arbiters=arbiters,
|
||||
)
|
||||
|
||||
# Audit: graduation started. The terminal `graduate_complete` /
|
||||
# `graduate_rollback` rows below close the linkable sequence.
|
||||
# `graduate_failed` rows below close the linkable sequence.
|
||||
_audit(
|
||||
viewer.user_id, viewer.gitea_login, "graduate_start",
|
||||
rfc_slug=slug,
|
||||
details={
|
||||
"rfc_id": rfc_id, "repo": repo_full, "owners": owners,
|
||||
"blocking_prs": blocking,
|
||||
},
|
||||
details={"rfc_id": rfc_id, "owners": owners},
|
||||
)
|
||||
|
||||
# Test seam: `?_sync=1` awaits the orchestrator inline so
|
||||
# integration tests can assert post-conditions without driving
|
||||
# the SSE. Production clients use the spec-described shape —
|
||||
# POST returns immediately, the client subscribes to the
|
||||
# progress SSE.
|
||||
# the SSE. Production clients POST then subscribe to the SSE.
|
||||
coro = _orchestrate(
|
||||
config=config, gitea=gitea, bot=bot,
|
||||
actor=viewer.as_actor(), state=state,
|
||||
super_draft_body=super_draft_entry.body,
|
||||
super_draft_title=super_draft_entry.title,
|
||||
super_draft_tags=list(super_draft_entry.tags),
|
||||
graduated_contents=graduated_contents,
|
||||
meta_file_sha=meta_sha,
|
||||
)
|
||||
@@ -505,38 +392,31 @@ def make_router(
|
||||
"ok": True,
|
||||
"slug": slug,
|
||||
"rfc_id": rfc_id,
|
||||
"repo": repo_full,
|
||||
"stream_url": f"/api/rfcs/{slug}/graduate/progress",
|
||||
"finished": state.finished,
|
||||
"succeeded": state.succeeded,
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §13.3: GET /api/rfcs/<slug>/graduate/progress
|
||||
# SSE stream of the step transitions. One event per step transition
|
||||
# (pending → running → done / failed), plus the trailing rollback
|
||||
# step's events if any earlier step fails.
|
||||
# GET /api/rfcs/<slug>/graduate/progress
|
||||
# SSE stream of the flip's step transitions (open_pr, merge_pr).
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/graduate/progress")
|
||||
async def graduate_progress(slug: str, request: Request):
|
||||
# v0.6.0 (item #4): the progress SSE surfaces admin-internal step
|
||||
# detail (repo name, PR number, rollback steps) that isn't part of
|
||||
# the v0.3.0 anonymous-read contract for catalog/RFC bodies. The
|
||||
# corresponding POST /graduate is gated to RFC owners/arbiters and
|
||||
# app admins/owners via `_can_graduate`; the read SSE shares that
|
||||
# operator-visible surface, so it requires at least an
|
||||
# authenticated viewer. We keep the floor at require_user (not
|
||||
# require_contributor) so a write-muted operator can still observe
|
||||
# the progress of a graduation they kicked off before being muted.
|
||||
# The progress SSE surfaces admin-internal step detail (PR number)
|
||||
# that isn't part of the anonymous-read contract. POST /graduate is
|
||||
# gated to RFC owners/arbiters and app admins/owners; the read SSE
|
||||
# shares that operator-visible surface and requires an authenticated
|
||||
# viewer. We keep the floor at require_user (not require_contributor)
|
||||
# so a write-muted operator can still observe a graduation they
|
||||
# kicked off before being muted.
|
||||
auth.require_user(request)
|
||||
state = _get_active(slug)
|
||||
if state is None:
|
||||
raise HTTPException(404, "No graduation in flight for this slug")
|
||||
|
||||
async def event_stream():
|
||||
# Emit the current snapshot first so a late subscriber sees
|
||||
# the steps already completed.
|
||||
yield _sse_event("snapshot", state.to_payload())
|
||||
if state.finished:
|
||||
yield _sse_event("done", state.to_payload())
|
||||
@@ -555,22 +435,16 @@ def make_router(
|
||||
# §13.1: POST /api/rfcs/<slug>/claim
|
||||
# Opens a meta-repo PR adding the actor's gitea_login to the entry's
|
||||
# owners list. Anyone signed in may claim — the merge is gated to
|
||||
# owners/admins per §13.1 (which collapses to admins for unclaimed
|
||||
# entries since `owners` is empty).
|
||||
# owners/admins per §13.1.
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/claim")
|
||||
async def claim_ownership(slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_super_draft(slug)
|
||||
# Refuse if the actor is already in owners — no-op claim.
|
||||
existing_owners = json.loads(rfc["owners_json"] or "[]")
|
||||
if viewer.gitea_login in existing_owners:
|
||||
return {"ok": True, "noop": True}
|
||||
# Refuse if a claim PR for this actor is already open. The branch
|
||||
# name `claim/<slug>` collides per actor implicitly since Gitea
|
||||
# refuses duplicate branch creation; we surface a clean 409 here
|
||||
# so the client doesn't see a 502.
|
||||
already = db.conn().execute(
|
||||
"""
|
||||
SELECT pr_number FROM cached_prs
|
||||
@@ -581,8 +455,6 @@ def make_router(
|
||||
if already:
|
||||
raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}")
|
||||
|
||||
# Compose the new entry contents — owners list with the claimant
|
||||
# appended.
|
||||
fetched = await gitea.read_file(
|
||||
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
|
||||
)
|
||||
@@ -626,7 +498,7 @@ def make_router(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestrator
|
||||
# Orchestrator — the §13.3 in-place flip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -637,57 +509,21 @@ async def _orchestrate(
|
||||
bot: Bot,
|
||||
actor: Actor,
|
||||
state: GraduationState,
|
||||
super_draft_body: str,
|
||||
super_draft_title: str,
|
||||
super_draft_tags: list[str],
|
||||
graduated_contents: str,
|
||||
meta_file_sha: str,
|
||||
) -> None:
|
||||
"""Run §13.3 step by step. Each step:
|
||||
"""Open the flip PR, then merge it. Two steps, no transaction:
|
||||
|
||||
- marks itself `running` and pushes an event
|
||||
- calls the bot method (which writes to Gitea + audit log)
|
||||
- marks itself `done` (or `failed`) and pushes another event
|
||||
- open_pr fails → nothing was created; the entry stays a super-draft.
|
||||
- merge_pr fails → close the open PR and delete its branch (the only
|
||||
artifact a mid-flip failure can leave on the meta repo), then the
|
||||
entry stays a super-draft.
|
||||
|
||||
On failure at step N, every later step is marked `not-reached` and
|
||||
`_rollback` runs undoes in reverse from N-1 to 1.
|
||||
There is no rollback of a *merged* flip — once the meta-repo merge has
|
||||
landed, the path forward is §3's `withdraw` (§13.5).
|
||||
"""
|
||||
try:
|
||||
# ----- Step 1: create per-RFC repo -----
|
||||
await _start(state, "create_repo", f"Creating `{state.repo_full}`…")
|
||||
try:
|
||||
await bot.create_rfc_repo_for_graduation(
|
||||
actor, org=config.gitea_org, repo_name=state.repo_name,
|
||||
slug=state.slug, title=super_draft_title,
|
||||
)
|
||||
except GiteaError as e:
|
||||
await _fail(state, "create_repo", f"Gitea: {e.detail}")
|
||||
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
|
||||
state=state, failed_at="create_repo")
|
||||
return
|
||||
await _done(state, "create_repo", state.repo_full)
|
||||
|
||||
# ----- Step 2: seed RFC.md, README.md, .rfc/metadata.yaml -----
|
||||
await _start(state, "seed_files", "Writing initial commit on main…")
|
||||
try:
|
||||
await bot.seed_graduated_rfc(
|
||||
actor,
|
||||
org=config.gitea_org, repo_name=state.repo_name,
|
||||
slug=state.slug, title=super_draft_title,
|
||||
rfc_body=super_draft_body, rfc_id=state.rfc_id,
|
||||
meta_full=config.meta_repo_full,
|
||||
meta_path=f"rfcs/{state.slug}.md",
|
||||
owners=state.owners, arbiters=state.arbiters,
|
||||
tags=super_draft_tags,
|
||||
)
|
||||
except GiteaError as e:
|
||||
await _fail(state, "seed_files", f"Gitea: {e.detail}")
|
||||
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
|
||||
state=state, failed_at="seed_files")
|
||||
return
|
||||
await _done(state, "seed_files", "RFC.md, README.md, .rfc/metadata.yaml")
|
||||
|
||||
# ----- Step 3: open graduation PR -----
|
||||
# ----- Step 1: open the graduation PR (flip frontmatter) -----
|
||||
await _start(state, "open_pr", "Opening graduation PR…")
|
||||
try:
|
||||
pr = await bot.open_graduation_pr(
|
||||
@@ -696,19 +532,18 @@ async def _orchestrate(
|
||||
slug=state.slug,
|
||||
new_file_contents=graduated_contents,
|
||||
prior_sha=meta_file_sha,
|
||||
rfc_id=state.rfc_id, repo_full=state.repo_full,
|
||||
rfc_id=state.rfc_id,
|
||||
owners=state.owners,
|
||||
)
|
||||
except GiteaError as e:
|
||||
await _fail(state, "open_pr", f"Gitea: {e.detail}")
|
||||
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
|
||||
state=state, failed_at="open_pr")
|
||||
await _finish_failed(state, failed_at="open_pr", on_behalf_of=actor.gitea_login)
|
||||
return
|
||||
state.new_pr_number = pr["number"]
|
||||
state.graduation_branch = pr["head"]["ref"]
|
||||
await _done(state, "open_pr", f"PR #{state.new_pr_number}")
|
||||
|
||||
# ----- Step 4: merge the graduation PR -----
|
||||
# ----- Step 2: merge the graduation PR -----
|
||||
await _start(state, "merge_pr", f"Merging PR #{state.new_pr_number}…")
|
||||
try:
|
||||
await bot.merge_graduation_pr(
|
||||
@@ -720,35 +555,28 @@ async def _orchestrate(
|
||||
)
|
||||
except GiteaError as e:
|
||||
await _fail(state, "merge_pr", f"Gitea: {e.detail}")
|
||||
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
|
||||
state=state, failed_at="merge_pr")
|
||||
await _cleanup_unmerged(config=config, bot=bot, actor=actor, state=state)
|
||||
await _finish_failed(state, failed_at="merge_pr", on_behalf_of=actor.gitea_login)
|
||||
return
|
||||
await _done(state, "merge_pr", f"PR #{state.new_pr_number} merged")
|
||||
|
||||
# ----- Step 5: refresh the cache so the catalog flips immediately.
|
||||
# Per §13.3 step 5 the webhook flow is the steady-state path, but
|
||||
# we refresh inline so the dialog can transition to "graduation
|
||||
# complete" with the catalog row already showing `active`. A
|
||||
# cache-refresh failure does not unwind Git state — the
|
||||
# reconciler will catch up per §4.1.
|
||||
await _start(state, "refresh_cache", "Refreshing catalog and views…")
|
||||
# Refresh the cache so the catalog flips immediately. The webhook
|
||||
# flow is the steady-state path (§13.3); we refresh inline so the
|
||||
# dialog can transition to "graduation complete" with the catalog
|
||||
# row already showing `active`. A refresh failure does not unwind
|
||||
# the merge — the reconciler catches up per §4.1.
|
||||
try:
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
await cache.refresh_meta_branches(config, gitea)
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
await cache.refresh_rfc_repo(config, gitea, state.slug)
|
||||
except Exception as e:
|
||||
log.warning("graduate refresh_cache failed for %s: %s", state.slug, e)
|
||||
await _done(state, "refresh_cache", f"Cache will catch up via reconciler ({e})")
|
||||
else:
|
||||
await _done(state, "refresh_cache", "Catalog and main view updated")
|
||||
log.warning("graduate cache refresh failed for %s: %s", state.slug, e)
|
||||
|
||||
# Terminal success row in the audit log.
|
||||
_audit(
|
||||
None, actor.gitea_login, "graduate_complete",
|
||||
rfc_slug=state.slug,
|
||||
details={
|
||||
"rfc_id": state.rfc_id, "repo": state.repo_full,
|
||||
"rfc_id": state.rfc_id,
|
||||
"owners": state.owners, "pr_number": state.new_pr_number,
|
||||
},
|
||||
)
|
||||
@@ -757,109 +585,38 @@ async def _orchestrate(
|
||||
await state.queue.put({"event": "completed", "payload": state.to_payload()})
|
||||
except Exception as e:
|
||||
log.exception("graduate: unexpected error for %s", state.slug)
|
||||
# Best-effort: mark the in-flight step failed, then roll back.
|
||||
running = next((s for s in state.steps if s.status == "running"), None)
|
||||
if running is not None:
|
||||
await _fail(state, running.key, f"unexpected: {e}")
|
||||
await _rollback(
|
||||
config=config, gitea=gitea, bot=bot, actor=actor,
|
||||
state=state, failed_at=running.key if running else "unknown",
|
||||
await _finish_failed(
|
||||
state, failed_at=running.key if running else "unknown",
|
||||
on_behalf_of=actor.gitea_login,
|
||||
)
|
||||
finally:
|
||||
# Push the sentinel so any open SSE handler returns.
|
||||
await state.queue.put(None)
|
||||
|
||||
|
||||
async def _rollback(
|
||||
*,
|
||||
config: Config, gitea: Gitea, bot: Bot, actor: Actor,
|
||||
state: GraduationState, failed_at: str,
|
||||
async def _cleanup_unmerged(
|
||||
*, config: Config, bot: Bot, actor: Actor, state: GraduationState,
|
||||
) -> None:
|
||||
"""Run undoes in reverse order from the last completed step. Each
|
||||
undo emits its own rollback-step event so the dialog can render the
|
||||
cleanup as a visible step appended to the stack per §13.3."""
|
||||
state.rollback_started = True
|
||||
# Mark every step after the failed one as not-reached so the rendered
|
||||
# stack is honest about what didn't run.
|
||||
seen_failure = False
|
||||
for s in state.steps:
|
||||
if s.status == "failed":
|
||||
seen_failure = True
|
||||
continue
|
||||
if seen_failure and s.status == "pending":
|
||||
s.status = "not-reached"
|
||||
|
||||
# Walk completed steps in reverse and run their inverses.
|
||||
for s in reversed(state.steps):
|
||||
if s.status != "done":
|
||||
continue
|
||||
undo = _UNDO_BY_STEP.get(s.key)
|
||||
if undo is None:
|
||||
continue
|
||||
rb = StepState(key=f"undo:{s.key}", label=f"Undo: {s.label}",
|
||||
status="running", detail="")
|
||||
state.rollback_steps.append(rb)
|
||||
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
|
||||
try:
|
||||
detail = await undo(
|
||||
config=config, gitea=gitea, bot=bot, actor=actor, state=state,
|
||||
)
|
||||
except Exception as e:
|
||||
rb.status = "failed"
|
||||
rb.detail = f"{e}"
|
||||
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
|
||||
continue
|
||||
rb.status = "done"
|
||||
rb.detail = detail or ""
|
||||
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
|
||||
|
||||
_audit(
|
||||
None, actor.gitea_login, "graduate_rollback",
|
||||
rfc_slug=state.slug,
|
||||
details={
|
||||
"failed_at": failed_at,
|
||||
"error": state.error,
|
||||
"rfc_id": state.rfc_id,
|
||||
"repo": state.repo_full,
|
||||
"undone": [s.key for s in state.rollback_steps if s.status == "done"],
|
||||
},
|
||||
)
|
||||
state.finished = True
|
||||
state.succeeded = False
|
||||
await state.queue.put({"event": "rolled_back", "payload": state.to_payload()})
|
||||
|
||||
|
||||
async def _undo_create_repo(*, config, gitea, bot, actor, state) -> str:
|
||||
await bot.delete_rfc_repo(
|
||||
actor, org=config.gitea_org, repo_name=state.repo_name,
|
||||
slug=state.slug, reason="graduation rollback",
|
||||
)
|
||||
return f"Deleted `{state.repo_full}`"
|
||||
|
||||
|
||||
async def _undo_seed_files(*, config, gitea, bot, actor, state) -> str:
|
||||
# The seed commits live inside the per-RFC repo created in step 1;
|
||||
# deleting the repo (step 1's undo) reclaims them at the same time.
|
||||
# We surface a separate rollback step here so the rendered stack
|
||||
# mirrors the forward steps, but the work is folded into _undo_create_repo.
|
||||
return "Folded into repo deletion"
|
||||
|
||||
|
||||
async def _undo_open_pr(*, config, gitea, bot, actor, state) -> str:
|
||||
"""A merge failure leaves the flip PR open on its `graduate-<slug>-<hex>`
|
||||
branch. Close the PR and delete the branch so failed attempts don't
|
||||
accumulate on the meta repo. Best-effort — failures here are logged,
|
||||
not surfaced as a separate step (the entry already stays a super-draft).
|
||||
"""
|
||||
if state.new_pr_number is None:
|
||||
return "No PR opened"
|
||||
await bot.close_graduation_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=config.meta_repo,
|
||||
pr_number=state.new_pr_number,
|
||||
head_branch=state.graduation_branch or "",
|
||||
slug=state.slug, reason="graduation rollback",
|
||||
)
|
||||
# Per the §19.2 "graduation rollback's branch cleanup" candidate
|
||||
# that Slice 8 settles: delete the dash-suffixed branch on rollback
|
||||
# so failed-graduation branches don't accumulate on the meta repo.
|
||||
# The §12 hygiene sweep would catch this eventually, but closing
|
||||
# the loop here removes the chance of pile-up across retries.
|
||||
return
|
||||
try:
|
||||
await bot.close_graduation_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=config.meta_repo,
|
||||
pr_number=state.new_pr_number,
|
||||
head_branch=state.graduation_branch or "",
|
||||
slug=state.slug, reason="graduation merge failed",
|
||||
)
|
||||
except Exception:
|
||||
log.exception("graduate cleanup: close PR #%s failed", state.new_pr_number)
|
||||
branch_name = state.graduation_branch or ""
|
||||
if branch_name:
|
||||
try:
|
||||
@@ -870,24 +627,35 @@ async def _undo_open_pr(*, config, gitea, bot, actor, state) -> str:
|
||||
branch=branch_name,
|
||||
slug=state.slug,
|
||||
action_kind="delete_post_merge_branch",
|
||||
reason="graduation rollback",
|
||||
reason="graduation merge failed",
|
||||
)
|
||||
except Exception:
|
||||
log.exception("rollback: delete_branch failed for %s", branch_name)
|
||||
return f"Closed PR #{state.new_pr_number}"
|
||||
log.exception("graduate cleanup: delete_branch %s failed", branch_name)
|
||||
|
||||
|
||||
# merge_pr's undo is intentionally absent — once the meta-repo merge has
|
||||
# landed, graduation is irreversible per §13.5. If we ever reach a merged
|
||||
# state and a later step fails (which can't happen — refresh_cache failures
|
||||
# fold into success), there is no clean undo path; the user transitions
|
||||
# via §3's `withdraw` instead.
|
||||
|
||||
_UNDO_BY_STEP = {
|
||||
"create_repo": _undo_create_repo,
|
||||
"seed_files": _undo_seed_files,
|
||||
"open_pr": _undo_open_pr,
|
||||
}
|
||||
async def _finish_failed(state: GraduationState, *, failed_at: str, on_behalf_of: str) -> None:
|
||||
"""Mark any step after the failure as not-reached, write the audit
|
||||
row, and emit the terminal failed event."""
|
||||
seen_failure = False
|
||||
for s in state.steps:
|
||||
if s.status == "failed":
|
||||
seen_failure = True
|
||||
continue
|
||||
if seen_failure and s.status == "pending":
|
||||
s.status = "not-reached"
|
||||
_audit(
|
||||
None, on_behalf_of, "graduate_failed",
|
||||
rfc_slug=state.slug,
|
||||
details={
|
||||
"failed_at": failed_at,
|
||||
"error": state.error,
|
||||
"rfc_id": state.rfc_id,
|
||||
"pr_number": state.new_pr_number,
|
||||
},
|
||||
)
|
||||
state.finished = True
|
||||
state.succeeded = False
|
||||
await state.queue.put({"event": "failed", "payload": state.to_payload()})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -907,7 +675,7 @@ def _can_graduate(rfc, viewer) -> bool:
|
||||
|
||||
def _audit(
|
||||
actor_user_id: int | None,
|
||||
on_behalf_of: str,
|
||||
on_behalf_of: str | None,
|
||||
action_kind: str,
|
||||
*,
|
||||
rfc_slug: str | None = None,
|
||||
@@ -918,7 +686,7 @@ def _audit(
|
||||
"""Direct audit-log write for graduation lifecycle events that don't
|
||||
correspond to a single Gitea write. The per-step Gitea writes log
|
||||
themselves via the bot's `_log`; this is for the bracketing
|
||||
`graduate_start` / `graduate_complete` / `graduate_rollback` rows."""
|
||||
`graduate_start` / `graduate_complete` / `graduate_failed` rows."""
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO actions
|
||||
@@ -935,8 +703,7 @@ def _audit(
|
||||
json.dumps(details) if details else None,
|
||||
),
|
||||
)
|
||||
# §15 chokepoint per Slice 6: the bracket rows (graduate_start,
|
||||
# graduate_complete) drive their own notifications per §15.1.
|
||||
# §15 chokepoint: the bracket rows drive their own notifications.
|
||||
from . import notify
|
||||
notify.fan_out_from_action(
|
||||
actor_user_id=actor_user_id,
|
||||
|
||||
@@ -133,69 +133,15 @@ def make_router() -> APIRouter:
|
||||
"Only the RFC's owner can invite collaborators",
|
||||
)
|
||||
|
||||
invitee_email = body.invitee_email.strip()
|
||||
role_in_rfc = body.role_in_rfc
|
||||
|
||||
# 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,
|
||||
return 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=body.invitee_email,
|
||||
role_in_rfc=body.role_in_rfc,
|
||||
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
|
||||
# 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"
|
||||
|
||||
|
||||
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:
|
||||
"""A 256-bit URL-safe token. The token shape is opaque to the
|
||||
consumer; the email link encodes it as a query param."""
|
||||
|
||||
@@ -557,7 +557,26 @@ def make_router(config: Config) -> APIRouter:
|
||||
# stays unauthenticated for dev (the v1 contract).
|
||||
import os as _os
|
||||
expected = _os.environ.get("WEBHOOK_EMAIL_BOUNCE_SECRET", "").strip()
|
||||
if expected:
|
||||
# v0.25.0 (audit 0026 M5): fail closed. An unset secret used to
|
||||
# leave this endpoint fully unauthenticated — anyone could suppress
|
||||
# any user's mail by POSTing their address (email_opt_out_all flip
|
||||
# below). Now an unset secret DISABLES the endpoint (503) instead
|
||||
# of opening it. A dev that genuinely wants it open opts in
|
||||
# explicitly with RFC_APP_INSECURE_BOUNCE_WEBHOOK=1, mirroring the
|
||||
# RFC_APP_INSECURE_WEBHOOKS dev-bypass on the Gitea hook.
|
||||
if not expected:
|
||||
if _os.environ.get("RFC_APP_INSECURE_BOUNCE_WEBHOOK", "").strip() == "1":
|
||||
log.warning(
|
||||
"email-bounce webhook running UNAUTHENTICATED "
|
||||
"(RFC_APP_INSECURE_BOUNCE_WEBHOOK=1) — never set this in production"
|
||||
)
|
||||
else:
|
||||
log.error(
|
||||
"email-bounce webhook refused: WEBHOOK_EMAIL_BOUNCE_SECRET is unset "
|
||||
"(set the secret to enable, or RFC_APP_INSECURE_BOUNCE_WEBHOOK=1 for dev)"
|
||||
)
|
||||
raise HTTPException(503, "Bounce webhook not configured")
|
||||
else:
|
||||
received = request.headers.get("X-Webhook-Secret", "")
|
||||
import hmac as _hmac
|
||||
if not received or not _hmac.compare_digest(expected, received):
|
||||
|
||||
+67
-12
@@ -23,7 +23,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver
|
||||
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver, rfc_links
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
@@ -42,6 +42,11 @@ RFC_FILE_PATH = "RFC.md"
|
||||
class OpenPRBody(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=240)
|
||||
description: str = Field(max_length=8000)
|
||||
# Roadmap #26: optional "What will you be using this change for?" —
|
||||
# the concrete ground-truth use case sibling to the required
|
||||
# "why is this change needed" (the `description`). Optional, generous
|
||||
# cap matching the description bound.
|
||||
proposed_use_case: str | None = Field(default=None, max_length=8000)
|
||||
|
||||
|
||||
class PRDescriptionBody(BaseModel):
|
||||
@@ -173,6 +178,26 @@ def make_router(
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
|
||||
await _refresh_after_pr_write(rfc)
|
||||
|
||||
# Roadmap #26: persist the optional use case to the canonical,
|
||||
# reconcile-proof side table keyed by the PR number. Blank/omitted
|
||||
# writes no row (absence == "left blank"). The mirror onto
|
||||
# cached_prs keeps the cache column in parity.
|
||||
use_case = (body.proposed_use_case or "").strip()
|
||||
if use_case:
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
|
||||
VALUES ('pr', ?, ?, ?)
|
||||
ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
||||
""",
|
||||
(slug, pr["number"], use_case),
|
||||
)
|
||||
db.conn().execute(
|
||||
"UPDATE cached_prs SET proposed_use_case = ? WHERE rfc_slug = ? AND pr_number = ?",
|
||||
(use_case, slug, pr["number"]),
|
||||
)
|
||||
|
||||
return {"pr_number": pr["number"], "slug": slug, "branch": branch}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
@@ -188,6 +213,12 @@ def make_router(
|
||||
path = _file_path_for(rfc)
|
||||
head_branch = pr_row["head_branch"]
|
||||
|
||||
# Roadmap #28 Part 1: build the RFC auto-link index once for this
|
||||
# PR view (read-time enrichment against the live accepted-RFC
|
||||
# corpus; see rfc_links.py). exclude_slug suppresses self-links to
|
||||
# this RFC inside its own PR.
|
||||
link_index = rfc_links.build_index(db.conn(), exclude_slug=slug)
|
||||
|
||||
# §11.3: PRs are always public; no visibility check.
|
||||
main_fetched = await gitea.read_file(owner, repo, path, ref="main")
|
||||
main_body = _extract_body(rfc, (main_fetched or ("", ""))[0])
|
||||
@@ -234,6 +265,13 @@ def make_router(
|
||||
for r in msg_rows:
|
||||
messages_by_thread.setdefault(r["thread_id"], []).append(_serialize_message(r))
|
||||
|
||||
# Roadmap #28 Part 1: enrich every comment with RFC auto-link
|
||||
# segments (read-time; see rfc_links.py). The description is
|
||||
# enriched alongside it in the return dict below.
|
||||
for _msgs in messages_by_thread.values():
|
||||
for _m in _msgs:
|
||||
_m["text_segments"] = link_index.segment(_m["text"])
|
||||
|
||||
# Per-user seen cursor per §10.3. Anonymous viewers get no
|
||||
# cursor — they always see "everything new" but cannot advance
|
||||
# the cursor (no row to write to).
|
||||
@@ -300,6 +338,8 @@ def make_router(
|
||||
"pr_number": pr_number,
|
||||
"title": pr_row["title"],
|
||||
"description": pr_row["description"],
|
||||
"description_segments": link_index.segment(pr_row["description"]),
|
||||
"proposed_use_case": _pr_use_case(pr_number),
|
||||
"state": pr_row["state"],
|
||||
"opened_by": pr_row["opened_by"],
|
||||
"opened_at": pr_row["opened_at"],
|
||||
@@ -563,7 +603,7 @@ def make_router(
|
||||
repo=repo,
|
||||
slug=slug,
|
||||
file_path=_file_path_for(rfc),
|
||||
is_super_draft=_is_super_draft(rfc),
|
||||
is_super_draft=_is_meta_resident(rfc),
|
||||
original_branch=original_branch,
|
||||
resolution_branch=resolution_branch,
|
||||
)
|
||||
@@ -631,32 +671,36 @@ def make_router(
|
||||
"""Used by the §10 PR-flow read and write paths. Per §17's routing-
|
||||
collapse rule, a super-draft RFC also routes here — its body-edit
|
||||
PRs are meta-repo PRs with pr_kind='meta_body_edit', but the API
|
||||
surface is identical."""
|
||||
surface is identical. Under the meta-only topology (§1) an active
|
||||
RFC is meta-resident too (repo is null) — that is normal, not an
|
||||
error, so there is no per-RFC-repo precondition."""
|
||||
row = _require_rfc(slug)
|
||||
if row["state"] not in ("active", "super-draft"):
|
||||
raise HTTPException(409, f"RFC is {row['state']}")
|
||||
if row["state"] == "active" and not row["repo"]:
|
||||
raise HTTPException(409, "RFC has no repo")
|
||||
return row
|
||||
|
||||
def _is_super_draft(rfc) -> bool:
|
||||
return rfc["state"] == "super-draft"
|
||||
def _is_meta_resident(rfc) -> bool:
|
||||
"""Meta-only topology (§1): an entry lives in the meta repo's
|
||||
`rfcs/<slug>.md` (super-draft or active-in-place) unless it carries
|
||||
a legacy per-RFC `repo:` — which nothing does after the RFC-0001
|
||||
fold-back (§13.6). Drives the body/path/repo dispatch below."""
|
||||
return not rfc["repo"]
|
||||
|
||||
def _owner_repo(rfc) -> tuple[str, str]:
|
||||
if _is_super_draft(rfc):
|
||||
if _is_meta_resident(rfc):
|
||||
return config.gitea_org, config.meta_repo
|
||||
owner, repo = rfc["repo"].split("/", 1)
|
||||
return owner, repo
|
||||
|
||||
def _file_path_for(rfc) -> str:
|
||||
if _is_super_draft(rfc):
|
||||
if _is_meta_resident(rfc):
|
||||
return f"rfcs/{rfc['slug']}.md"
|
||||
return RFC_FILE_PATH
|
||||
|
||||
def _extract_body(rfc, file_contents: str) -> str:
|
||||
"""For super-draft entries the file on disk is the full
|
||||
"""For meta-resident entries the file on disk is the full
|
||||
frontmatter+body envelope; the editable body is entry.body."""
|
||||
if not _is_super_draft(rfc):
|
||||
if not _is_meta_resident(rfc):
|
||||
return file_contents
|
||||
try:
|
||||
entry = entry_mod.parse(file_contents)
|
||||
@@ -720,7 +764,7 @@ def make_router(
|
||||
return row["original_pr_number"] if row else None
|
||||
|
||||
async def _refresh_after_pr_write(rfc) -> None:
|
||||
if _is_super_draft(rfc):
|
||||
if _is_meta_resident(rfc):
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
await cache.refresh_meta_branches(config, gitea)
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
@@ -762,6 +806,17 @@ def _can_edit_pr_text(rfc, pr_row, viewer) -> bool:
|
||||
return _can_withdraw(rfc, pr_row, viewer)
|
||||
|
||||
|
||||
def _pr_use_case(pr_number: int) -> str | None:
|
||||
"""Roadmap #26: the optional propose-PR use case from the canonical
|
||||
side table, or None when the change was opened without one ("left
|
||||
blank")."""
|
||||
row = db.conn().execute(
|
||||
"SELECT use_case FROM proposed_use_cases WHERE scope = 'pr' AND pr_number = ?",
|
||||
(pr_number,),
|
||||
).fetchone()
|
||||
return row["use_case"] if row else None
|
||||
|
||||
|
||||
def _pr_capabilities(rfc, pr_row, viewer) -> dict:
|
||||
return {
|
||||
"can_merge": _can_merge(rfc, viewer) and pr_row["state"] == "open",
|
||||
|
||||
+13
-136
@@ -695,111 +695,7 @@ class Bot:
|
||||
)
|
||||
return sha
|
||||
|
||||
# ----- §13 graduation: per-step primitives and rollback inverses -----
|
||||
|
||||
async def create_rfc_repo_for_graduation(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
repo_name: str,
|
||||
slug: str,
|
||||
title: str,
|
||||
) -> dict:
|
||||
"""§13.3 step 1: create the per-RFC repo.
|
||||
|
||||
Empty repo (no auto-init) — `seed_graduated_rfc` writes the first
|
||||
commit on `main`. Returns the Gitea repo payload."""
|
||||
repo = await self._gitea.create_org_repo(
|
||||
org, repo_name, description=f"RFC: {title}"
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"graduate_repo_create",
|
||||
rfc_slug=slug,
|
||||
details={"repo": f"{org}/{repo_name}", "title": title},
|
||||
)
|
||||
return repo
|
||||
|
||||
async def seed_graduated_rfc(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
repo_name: str,
|
||||
slug: str,
|
||||
title: str,
|
||||
rfc_body: str,
|
||||
rfc_id: str,
|
||||
meta_full: str,
|
||||
meta_path: str,
|
||||
owners: list[str],
|
||||
arbiters: list[str],
|
||||
tags: list[str],
|
||||
) -> str:
|
||||
"""§13.3 step 2: seed RFC.md, README.md, .rfc/metadata.yaml on the
|
||||
new repo's `main`. Three create_file calls; one audit row.
|
||||
|
||||
Returns the final commit sha on main.
|
||||
"""
|
||||
import yaml as _yaml
|
||||
|
||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
||||
# 2a) RFC.md — the document. The super-draft's body is migrated
|
||||
# verbatim per §13.3; if the body is empty we seed a minimal
|
||||
# placeholder so the editor has something to render on first open.
|
||||
body = rfc_body.strip() + "\n" if rfc_body.strip() else (
|
||||
f"# {title}\n\n*RFC.md to be filled in — the super-draft graduated with an empty body.*\n"
|
||||
)
|
||||
rfc_msg = _stamp_single(f"Seed RFC.md from super-draft {slug}", actor)
|
||||
rfc_result = await self._gitea.create_file(
|
||||
org, repo_name, "RFC.md",
|
||||
content=body, message=rfc_msg, branch="main",
|
||||
author_name=actor.display_name, author_email=ae,
|
||||
)
|
||||
# 2b) README.md — header pointing back at the meta-repo entry.
|
||||
readme = (
|
||||
f"# {rfc_id} — {title}\n\n"
|
||||
f"This repository carries the canonical text of {rfc_id}.\n"
|
||||
f"The meta-repo entry is `{meta_path}` in `{meta_full}`.\n\n"
|
||||
f"The RFC body is in `RFC.md`. Contributions go through the\n"
|
||||
f"app's §8 RFC view — open a branch, propose changes, land a PR.\n"
|
||||
)
|
||||
readme_msg = _stamp_single(f"Seed README.md for {rfc_id}", actor)
|
||||
await self._gitea.create_file(
|
||||
org, repo_name, "README.md",
|
||||
content=readme, message=readme_msg, branch="main",
|
||||
author_name=actor.display_name, author_email=ae,
|
||||
)
|
||||
# 2c) .rfc/metadata.yaml — mirror of meta-repo frontmatter for
|
||||
# future tooling (linting, automation, CI lookups).
|
||||
meta_yaml = _yaml.safe_dump(
|
||||
{
|
||||
"slug": slug, "title": title, "id": rfc_id,
|
||||
"owners": owners, "arbiters": arbiters, "tags": list(tags),
|
||||
},
|
||||
sort_keys=False,
|
||||
)
|
||||
meta_msg = _stamp_single(f"Seed .rfc/metadata.yaml for {rfc_id}", actor)
|
||||
meta_result = await self._gitea.create_file(
|
||||
org, repo_name, ".rfc/metadata.yaml",
|
||||
content=meta_yaml, message=meta_msg, branch="main",
|
||||
author_name=actor.display_name, author_email=ae,
|
||||
)
|
||||
last_sha = (
|
||||
meta_result.get("commit", {}).get("sha")
|
||||
or rfc_result.get("commit", {}).get("sha")
|
||||
or ""
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"graduate_repo_seed",
|
||||
rfc_slug=slug,
|
||||
branch_name="main",
|
||||
bot_commit_sha=last_sha,
|
||||
details={"repo": f"{org}/{repo_name}", "rfc_id": rfc_id},
|
||||
)
|
||||
return last_sha
|
||||
# ----- §13 graduation (meta-only): open + merge the flip PR -----
|
||||
|
||||
async def open_graduation_pr(
|
||||
self,
|
||||
@@ -811,13 +707,14 @@ class Bot:
|
||||
new_file_contents: str,
|
||||
prior_sha: str,
|
||||
rfc_id: str,
|
||||
repo_full: str,
|
||||
owners: list[str],
|
||||
) -> dict:
|
||||
"""§13.3 step 3: open a PR against the meta repo that strips the
|
||||
super-draft body and fills graduation frontmatter fields. Branch
|
||||
name uses the `graduate-<slug>-<6hex>` shape — dash-separated like
|
||||
the other meta-repo branches per the §19.2 path-routing candidate.
|
||||
"""§13.3 (meta-only): open a PR against the meta repo that flips the
|
||||
entry's frontmatter to `state: active` with the integer `id` and
|
||||
graduation stamps — **keeping the body unchanged** (§1 meta-only
|
||||
topology; no repo is created and no body is stripped). Branch name
|
||||
uses the `graduate-<slug>-<6hex>` shape — dash-separated like the
|
||||
other meta-repo branches per the §19.2 path-routing candidate.
|
||||
"""
|
||||
import secrets
|
||||
|
||||
@@ -844,11 +741,11 @@ class Bot:
|
||||
pr_body_text = (
|
||||
f"Graduates super-draft `{slug}` to active.\n\n"
|
||||
f"- ID: `{rfc_id}`\n"
|
||||
f"- Repo: `{repo_full}`\n"
|
||||
f"- Owners: {owners_str}\n\n"
|
||||
f"The meta-repo entry becomes frontmatter-only; the canonical body\n"
|
||||
f"moves to `RFC.md` in the new repo. The graduation sequence is\n"
|
||||
f"transactional per §13.3."
|
||||
f"This is an in-place state flip per the meta-only topology\n"
|
||||
f"(SPEC §1, §13.3): the entry `rfcs/{slug}.md` keeps its body and\n"
|
||||
f"stays in the meta repo. Only the frontmatter changes — `state`,\n"
|
||||
f"`id`, and the graduation stamps."
|
||||
)
|
||||
_subject, pr_body = _stamp("", pr_body_text, actor)
|
||||
pr = await self._gitea.create_pull(
|
||||
@@ -862,7 +759,7 @@ class Bot:
|
||||
branch_name=branch,
|
||||
pr_number=pr["number"],
|
||||
bot_commit_sha=commit_sha,
|
||||
details={"pr_title": pr_title, "rfc_id": rfc_id, "repo": repo_full},
|
||||
details={"pr_title": pr_title, "rfc_id": rfc_id},
|
||||
)
|
||||
return pr
|
||||
|
||||
@@ -912,27 +809,7 @@ class Bot:
|
||||
details={"rfc_id": rfc_id},
|
||||
)
|
||||
|
||||
# ----- §13.3 rollback inverses -----
|
||||
|
||||
async def delete_rfc_repo(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
repo_name: str,
|
||||
slug: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""Undo of `create_rfc_repo_for_graduation`. Records `graduate_repo_delete`
|
||||
in the audit log with the rollback reason so the §13.3 stack's
|
||||
rendered failure surface can be reconstructed from `actions`."""
|
||||
await self._gitea.delete_repo(org, repo_name)
|
||||
_log(
|
||||
actor,
|
||||
"graduate_repo_delete",
|
||||
rfc_slug=slug,
|
||||
details={"repo": f"{org}/{repo_name}", "reason": reason},
|
||||
)
|
||||
# ----- §13.3 (meta-only): cleanup of an unmerged flip PR -----
|
||||
|
||||
async def close_graduation_pr(
|
||||
self,
|
||||
|
||||
+42
-4
@@ -219,6 +219,19 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
|
||||
open_pulls, closed_pulls = [], []
|
||||
for pull in open_pulls + closed_pulls:
|
||||
head_branch = pull.get("head", {}).get("ref", "")
|
||||
# Same deleted-branch recovery as refresh_meta_pulls: a merged-and-
|
||||
# deleted PR's `head.ref` collapses to `refs/pull/<N>/head`. Here
|
||||
# the slug is known (param), so state still updates correctly and
|
||||
# no ghost forms — but blindly storing the sentinel would clobber
|
||||
# the real branch name api_prs.py relies on as a fallback ref when
|
||||
# the merge commit is gone. Recover it from the stored row.
|
||||
if not head_branch or head_branch.startswith("refs/pull/"):
|
||||
prior = db.conn().execute(
|
||||
"SELECT head_branch FROM cached_prs WHERE repo = ? AND pr_number = ?",
|
||||
(repo_full, pull["number"]),
|
||||
).fetchone()
|
||||
if prior and prior["head_branch"]:
|
||||
head_branch = prior["head_branch"]
|
||||
state = _state_from_pull(pull)
|
||||
gitea_opener = (pull.get("user") or {}).get("login") or ""
|
||||
opened_by = _resolve_actor(
|
||||
@@ -329,9 +342,13 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
|
||||
if not slug:
|
||||
continue
|
||||
rfc = db.conn().execute(
|
||||
"SELECT state FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
"SELECT state, repo FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
if not rfc or rfc["state"] != "super-draft":
|
||||
# Meta-only topology (§1): edit branches live on the meta repo for
|
||||
# every meta-resident entry — super-drafts and active RFCs alike
|
||||
# (active RFCs are graduated in place and keep editing here, §13).
|
||||
# A legacy per-RFC repo (repo set) is the only thing excluded.
|
||||
if not rfc or rfc["repo"] or rfc["state"] not in ("super-draft", "active"):
|
||||
continue
|
||||
edit_keys_seen.add((slug, name))
|
||||
db.conn().execute(
|
||||
@@ -352,7 +369,8 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
|
||||
# diverges from this single point.
|
||||
if meta_main_sha:
|
||||
super_drafts = db.conn().execute(
|
||||
"SELECT slug FROM cached_rfcs WHERE state = 'super-draft'"
|
||||
"SELECT slug FROM cached_rfcs "
|
||||
"WHERE repo IS NULL AND state IN ('super-draft', 'active')"
|
||||
).fetchall()
|
||||
for r in super_drafts:
|
||||
db.conn().execute(
|
||||
@@ -374,7 +392,8 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
|
||||
SELECT b.rfc_slug, b.branch_name
|
||||
FROM cached_branches b
|
||||
JOIN cached_rfcs r ON r.slug = b.rfc_slug
|
||||
WHERE r.state = 'super-draft'
|
||||
WHERE r.repo IS NULL
|
||||
AND r.state IN ('super-draft', 'active')
|
||||
AND b.state != 'deleted'
|
||||
AND b.branch_name != 'main'
|
||||
"""
|
||||
@@ -431,6 +450,25 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
|
||||
|
||||
for pull in open_pulls + closed_pulls:
|
||||
head_branch = pull.get("head", {}).get("ref", "")
|
||||
# A merged-and-deleted PR's branch is no longer reported by Gitea
|
||||
# as its real name — the `head.ref` collapses to the synthetic
|
||||
# `refs/pull/<N>/head` sentinel (or empty). The slug + kind both
|
||||
# derive from the branch name, so a deleted branch would parse to
|
||||
# slug=None and the row would be skipped forever, freezing the
|
||||
# cached_prs row at its last-seen `state='open'` — a permanent
|
||||
# ghost "pending idea" for an entry that has actually merged
|
||||
# (caught when the operator authoring lane in ROADMAP #35 merged
|
||||
# an idea PR with the branch deleted; the web UX leaves branches
|
||||
# in place so it never tripped this). Recover the original branch
|
||||
# from the row we already stored when the PR was open — that row
|
||||
# retains the real `head_branch` (migration 002).
|
||||
if not head_branch or head_branch.startswith("refs/pull/"):
|
||||
prior = db.conn().execute(
|
||||
"SELECT head_branch FROM cached_prs WHERE repo = ? AND pr_number = ?",
|
||||
(repo_full, pull["number"]),
|
||||
).fetchone()
|
||||
if prior and prior["head_branch"]:
|
||||
head_branch = prior["head_branch"]
|
||||
slug = _slug_from_head_branch(head_branch)
|
||||
if slug is None:
|
||||
continue
|
||||
|
||||
+32
-21
@@ -97,6 +97,13 @@ class IssueOutcome:
|
||||
raw_token: str
|
||||
row_id: int
|
||||
|
||||
@property
|
||||
def cookie_value(self) -> str:
|
||||
"""The value to put in the `rfc_device_trust` cookie: the row-id
|
||||
selector joined to the raw token (v0.25.0 / audit 0026 M1). The
|
||||
selector lets `lookup` read one indexed row instead of scanning."""
|
||||
return f"{self.row_id}.{self.raw_token}"
|
||||
|
||||
|
||||
def _new_token() -> str:
|
||||
return secrets.token_urlsafe(TOKEN_BYTES)
|
||||
@@ -173,33 +180,37 @@ def lookup(raw_token: str) -> LookupOutcome:
|
||||
if not raw:
|
||||
return LookupOutcome(ok=False, user=None, reason="invalid")
|
||||
|
||||
# The unique index on `device_token_hash` would let us SELECT by
|
||||
# hash if bcrypt were a stable hash, but bcrypt incorporates a
|
||||
# per-row salt — equal tokens produce different hashes. We walk
|
||||
# the candidate set instead. In practice the set is small (a
|
||||
# human has a handful of trusted devices) and bcrypt is cheap on
|
||||
# the order of milliseconds; the walk is bounded by the user's
|
||||
# active device count.
|
||||
# v0.25.0 (audit 0026 M1): the cookie is "<row_id>.<raw_token>". We
|
||||
# parse the row-id selector and read exactly ONE row by its indexed
|
||||
# primary key, then bcrypt-check the token against that single row.
|
||||
#
|
||||
# We don't pre-filter by `revoked_at IS NULL` here so that a
|
||||
# token presented for a recently-revoked row produces a
|
||||
# 'revoked' outcome (the endpoint surfaces a different shape).
|
||||
# Same for expired: we let the walk hit and classify after.
|
||||
rows = db.conn().execute(
|
||||
# The previous shape read EVERY device_trust row (all users, including
|
||||
# revoked/expired) and bcrypt-checked each — an unauthenticated
|
||||
# CPU-amplification DoS reachable at /auth/device-trust/start that
|
||||
# grew without bound as the table accumulated. bcrypt's per-row salt
|
||||
# is why we can't SELECT by hash; carrying the row-id in the cookie is
|
||||
# the standard fix (the id is not secret; the token still is).
|
||||
selector, sep, token = raw.partition(".")
|
||||
if not sep or not selector.isdigit() or not token:
|
||||
# Legacy bare-token cookies (pre-v0.25.0) and malformed values land
|
||||
# here. We refuse rather than fall back to a full-table scan, so
|
||||
# the amplification path is fully closed; affected users simply
|
||||
# re-authenticate once via OTC/passcode and get a new cookie.
|
||||
return LookupOutcome(ok=False, user=None, reason="invalid")
|
||||
|
||||
matched = db.conn().execute(
|
||||
"""
|
||||
SELECT id, user_id, device_token_hash, expires_at, revoked_at
|
||||
FROM device_trust
|
||||
ORDER BY id DESC
|
||||
WHERE id = ?
|
||||
""",
|
||||
).fetchall()
|
||||
(int(selector),),
|
||||
).fetchone()
|
||||
|
||||
matched = None
|
||||
for row in rows:
|
||||
if _check(raw, row["device_token_hash"]):
|
||||
matched = row
|
||||
break
|
||||
|
||||
if matched is None:
|
||||
# One bcrypt check, against the selected row only. A wrong/forged token
|
||||
# for a real id reads as 'unknown' (cookie cleared), same as a missing
|
||||
# row — a probing client can't distinguish the two.
|
||||
if matched is None or not _check(token, matched["device_token_hash"]):
|
||||
return LookupOutcome(ok=False, user=None, reason="unknown")
|
||||
|
||||
if matched["revoked_at"] is not None:
|
||||
|
||||
@@ -60,12 +60,17 @@ def build_envelope(
|
||||
`from_name` is the display label that goes through `formataddr`
|
||||
so spaces / commas in the display string are encoded correctly.
|
||||
|
||||
`body_plain` is mandatory. `body_html`, if supplied, lands as the
|
||||
second part of a `multipart/alternative` body — mail clients
|
||||
that prefer HTML render it; clients that don't fall back to the
|
||||
plain part. The text/plain part comes first per RFC 2046, so a
|
||||
plain-text client that picks the first body gets the readable
|
||||
text.
|
||||
`body_plain` is mandatory. `body_html` is **reserved and not yet
|
||||
enabled** (security-audit-0026 I3): no send path supplies it today —
|
||||
every rfc-app mail is plain text — and passing it raises
|
||||
`NotImplementedError`. The parameter is kept in the signature for
|
||||
documented future symmetry: when HTML mail is enabled it will land
|
||||
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
|
||||
different mailbox than the From line (e.g., a watcher
|
||||
@@ -131,13 +136,20 @@ def build_envelope(
|
||||
# idempotent and not require auth. See
|
||||
# `api_notifications.py` for the receiver.
|
||||
msg["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
|
||||
if body_html:
|
||||
# multipart/alternative: text/plain first, text/html second.
|
||||
# `set_content` sets the first part (and the message's main
|
||||
# body); `add_alternative` adds the second part and
|
||||
# restructures the message as multipart/alternative.
|
||||
msg.set_content(body_plain)
|
||||
msg.add_alternative(body_html, subtype="html")
|
||||
else:
|
||||
msg.set_content(body_plain)
|
||||
if body_html is not None:
|
||||
# I3 (security-audit-0026): the multipart/alternative HTML path
|
||||
# is intentionally NOT enabled. No send path passes `body_html`
|
||||
# today, and emitting an HTML body built from user-supplied
|
||||
# content without escaping it first would reintroduce the C1
|
||||
# stored-XSS class in the mail channel (the recipient's client
|
||||
# renders the HTML). Fail loudly here rather than silently
|
||||
# shipping HTML: enabling HTML mail is a deliberate change that
|
||||
# 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
|
||||
|
||||
@@ -284,9 +284,10 @@ async def _delete_branch_via_bot(
|
||||
reason: str,
|
||||
) -> bool:
|
||||
"""Call `bot.delete_branch` with the system actor. Resolves the
|
||||
`(org, repo)` pair from the slug: super-draft edit branches and
|
||||
graduation branches live on the meta repo; active-RFC branches
|
||||
live on the per-RFC repo named by `cached_rfcs.repo`.
|
||||
`(org, repo)` pair from the slug: under the meta-only topology (§1)
|
||||
every meta-resident entry's edit branches and graduation branches
|
||||
live on the meta repo; a legacy per-RFC repo (a `repo:` that survives
|
||||
from before the fold-back, §13.6) is named by `cached_rfcs.repo`.
|
||||
|
||||
Returns True on a clean delete; False if the rfc row is missing
|
||||
(we leave the branch row in place — a subsequent reconciler sweep
|
||||
@@ -297,12 +298,13 @@ async def _delete_branch_via_bot(
|
||||
if rfc is None:
|
||||
log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch)
|
||||
return False
|
||||
if rfc["state"] == "super-draft":
|
||||
if not rfc["repo"]:
|
||||
owner, repo = config.gitea_org, config.meta_repo
|
||||
elif rfc["state"] == "active" and rfc["repo"] and "/" in rfc["repo"]:
|
||||
elif "/" in rfc["repo"]:
|
||||
owner, repo = rfc["repo"].split("/", 1)
|
||||
else:
|
||||
log.warning("hygiene: cannot resolve repo for %s state=%s", slug, rfc["state"])
|
||||
log.warning("hygiene: cannot resolve repo for %s state=%s repo=%r",
|
||||
slug, rfc["state"], rfc["repo"])
|
||||
return False
|
||||
try:
|
||||
await bot.delete_branch(
|
||||
|
||||
+61
-19
@@ -7,6 +7,7 @@ no need for a separate worker.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -28,6 +29,7 @@ from . import (
|
||||
otc,
|
||||
passcode as passcode_mod,
|
||||
providers as providers_mod,
|
||||
ratelimit,
|
||||
turnstile,
|
||||
webhooks,
|
||||
)
|
||||
@@ -142,12 +144,20 @@ def create_app() -> FastAPI:
|
||||
# eagerly via load_config(). Everything else waits for lifespan.
|
||||
config = load_config()
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
# v0.25.0 (audit 0026 M4): the session cookie is the primary 30-day
|
||||
# auth credential and must carry `Secure` in production so it never
|
||||
# travels cleartext. Default to Secure; a dev box serving over plain
|
||||
# http opts out with SESSION_COOKIE_SECURE=false. Production (OHM is
|
||||
# HTTPS-only with an HTTP->HTTPS 301) leaves this unset → Secure on.
|
||||
session_secure = os.environ.get("SESSION_COOKIE_SECURE", "true").strip().lower() not in (
|
||||
"0", "false", "no", "off",
|
||||
)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=config.secret_key,
|
||||
session_cookie="rfc_session",
|
||||
max_age=60 * 60 * 24 * 30,
|
||||
https_only=False,
|
||||
https_only=session_secure,
|
||||
)
|
||||
return app
|
||||
|
||||
@@ -155,24 +165,25 @@ def create_app() -> FastAPI:
|
||||
app = create_app()
|
||||
|
||||
|
||||
def _set_device_trust_cookie(response: Response, raw_token: str) -> None:
|
||||
def _set_device_trust_cookie(response: Response, cookie_value: str) -> None:
|
||||
"""Attach the v0.11.0 device-trust cookie to the response.
|
||||
|
||||
HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. The
|
||||
cookie value is the raw token; server-side storage is the hash.
|
||||
The cookie is "essential" per the v0.13.0 cookie-consent contract
|
||||
(it is part of authentication), so we set it regardless of the
|
||||
user's analytics / other-cookies choice.
|
||||
HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. As of
|
||||
v0.25.0 (audit 0026 M1) the value is `IssueOutcome.cookie_value` —
|
||||
"<row_id>.<raw_token>" — so `device_trust.lookup` can read one indexed
|
||||
row instead of scanning; server-side storage remains the bcrypt hash
|
||||
of the token half only. The cookie is "essential" per the v0.13.0
|
||||
cookie-consent contract (it is part of authentication), so we set it
|
||||
regardless of the user's analytics / other-cookies choice.
|
||||
|
||||
Secure=True means the cookie is only ever sent over HTTPS. The
|
||||
SessionMiddleware in `create_app` keeps `https_only=False` for
|
||||
dev parity, but the device-trust cookie holds a 30-day credential
|
||||
and must not travel cleartext — production deployments serve over
|
||||
HTTPS, so Secure on the device-trust cookie is non-negotiable.
|
||||
Secure=True means the cookie is only ever sent over HTTPS — the
|
||||
device-trust cookie holds a 30-day credential and must never travel
|
||||
cleartext. (The session cookie now also defaults to Secure; see M4 in
|
||||
`create_app`.)
|
||||
"""
|
||||
response.set_cookie(
|
||||
key=device_trust_mod.COOKIE_NAME,
|
||||
value=raw_token,
|
||||
value=cookie_value,
|
||||
max_age=device_trust_mod.COOKIE_MAX_AGE_SECONDS,
|
||||
path="/",
|
||||
secure=True,
|
||||
@@ -245,6 +256,10 @@ def _oauth_router(config) -> APIRouter:
|
||||
|
||||
@router.post("/auth/otc/request")
|
||||
async def otc_request(body: OtcRequestBody, request: Request):
|
||||
# v0.25.0 (audit 0026 H1/L2): per-IP brake at the cheapest point,
|
||||
# before the Turnstile network call or any bcrypt/SMTP work.
|
||||
if not ratelimit.otc_request_limiter.allow(ratelimit.client_key(request)):
|
||||
raise HTTPException(429, "Too many requests; please wait a few minutes")
|
||||
# v0.12.0 / roadmap item #10: gate the request on a successful
|
||||
# Turnstile siteverify before the bcrypt hash + SMTP send. The
|
||||
# check runs first so a failed challenge spends no rate budget
|
||||
@@ -252,7 +267,7 @@ def _oauth_router(config) -> APIRouter:
|
||||
# secret AND TURNSTILE_REQUIRED=false (the default), the gate
|
||||
# opens — see `backend/app/turnstile.py` for the full matrix.
|
||||
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 ts.reason == "misconfigured":
|
||||
# TURNSTILE_REQUIRED=true but the secret is unset. This
|
||||
@@ -278,9 +293,25 @@ def _oauth_router(config) -> APIRouter:
|
||||
|
||||
@router.post("/auth/otc/verify")
|
||||
async def otc_verify(body: OtcVerifyBody, request: Request, response: Response):
|
||||
# v0.25.0 (audit 0026 H1): per-IP brake against fan-out guessing,
|
||||
# plus the per-email lockout enforced inside otc.verify_code.
|
||||
ip = ratelimit.client_key(request)
|
||||
if not ratelimit.verify_limiter.allow(ip):
|
||||
raise HTTPException(429, "Too many attempts; please wait a few minutes")
|
||||
result = otc.verify_code(body.email, body.code)
|
||||
if result.reason == "locked":
|
||||
raise HTTPException(
|
||||
423,
|
||||
{
|
||||
"detail": "Too many failed attempts; wait a few minutes or request a new code",
|
||||
"locked_until": result.locked_until,
|
||||
},
|
||||
)
|
||||
if not result.ok or result.user is None:
|
||||
raise HTTPException(400, "Invalid or expired code")
|
||||
# Legit sign-in: clear this IP's window so a user who fat-fingered
|
||||
# a couple of codes isn't left throttled.
|
||||
ratelimit.verify_limiter.reset(ip)
|
||||
auth.store_session(request, result.user)
|
||||
# v0.8.0: surface `needs_profile` so the Login.jsx surface can
|
||||
# decide whether to advance to the first/last/why capture step
|
||||
@@ -313,7 +344,7 @@ def _oauth_router(config) -> APIRouter:
|
||||
if body.trust_device:
|
||||
ua = request.headers.get("user-agent", "")
|
||||
outcome = device_trust_mod.issue(result.user.user_id, ua)
|
||||
_set_device_trust_cookie(response, outcome.raw_token)
|
||||
_set_device_trust_cookie(response, outcome.cookie_value)
|
||||
return {
|
||||
"ok": True,
|
||||
"user": {
|
||||
@@ -337,12 +368,17 @@ def _oauth_router(config) -> APIRouter:
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/auth/passcode/check")
|
||||
async def passcode_check(email: str = ""):
|
||||
async def passcode_check(request: Request, email: str = ""):
|
||||
"""Does this email have a passcode set? Anonymous endpoint —
|
||||
the Login.jsx flow calls this after the user types their email
|
||||
to decide whether to render a passcode input or fall back to
|
||||
OTC. We surface only the boolean; lockout state, the hash, and
|
||||
the set-at stamp are not leaked here."""
|
||||
the set-at stamp are not leaked here.
|
||||
|
||||
v0.25.0 (audit 0026 L3): per-IP rate limit so the has-passcode
|
||||
boolean can't be bulk-harvested to enumerate accounts."""
|
||||
if not ratelimit.check_limiter.allow(ratelimit.client_key(request)):
|
||||
raise HTTPException(429, "Too many requests; please wait a few minutes")
|
||||
status = passcode_mod.passcode_status(email)
|
||||
return {"has_passcode": status.has_passcode}
|
||||
|
||||
@@ -376,6 +412,11 @@ def _oauth_router(config) -> APIRouter:
|
||||
v0.11.0: the body's `trust_device` flag, if true, mints a
|
||||
fresh device-trust row and sets the long-lived cookie. Same
|
||||
opt-in contract as `/auth/otc/verify`."""
|
||||
# v0.25.0 (audit 0026 H1): per-IP brake in front of the per-account
|
||||
# passcode lockout, so fan-out across emails is throttled too.
|
||||
ip = ratelimit.client_key(request)
|
||||
if not ratelimit.verify_limiter.allow(ip):
|
||||
raise HTTPException(429, "Too many attempts; please wait a few minutes")
|
||||
result = passcode_mod.verify_passcode(body.email, body.passcode)
|
||||
if result.reason == "locked":
|
||||
raise HTTPException(
|
||||
@@ -387,11 +428,12 @@ def _oauth_router(config) -> APIRouter:
|
||||
)
|
||||
if not result.ok or result.user is None:
|
||||
raise HTTPException(400, "Invalid passcode")
|
||||
ratelimit.verify_limiter.reset(ip)
|
||||
auth.store_session(request, result.user)
|
||||
if body.trust_device:
|
||||
ua = request.headers.get("user-agent", "")
|
||||
outcome = device_trust_mod.issue(result.user.user_id, ua)
|
||||
_set_device_trust_cookie(response, outcome.raw_token)
|
||||
_set_device_trust_cookie(response, outcome.cookie_value)
|
||||
return {
|
||||
"ok": True,
|
||||
"user": {
|
||||
@@ -459,7 +501,7 @@ def _oauth_router(config) -> APIRouter:
|
||||
if body.trust_device:
|
||||
ua = request.headers.get("user-agent", "")
|
||||
outcome = device_trust_mod.issue(result.user.user_id, ua)
|
||||
_set_device_trust_cookie(response, outcome.raw_token)
|
||||
_set_device_trust_cookie(response, outcome.cookie_value)
|
||||
|
||||
# Has the user already set a passcode? (Could only happen via
|
||||
# an admin pre-population path that doesn't exist yet, but
|
||||
|
||||
@@ -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(
|
||||
*,
|
||||
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}."
|
||||
if event_kind == "pr_conflict_with_main":
|
||||
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":
|
||||
# v0.9.0: framework-scoped, not RFC-scoped. The actor (the
|
||||
# requester) and the captured full name + email read as
|
||||
@@ -884,6 +974,11 @@ def list_inbox(
|
||||
"read_at": row["read_at"],
|
||||
"category": extras.get("category"),
|
||||
"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:
|
||||
|
||||
@@ -85,6 +85,15 @@ def _cooldown_seconds() -> int:
|
||||
return 60
|
||||
|
||||
|
||||
# v0.25.0 / security audit 0026 (H1): per-email OTC verify lockout,
|
||||
# mirroring the passcode path (passcode.py). Five consecutive wrong codes
|
||||
# for an email lock its OTC verify for 15 minutes. The per-IP limiter in
|
||||
# ratelimit.py is the primary brute-force brake; this is the durable,
|
||||
# passcode-parity layer.
|
||||
LOCKOUT_AFTER_FAILED_ATTEMPTS = 5
|
||||
LOCKOUT_DURATION_MINUTES = 15
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Code generation + hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -206,6 +215,61 @@ class VerifyOutcome:
|
||||
ok: bool
|
||||
user: SessionUser | None
|
||||
reason: str
|
||||
# v0.25.0 (H1): ISO-8601 stamp when reason == 'locked'.
|
||||
locked_until: str | None = None
|
||||
|
||||
|
||||
def _verify_lockout_until(email: str) -> str | None:
|
||||
"""Return the active lockout stamp for `email`, or None if not locked.
|
||||
|
||||
Clears an elapsed lockout (and resets the counter) as a side effect so
|
||||
the next failure starts a fresh budget — mirrors passcode.verify_passcode.
|
||||
"""
|
||||
row = db.conn().execute(
|
||||
"SELECT failed_attempts, locked_until FROM otc_verify_state WHERE email = ?",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if row is None or not row["locked_until"]:
|
||||
return None
|
||||
still_locked = db.conn().execute(
|
||||
"SELECT datetime(?) > datetime('now') AS locked", (row["locked_until"],),
|
||||
).fetchone()["locked"]
|
||||
if still_locked:
|
||||
return row["locked_until"]
|
||||
db.conn().execute(
|
||||
"UPDATE otc_verify_state SET failed_attempts = 0, locked_until = NULL WHERE email = ?",
|
||||
(email,),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _record_verify_failure(email: str) -> None:
|
||||
"""Increment the per-email failure counter; stamp a lockout once it
|
||||
crosses the threshold. Mirrors the passcode lockout shape."""
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO otc_verify_state (email, failed_attempts)
|
||||
VALUES (?, 1)
|
||||
ON CONFLICT(email) DO UPDATE SET failed_attempts = failed_attempts + 1
|
||||
""",
|
||||
(email,),
|
||||
)
|
||||
count = db.conn().execute(
|
||||
"SELECT failed_attempts FROM otc_verify_state WHERE email = ?", (email,),
|
||||
).fetchone()["failed_attempts"]
|
||||
if count >= LOCKOUT_AFTER_FAILED_ATTEMPTS:
|
||||
db.conn().execute(
|
||||
f"""
|
||||
UPDATE otc_verify_state
|
||||
SET locked_until = datetime('now', '+{LOCKOUT_DURATION_MINUTES} minutes')
|
||||
WHERE email = ?
|
||||
""",
|
||||
(email,),
|
||||
)
|
||||
|
||||
|
||||
def _clear_verify_state(email: str) -> None:
|
||||
db.conn().execute("DELETE FROM otc_verify_state WHERE email = ?", (email,))
|
||||
|
||||
|
||||
def verify_code(email: str, code: str) -> VerifyOutcome:
|
||||
@@ -214,6 +278,13 @@ def verify_code(email: str, code: str) -> VerifyOutcome:
|
||||
if not email or not code:
|
||||
return VerifyOutcome(ok=False, user=None, reason="invalid")
|
||||
|
||||
# v0.25.0 (H1): refuse before spending any bcrypt if this email is in
|
||||
# its OTC-verify lockout window. The passcode path is unaffected — a
|
||||
# locked-out OTC user can still set/use a passcode, and vice versa.
|
||||
locked_until = _verify_lockout_until(email)
|
||||
if locked_until:
|
||||
return VerifyOutcome(ok=False, user=None, reason="locked", locked_until=locked_until)
|
||||
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT id, code_hash, expires_at, consumed_at
|
||||
@@ -237,6 +308,9 @@ def verify_code(email: str, code: str) -> VerifyOutcome:
|
||||
break
|
||||
|
||||
if matched is None:
|
||||
# A genuine wrong guess against this email — the brute-force
|
||||
# signal. Count it toward the lockout threshold (H1).
|
||||
_record_verify_failure(email)
|
||||
return VerifyOutcome(ok=False, user=None, reason="wrong")
|
||||
|
||||
if matched["consumed_at"] is not None:
|
||||
@@ -255,6 +329,8 @@ def verify_code(email: str, code: str) -> VerifyOutcome:
|
||||
"UPDATE otc_codes SET consumed_at = datetime('now') WHERE id = ?",
|
||||
(matched["id"],),
|
||||
)
|
||||
# Success wipes the per-email failure counter (H1).
|
||||
_clear_verify_state(email)
|
||||
user = provision_or_link_user(email)
|
||||
return VerifyOutcome(ok=True, user=user, reason="ok")
|
||||
|
||||
|
||||
@@ -184,6 +184,21 @@ def load_providers(env: dict) -> dict[str, BaseProvider]:
|
||||
return providers
|
||||
|
||||
|
||||
def construct_haiku(api_key: str) -> AnthropicProvider:
|
||||
"""A dedicated Claude Haiku provider, independent of the
|
||||
`ENABLED_MODELS` chat-picker universe.
|
||||
|
||||
The §9.1 tag-suggestion surface (roadmap #27) always wants the
|
||||
cheap + fast model regardless of which models the operator surfaces
|
||||
in the §8.12 picker, so it constructs Haiku directly from the
|
||||
operator's Anthropic key rather than going through `load_providers`.
|
||||
The model id is sourced from the same `_CLAUDE_VARIANTS` table the
|
||||
picker uses, so a model-string bump lands in one place.
|
||||
"""
|
||||
model, name = _CLAUDE_VARIANTS["claude-haiku"]
|
||||
return AnthropicProvider(api_key=api_key, model=model, display_name=name)
|
||||
|
||||
|
||||
def load_from_config(config) -> dict[str, BaseProvider]:
|
||||
"""Convenience adapter so callers can pass our Config dataclass directly."""
|
||||
env = {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""In-process per-IP sliding-window rate limiter (security audit 0026, H1).
|
||||
|
||||
The auth verify endpoints (`/auth/otc/verify`, `/auth/passcode/verify`)
|
||||
had no per-IP brake, so an attacker could fan out guesses against a
|
||||
target identity bounded only by bcrypt cost. This module is the brake.
|
||||
|
||||
It is deliberately tiny: §4.2 says the app is a single process with a
|
||||
colocated SQLite file, so an in-memory dict of `key -> deque[timestamps]`
|
||||
is sufficient and needs no shared store. State resets on restart, which
|
||||
fails *open* for a brief window — acceptable because the per-email OTC
|
||||
lockout (`otc_verify_state`) and the passcode lockout both persist in the
|
||||
database and carry the durable guarantee; this limiter is the
|
||||
anti-fan-out layer on top.
|
||||
|
||||
Chosen over a per-identity lockout *as the primary control* because a
|
||||
per-IP window throttles the attacker without letting them grief a victim
|
||||
by locking that victim's account (the known downside of identity
|
||||
lockouts). Both layers run together.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
|
||||
class SlidingWindowLimiter:
|
||||
"""Allow at most `max_events` per `window_seconds` per key.
|
||||
|
||||
`allow(key)` records an event and returns True if the key is still
|
||||
within budget, False if it has exceeded it. Timestamps use a
|
||||
monotonic clock so the limiter is immune to wall-clock jumps.
|
||||
"""
|
||||
|
||||
def __init__(self, max_events: int, window_seconds: float) -> None:
|
||||
self.max_events = max_events
|
||||
self.window_seconds = window_seconds
|
||||
self._events: dict[str, deque[float]] = defaultdict(deque)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def allow(self, key: str) -> bool:
|
||||
now = time.monotonic()
|
||||
cutoff = now - self.window_seconds
|
||||
with self._lock:
|
||||
q = self._events[key]
|
||||
while q and q[0] < cutoff:
|
||||
q.popleft()
|
||||
if len(q) >= self.max_events:
|
||||
return False
|
||||
q.append(now)
|
||||
# Opportunistic cleanup so idle keys don't accumulate forever.
|
||||
if not q:
|
||||
self._events.pop(key, None)
|
||||
return True
|
||||
|
||||
def reset(self, key: str) -> None:
|
||||
"""Drop a key's window — e.g. after a successful sign-in so a
|
||||
legitimate user who fat-fingered a few times isn't throttled."""
|
||||
with self._lock:
|
||||
self._events.pop(key, None)
|
||||
|
||||
|
||||
# Module-level limiters shared across requests (one process, so module
|
||||
# state is the natural home). Tunables are intentionally generous enough
|
||||
# not to bother a human retyping a code, tight enough to kill fan-out:
|
||||
# * verify: 10 attempts / 5 min / IP across the auth verify surfaces.
|
||||
# * otc request: 5 sends / 5 min / IP (Turnstile is the primary gate;
|
||||
# this is defense in depth against a solved-challenge replay loop).
|
||||
verify_limiter = SlidingWindowLimiter(max_events=10, window_seconds=300)
|
||||
otc_request_limiter = SlidingWindowLimiter(max_events=5, window_seconds=300)
|
||||
# /auth/passcode/check is an anonymous has-passcode oracle (audit 0026 L3).
|
||||
# It's a legitimate Login-flow affordance, so the budget is generous —
|
||||
# enough for a human typing emails, tight enough to stop bulk scraping.
|
||||
check_limiter = SlidingWindowLimiter(max_events=30, window_seconds=300)
|
||||
|
||||
|
||||
def _reset_all_for_tests() -> None:
|
||||
"""Clear every module-level limiter's window. Test support only — the
|
||||
limiters are process-global singletons, so without a per-test reset
|
||||
one test's requests bleed into the next and later tests trip the
|
||||
budget (429). Not called in production."""
|
||||
for lim in (verify_limiter, otc_request_limiter, check_limiter):
|
||||
with lim._lock:
|
||||
lim._events.clear()
|
||||
|
||||
|
||||
def client_key(request) -> str:
|
||||
"""Best-effort client identity for limiting. Behind nginx the app is
|
||||
started with `--forwarded-allow-ips 127.0.0.1`, so `request.client.host`
|
||||
reflects the real client IP via Uvicorn's ProxyHeaders handling."""
|
||||
client = getattr(request, "client", None)
|
||||
return client.host if client and client.host else "unknown"
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Roadmap #28 — scan submitted prose for RFC-shaped references.
|
||||
|
||||
The scanner splits a plain-text PR description / comment body into a list
|
||||
of *segments* the frontend renders: plain-text runs interleaved with
|
||||
typed link segments. The backend never emits HTML — the frontend maps
|
||||
each segment onto a React node — so the surface is XSS-safe by
|
||||
construction and independent of any HTML-sanitization layer.
|
||||
|
||||
Three buckets, one scan (Parts 1–3):
|
||||
|
||||
* ``{"type": "rfc", ...}`` — Part 1. The term matches an
|
||||
**accepted** (``state='active'``) RFC; renders as a link to it.
|
||||
* ``{"type": "rfc-pending", ...}`` — Part 3. The term matches a
|
||||
**pending** RFC — a super-draft (``state='super-draft'``: accepted
|
||||
as an idea but not yet graduated to an active RFC) — which has an
|
||||
owner and a contribution surface. Renders as an "ask to contribute"
|
||||
affordance carrying the owner's display name.
|
||||
* ``{"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.
|
||||
|
||||
Precedence at any position is active > pending > candidate, then
|
||||
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.
|
||||
* Multi-word titles (containing whitespace, e.g. ``Open Human Model``).
|
||||
* Hyphenated slugs (containing ``-``, e.g. ``open-human-model``).
|
||||
|
||||
Single common-word titles/slugs are deliberately NOT matched — they
|
||||
would turn every prose occurrence into an affordance.
|
||||
|
||||
**Part 2 candidate heuristic.** A candidate term is a **multi-word tag**
|
||||
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
|
||||
|
||||
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:
|
||||
"""Word-boundary test. Hyphen and underscore count as word chars so a
|
||||
match can't begin or end in the middle of a kebab/snake token."""
|
||||
return c.isalnum() or c in ("-", "_")
|
||||
|
||||
|
||||
def _emit(term: Term, label: str) -> dict[str, Any]:
|
||||
"""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}
|
||||
|
||||
|
||||
def segment_text(text: str | None, terms: Iterable[Term | tuple]) -> list[dict[str, Any]]:
|
||||
"""Split ``text`` into text / link segments against ``terms``.
|
||||
|
||||
``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
|
||||
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:
|
||||
return [{"type": "text", "text": text or ""}]
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
buf: list[str] = []
|
||||
low = text.lower()
|
||||
n = len(text)
|
||||
i = 0
|
||||
while i < n:
|
||||
match: tuple[Term, int] | None = None
|
||||
for term in ordered:
|
||||
klen = len(term.key)
|
||||
if klen == 0 or not low.startswith(term.key, i):
|
||||
continue
|
||||
before = text[i - 1] if i > 0 else ""
|
||||
after = text[i + klen] if i + klen < n else ""
|
||||
if _is_word_char(before) or _is_word_char(after):
|
||||
continue
|
||||
match = (term, klen)
|
||||
break
|
||||
if match is not None:
|
||||
term, klen = match
|
||||
if buf:
|
||||
out.append({"type": "text", "text": "".join(buf)})
|
||||
buf = []
|
||||
out.append(_emit(term, text[i:i + klen]))
|
||||
i += klen
|
||||
else:
|
||||
buf.append(text[i])
|
||||
i += 1
|
||||
if buf:
|
||||
out.append({"type": "text", "text": "".join(buf)})
|
||||
return out
|
||||
|
||||
|
||||
def _keys_for(slug: str, title: str, rfc_id: str | None) -> Iterable[str]:
|
||||
"""The match keys an RFC contributes. See the module docstring for why
|
||||
each gate exists (conservative, false-positive-averse)."""
|
||||
if rfc_id:
|
||||
rid = rfc_id.strip()
|
||||
if len(rid) >= 2:
|
||||
yield rid.lower()
|
||||
if title:
|
||||
t = title.strip()
|
||||
# Multi-word titles only — a single common word is too noisy.
|
||||
if len(t) >= 2 and (" " in t or "\t" in t):
|
||||
yield t.lower()
|
||||
if slug:
|
||||
s = slug.strip()
|
||||
# Hyphenated slugs only — a single-token slug is a bare word.
|
||||
if len(s) >= 2 and "-" in s:
|
||||
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:
|
||||
"""A reusable term index built once per request and applied to many
|
||||
bodies (a PR's description plus every comment on it)."""
|
||||
|
||||
def __init__(self, terms: Iterable[Term | tuple]):
|
||||
# Coerce + order once; segment_text re-sorts defensively but a
|
||||
# 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:
|
||||
return bool(self._terms)
|
||||
|
||||
def segment(self, text: str | None) -> list[dict[str, Any]]:
|
||||
return segment_text(text, self._terms)
|
||||
|
||||
|
||||
def _owner_display(conn, owners_json: str | None, proposed_by: str | None) -> str:
|
||||
"""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"
|
||||
|
||||
|
||||
def _tag_universe(conn) -> list[str]:
|
||||
"""Distinct tags across the cached corpus (the #27 de-facto taxonomy),
|
||||
preserving original spelling; case-deduped."""
|
||||
rows = conn.execute("SELECT tags_json FROM cached_rfcs").fetchall()
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
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"]
|
||||
defined_slugs.add((slug or "").lower())
|
||||
defined_titles.add((r["title"] or "").strip().lower())
|
||||
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
|
||||
for key in _keys_for(slug, title, rfc_id):
|
||||
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
|
||||
if low in defined_titles or low in defined_slugs or _slugify(tag) in defined_slugs:
|
||||
continue
|
||||
add(low, Term(key=low, kind="candidate", term=tag))
|
||||
|
||||
return LinkIndex(terms)
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Roadmap #27 — Claude Haiku tag suggestions for the propose-RFC modal.
|
||||
|
||||
A cheap, fast assist: given the partial RFC draft a user is typing, ask
|
||||
Claude Haiku to recommend tags drawn ONLY from the tags already in use
|
||||
across the corpus. v1 has no curated tag list — tags are free-form chip
|
||||
input (§9.1) — so "the taxonomy" is the de-facto set of distinct tags
|
||||
the existing RFCs already carry. The model is constrained to that set
|
||||
and MUST NOT invent new tags; taxonomy extension (letting the model
|
||||
propose genuinely new tags) is a deferred follow-up per the roadmap.
|
||||
|
||||
Why Haiku specifically: cost. Picking a few reasonable tags from a known
|
||||
set is well within Haiku's range, and the modal fires this repeatedly as
|
||||
the user types, so the per-call price has to stay small.
|
||||
|
||||
The whole surface degrades to silence rather than error: no Anthropic
|
||||
key, no provider, an empty corpus, a rate-limited caller, an empty
|
||||
draft, or an unparseable model reply all yield an empty suggestion list.
|
||||
The propose modal hides its suggestion row on an empty list, so the
|
||||
fallback is simply the existing free-form chip input with nothing extra
|
||||
shown.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import db
|
||||
from .providers import BaseProvider, construct_haiku
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Bound the universe handed to the model so a large corpus can't blow up
|
||||
# the prompt size (and the cost). The most-common tags matter most.
|
||||
_UNIVERSE_CAP = 200
|
||||
|
||||
# How many suggestions we ever return to the modal.
|
||||
DEFAULT_MAX_SUGGESTIONS = 6
|
||||
|
||||
|
||||
@dataclass
|
||||
class Draft:
|
||||
"""The partial propose-RFC draft. `pitch` is the "why is this needed"
|
||||
rationale; `use_case` is the #26 optional ground-truth field."""
|
||||
|
||||
title: str = ""
|
||||
pitch: str = ""
|
||||
use_case: str = ""
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return not (self.title.strip() or self.pitch.strip() or self.use_case.strip())
|
||||
|
||||
|
||||
def haiku_provider(config) -> BaseProvider | None:
|
||||
"""Construct a dedicated Claude Haiku provider from the operator's
|
||||
Anthropic key, or return None when no key is configured.
|
||||
|
||||
None means "suggestions unavailable" — the caller returns an empty
|
||||
list and the modal shows nothing. This is the seam tests monkeypatch
|
||||
to inject a fake provider without a real key. There is no RFC slug at
|
||||
propose time, so the §6.7 per-RFC funder path deliberately does not
|
||||
apply: tag suggestion always runs on the operator's own key.
|
||||
"""
|
||||
key = getattr(config, "anthropic_api_key", "") or ""
|
||||
if not key:
|
||||
return None
|
||||
return construct_haiku(key)
|
||||
|
||||
|
||||
def gather_tag_universe(cap: int = _UNIVERSE_CAP) -> list[str]:
|
||||
"""Every distinct tag in use across the cached corpus, most-common
|
||||
first (ties broken alphabetically for determinism), capped.
|
||||
|
||||
This is the universe the model is constrained to. An empty corpus
|
||||
yields an empty list, which short-circuits suggestion to silence.
|
||||
"""
|
||||
rows = db.conn().execute("SELECT tags_json FROM cached_rfcs").fetchall()
|
||||
counts: dict[str, int] = {}
|
||||
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()
|
||||
if tag:
|
||||
counts[tag] = counts.get(tag, 0) + 1
|
||||
ranked = sorted(counts, key=lambda t: (-counts[t], t.lower()))
|
||||
return ranked[:cap]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rate limiting — in-process, per-user sliding window.
|
||||
#
|
||||
# Cost control, not security: the `require_contributor` gate already
|
||||
# bounds callers to authenticated beta users, and the modal debounces.
|
||||
# This is the backstop against a stuck/abusive client hammering the
|
||||
# endpoint. In-memory is sufficient (single-process uvicorn on the VM)
|
||||
# and resets on restart, which is fine for a cost guard.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CALLS: dict[int, list[float]] = {}
|
||||
|
||||
|
||||
def _rate_config() -> tuple[int, float]:
|
||||
try:
|
||||
max_calls = int(os.environ.get("TAG_SUGGEST_RATE_MAX", "30"))
|
||||
except ValueError:
|
||||
max_calls = 30
|
||||
try:
|
||||
window = float(os.environ.get("TAG_SUGGEST_RATE_WINDOW_SECONDS", "60"))
|
||||
except ValueError:
|
||||
window = 60.0
|
||||
return max_calls, window
|
||||
|
||||
|
||||
def rate_limit_ok(user_id: int, *, now: float | None = None) -> bool:
|
||||
"""True if this call is within the per-user window; records the call.
|
||||
`now` is injectable for tests (defaults to a monotonic clock)."""
|
||||
max_calls, window = _rate_config()
|
||||
t = time.monotonic() if now is None else now
|
||||
calls = _CALLS.setdefault(user_id, [])
|
||||
cutoff = t - window
|
||||
calls[:] = [c for c in calls if c > cutoff]
|
||||
if len(calls) >= max_calls:
|
||||
return False
|
||||
calls.append(t)
|
||||
return True
|
||||
|
||||
|
||||
def reset_rate_limits() -> None:
|
||||
"""Test seam — clear the in-process window state."""
|
||||
_CALLS.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt + parse.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SYSTEM = (
|
||||
"You suggest topic tags for a draft RFC — a structured proposal "
|
||||
"document in a collection. You are given the draft's title, its "
|
||||
"rationale, an optional use case, and the exact set of tags already "
|
||||
"in use across the collection. Choose the tags from that set that "
|
||||
"best fit the draft.\n"
|
||||
"Rules:\n"
|
||||
"- Choose ONLY from the provided tag set. Never invent a tag.\n"
|
||||
"- Order best-fit first. Omit weak fits rather than padding the list.\n"
|
||||
"- Return at most {max} tags.\n"
|
||||
"Respond with ONLY a JSON array, no prose, of the form:\n"
|
||||
'[{{"tag": "<exact tag from the set>", "confidence": <number between 0 and 1>}}]\n'
|
||||
"If no tag in the set fits the draft, return []."
|
||||
)
|
||||
|
||||
|
||||
def _build_messages(draft: Draft, universe: list[str], max_suggestions: int):
|
||||
system = _SYSTEM.format(max=max_suggestions)
|
||||
parts: list[str] = []
|
||||
if draft.title.strip():
|
||||
parts.append(f"Title: {draft.title.strip()[:300]}")
|
||||
if draft.pitch.strip():
|
||||
parts.append(f"Why this RFC is needed:\n{draft.pitch.strip()[:4000]}")
|
||||
if draft.use_case.strip():
|
||||
parts.append(f"What it will be used for:\n{draft.use_case.strip()[:4000]}")
|
||||
parts.append(
|
||||
"Tags already in use (choose only from these):\n" + ", ".join(universe)
|
||||
)
|
||||
return system, [{"role": "user", "content": "\n\n".join(parts)}]
|
||||
|
||||
|
||||
def _extract_json_array(text: str):
|
||||
start = text.find("[")
|
||||
end = text.rfind("]")
|
||||
if start == -1 or end == -1 or end < start:
|
||||
return None
|
||||
try:
|
||||
return json.loads(text[start : end + 1])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_reply(text: str, universe: list[str], max_suggestions: int) -> list[dict]:
|
||||
"""Parse the model reply into a clean, universe-constrained list.
|
||||
|
||||
Tolerant of the model returning bare strings or objects, extra prose
|
||||
around the JSON, unknown/invented tags (dropped), duplicate tags
|
||||
(deduped), and missing/garbage confidences (defaulted + clamped).
|
||||
"""
|
||||
data = _extract_json_array(text or "")
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
# Map back to the canonical spelling in the universe, case-insensitively,
|
||||
# so a model that lowercases a tag still resolves to the real one.
|
||||
canonical = {t.lower(): t for t in universe}
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
raw = item.get("tag")
|
||||
conf = item.get("confidence")
|
||||
elif isinstance(item, str):
|
||||
raw, conf = item, None
|
||||
else:
|
||||
continue
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
tag = canonical.get(raw.strip().lower())
|
||||
if tag is None or tag in seen:
|
||||
continue
|
||||
try:
|
||||
c = float(conf) if conf is not None else 0.5
|
||||
except (ValueError, TypeError):
|
||||
c = 0.5
|
||||
c = max(0.0, min(1.0, c))
|
||||
out.append({"tag": tag, "confidence": round(c, 3)})
|
||||
seen.add(tag)
|
||||
if len(out) >= max_suggestions:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def suggest(
|
||||
provider: BaseProvider,
|
||||
draft: Draft,
|
||||
universe: list[str],
|
||||
max_suggestions: int = DEFAULT_MAX_SUGGESTIONS,
|
||||
) -> list[dict]:
|
||||
"""Orchestrate one suggestion call. Returns [] for an empty draft or
|
||||
empty universe (no model call), and for any provider/parse failure."""
|
||||
if draft.is_empty() or not universe:
|
||||
return []
|
||||
system, history = _build_messages(draft, universe, max_suggestions)
|
||||
try:
|
||||
text = provider.send(system, history)
|
||||
except Exception as exc: # provider/network failure → silent empty
|
||||
log.warning("tag-suggest provider failed: %s", exc)
|
||||
return []
|
||||
return parse_reply(text, universe, max_suggestions)
|
||||
@@ -73,6 +73,19 @@ def _siteverify_url() -> str:
|
||||
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
|
||||
class VerifyOutcome:
|
||||
"""Result of a Turnstile siteverify call.
|
||||
@@ -98,7 +111,7 @@ class VerifyOutcome:
|
||||
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.
|
||||
|
||||
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"
|
||||
* 'missing-token' / 'failed' / 'network' → 400 "verification failed"
|
||||
|
||||
Tests monkeypatch `httpx.post` (or set `TURNSTILE_SITEVERIFY_URL`
|
||||
+ a MockTransport client) to avoid touching the real CloudFlare
|
||||
endpoint. No real keys are ever embedded in tests.
|
||||
Async (I4, security-audit-0026): the siteverify call is awaited on an
|
||||
`httpx.AsyncClient` so a slow CloudFlare response can't block the
|
||||
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()
|
||||
required = _required()
|
||||
@@ -133,7 +151,7 @@ def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOu
|
||||
data["remoteip"] = client_ip
|
||||
|
||||
try:
|
||||
response = httpx.post(_siteverify_url(), data=data, timeout=10.0)
|
||||
response = await _siteverify_post(_siteverify_url(), data)
|
||||
payload = response.json()
|
||||
except Exception as exc: # network, JSON parse, etc.
|
||||
log.warning("Turnstile siteverify call failed: %s", exc)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Roadmap #26 (rfc-app v0.22.0): the optional "What will you be using
|
||||
-- this for?" capture on the two propose surfaces.
|
||||
--
|
||||
-- The roadmap's framing names "the rfcs table" and "the PR-metadata
|
||||
-- table" for a `proposed_use_case TEXT NULL` column. In this deployment
|
||||
-- those two surfaces are the cache tables `cached_rfcs` and `cached_prs`
|
||||
-- (002_cache.sql). We add the nullable column to each, matching the
|
||||
-- existing naming convention (no NOT NULL, no default — NULL is the
|
||||
-- "left blank" sentinel the view surfaces render tastefully).
|
||||
--
|
||||
-- BUT: those tables are *cache*, rebuilt from Gitea by the §4.1
|
||||
-- reconciler (cache.py). The reconciler's INSERT...ON CONFLICT DO UPDATE
|
||||
-- sets only the columns it knows about, so an unlisted column is
|
||||
-- preserved on the update path — yet a propose/open never *writes* the
|
||||
-- column through the cache (the write path is endpoint -> Gitea ->
|
||||
-- reconcile, and the reconciler does not carry this field). So the cache
|
||||
-- column alone would always read NULL.
|
||||
--
|
||||
-- The durable home is therefore a dedicated app-truth table the propose
|
||||
-- /open endpoints write directly (keyed by the PR number, which is the
|
||||
-- stable identity for both idea PRs and rfc_branch PRs) and the view
|
||||
-- endpoints read back. This is not cache — it is canonical and survives
|
||||
-- any reconcile. The cache columns are added too for parity with the
|
||||
-- roadmap's literal shape and for any future reconciler that learns to
|
||||
-- carry the field, but the side table is the source of truth read at
|
||||
-- view time.
|
||||
|
||||
ALTER TABLE cached_rfcs ADD COLUMN proposed_use_case TEXT;
|
||||
ALTER TABLE cached_prs ADD COLUMN proposed_use_case TEXT;
|
||||
|
||||
-- Canonical, reconcile-proof store. One row per propose/open that
|
||||
-- supplied a use case. `scope` distinguishes the propose-RFC surface
|
||||
-- ('rfc') from the propose-PR-against-an-RFC surface ('pr'); `pr_number`
|
||||
-- is the join key the endpoints already have in hand. NULL/omitted use
|
||||
-- cases simply never write a row here, so absence == "left blank".
|
||||
CREATE TABLE proposed_use_cases (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('rfc', 'pr')),
|
||||
rfc_slug TEXT NOT NULL,
|
||||
pr_number INTEGER NOT NULL,
|
||||
use_case TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (scope, pr_number)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_proposed_use_cases_lookup ON proposed_use_cases (scope, pr_number);
|
||||
CREATE INDEX idx_proposed_use_cases_slug ON proposed_use_cases (scope, rfc_slug);
|
||||
@@ -0,0 +1,54 @@
|
||||
-- v0.23.0 / roadmap item #29: server-side sign-in state resume.
|
||||
--
|
||||
-- Track each authenticated user's last-viewed route + a small bag of
|
||||
-- "light" component state so that the *next* sign-in can land the user
|
||||
-- back where they left off, rather than always dropping them on the
|
||||
-- empty-state home view.
|
||||
--
|
||||
-- Storage shape (one row per user — per-user, NOT per-device, per the
|
||||
-- #29 "safe default"):
|
||||
--
|
||||
-- * `user_id` — PRIMARY KEY and FK into users(id) with cascade on
|
||||
-- delete. INTEGER to match users.id (INTEGER PRIMARY KEY
|
||||
-- AUTOINCREMENT). A deleted user automatically loses their stored
|
||||
-- resume state. One row per user means a later sign-in on any
|
||||
-- device resumes the most-recently-recorded route — the per-user
|
||||
-- model the roadmap asks for.
|
||||
--
|
||||
-- * `last_route` — the frontend pathname the user was last on
|
||||
-- (e.g. "/rfc/open-human-model"). TEXT, nullable until the first
|
||||
-- route-change POST lands. NEVER contains draft-buffer contents —
|
||||
-- it is a route only. See SPEC §6.2 "Sign-in state resume
|
||||
-- (privacy)".
|
||||
--
|
||||
-- * `last_route_state` — a JSON-encoded bag of *light* component
|
||||
-- state (scroll anchors, open-tab selection, filter chips, etc.).
|
||||
-- SQLite has no native JSONB; we store JSON as TEXT exactly as the
|
||||
-- rest of the app stores its JSON blobs (json.dumps / json.loads,
|
||||
-- cf. permission_events.details, actions.details). Nullable.
|
||||
-- PRIVACY INVARIANT: this column MUST NOT carry draft-buffer text,
|
||||
-- PR bodies, comment drafts, or any user-typed content — only
|
||||
-- ephemeral view state safe to replay. The PUT handler is the
|
||||
-- enforcement point; the column comment is the contract.
|
||||
--
|
||||
-- * `resume_enabled` — the per-user opt-out flag. 1 (default) means
|
||||
-- "resume me where I left off"; 0 means "always land on home". The
|
||||
-- PUT handler no-ops the upsert when this is 0, and the read path
|
||||
-- refuses to hand back a stored route when this is 0. A
|
||||
-- profile-settings toggle UI to flip this is a follow-up (the
|
||||
-- column + default-on behavior ship now); see CHANGELOG v0.23.0.
|
||||
--
|
||||
-- * `last_updated_at` — TEXT timestamp, app convention
|
||||
-- `datetime('now')`, matching device_trust.last_seen_at /
|
||||
-- users.last_seen_at. Refreshed on every successful upsert.
|
||||
--
|
||||
-- No new env vars. The debounce interval for the frontend route-change
|
||||
-- POST is a frontend constant (~1s), not a server knob.
|
||||
|
||||
CREATE TABLE user_session_state (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
last_route TEXT,
|
||||
last_route_state TEXT, -- JSON-encoded light state, nullable
|
||||
resume_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- v0.25.0 / security audit 0026, finding H1.
|
||||
--
|
||||
-- The OTC verify path had no attempt-limit or lockout, unlike the
|
||||
-- passcode path (015_passcode.sql gave users.passcode_failed_attempts +
|
||||
-- passcode_locked_until). This table gives the OTC verify endpoint the
|
||||
-- same per-identity lockout shape. It is keyed by email rather than
|
||||
-- user_id because an OTC sign-in may not have a users row yet — the row
|
||||
-- is provisioned only on a *successful* verify, so the lockout state has
|
||||
-- to survive independently of it.
|
||||
--
|
||||
-- The per-IP rate limiter (app/ratelimit.py) is the primary brute-force
|
||||
-- defense; this table is the parity layer that mirrors the passcode
|
||||
-- lockout and persists across restarts.
|
||||
CREATE TABLE IF NOT EXISTS otc_verify_state (
|
||||
email TEXT PRIMARY KEY,
|
||||
failed_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TEXT
|
||||
);
|
||||
@@ -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,19 @@
|
||||
"""Shared pytest fixtures for the backend suite.
|
||||
|
||||
Added in v0.27.0 (security audit 0026) alongside the new per-IP rate
|
||||
limiter. The limiters in `app.ratelimit` are process-global singletons,
|
||||
so their state survives across tests within a run; without a reset, the
|
||||
accumulated requests from earlier tests exhaust the budget and later
|
||||
tests see spurious 429s. This autouse fixture gives every test a clean
|
||||
limiter window.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app import ratelimit
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_rate_limiters():
|
||||
ratelimit._reset_all_for_tests()
|
||||
yield
|
||||
ratelimit._reset_all_for_tests()
|
||||
@@ -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
|
||||
@@ -119,19 +119,20 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
|
||||
r = client.post(f"/api/rfcs/ohm/prs/{body_pr}/merge")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# --- 7. Graduate the super-draft. ---
|
||||
# --- 7. Graduate the super-draft (in-place flip, §13). ---
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["succeeded"] is True
|
||||
d = client.get("/api/rfcs/ohm").json()
|
||||
assert d["state"] == "active"
|
||||
assert d["repo"] == "wiggleverse/rfc-0001-ohm"
|
||||
# Meta-only topology (§1): no per-RFC repo — the active RFC lives
|
||||
# in its meta entry, `repo` stays null.
|
||||
assert d["repo"] is None
|
||||
|
||||
# --- 8. Alice opens a PR on the now-active RFC's per-RFC repo. ---
|
||||
# --- 8. Alice opens a PR on the now-active RFC (meta repo). ---
|
||||
# v0.16.0 (item #12): ben is the RFC owner now; alice needs a
|
||||
# per-RFC contributor invitation to cut a branch. In the
|
||||
# production flow, ben would invite her via /invitations and
|
||||
@@ -200,8 +201,8 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
|
||||
)
|
||||
assert counters["deleted_post_merge"] >= 1, counters
|
||||
|
||||
# The branch is gone from FakeGitea + cached row flipped.
|
||||
assert active_branch not in fake.branches[("wiggleverse", "rfc-0001-ohm")]
|
||||
# The branch is gone from FakeGitea (meta repo) + cached row flipped.
|
||||
assert active_branch not in fake.branches[("wiggleverse", "meta")]
|
||||
cached = db.conn().execute(
|
||||
"SELECT state FROM cached_branches WHERE rfc_slug = 'ohm' AND branch_name = ?",
|
||||
(active_branch,),
|
||||
|
||||
@@ -11,6 +11,8 @@ from __future__ import annotations
|
||||
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
import pytest
|
||||
|
||||
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."
|
||||
|
||||
|
||||
def test_envelope_with_html_is_multipart_alternative():
|
||||
msg = build_envelope(**_base_kwargs(body_html="<p>Hello, <b>world</b>.</p>"))
|
||||
assert msg.get_content_type() == "multipart/alternative"
|
||||
# Two parts: text/plain first (so plain-text clients picking the
|
||||
# first part get the readable text), text/html second.
|
||||
parts = list(msg.iter_parts())
|
||||
assert len(parts) == 2
|
||||
assert parts[0].get_content_type() == "text/plain"
|
||||
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_body_is_guarded_not_enabled():
|
||||
# I3 (security-audit-0026): the HTML/multipart-alternative path is
|
||||
# intentionally not enabled — passing body_html must fail loudly so
|
||||
# a future caller can't silently ship unescaped user HTML (the C1
|
||||
# stored-XSS class in the mail channel). When HTML mail is enabled
|
||||
# deliberately, this test flips to assert the multipart shape.
|
||||
with pytest.raises(NotImplementedError):
|
||||
build_envelope(**_base_kwargs(body_html="<p>Hello, <b>world</b>.</p>"))
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
"""End-to-end integration tests for the Slice 5 vertical (§13 in full).
|
||||
"""End-to-end integration tests for the §13 graduation flow under the
|
||||
meta-only topology (SPEC §1, ROADMAP #36).
|
||||
|
||||
Walks the §13.3 transactional sequence end-to-end against the in-process
|
||||
FakeGitea from test_propose_vertical.py:
|
||||
Graduation is an in-place state flip on the meta entry — no per-RFC repo
|
||||
is created, the body is kept, and there is no multi-step transaction or
|
||||
rollback (§13.3). These tests walk it against the in-process FakeGitea
|
||||
from test_propose_vertical.py:
|
||||
|
||||
* Seed an owned super-draft (skipping the propose+merge + §13.1 claim
|
||||
round-trips already proven by Slice 1 and exercised in
|
||||
test_claim_opens_meta_pr below for the §13.1 surface itself).
|
||||
* Seed an owned super-draft (the §13.1 claim flow is exercised
|
||||
separately in test_claim_opens_meta_pr).
|
||||
* GET /api/rfcs/<slug>/graduate/check returns per-field validity for
|
||||
the dialog.
|
||||
* GET /api/rfcs/<slug>/blocking-prs returns the §9.8 precondition list.
|
||||
* POST /api/rfcs/<slug>/graduate?_sync=1 runs the five-step sequence
|
||||
inline. On success: per-RFC repo exists with RFC.md / README.md /
|
||||
.rfc/metadata.yaml, meta-entry body is stripped, frontmatter is
|
||||
graduated, cached_rfcs.state is 'active'.
|
||||
* §9.8 precondition gate refuses the start when a body-edit PR is open.
|
||||
* Rollback on a mid-sequence failure unwinds repo creation cleanly.
|
||||
* §13.4 chat migration: whole-doc threads under (slug, 'main') survive
|
||||
graduation unchanged — the rfc_slug is the canonical key per §2.3,
|
||||
so no data movement is needed.
|
||||
* §9.8 pre-graduation history: the new RFC's /main response surfaces
|
||||
edit-branch threads under `pre_graduation_history`.
|
||||
the two-field dialog (integer id + owners; no repo name).
|
||||
* POST /api/rfcs/<slug>/graduate?_sync=1 opens + merges the flip PR
|
||||
inline. On success: NO per-RFC repo, the meta entry is `state:
|
||||
active` with the body KEPT and `repo` null, cached_rfcs.state flips
|
||||
to 'active'.
|
||||
* An open body-edit PR no longer blocks graduation (§9.8) — they
|
||||
coexist.
|
||||
* An open-PR failure leaves the entry a super-draft (nothing created);
|
||||
a merge failure cleans up the half-open PR/branch and leaves the
|
||||
entry a super-draft.
|
||||
* §13.4: chat threads + edit branches stay put — the slug is the
|
||||
canonical key per §2.3, so nothing moves at the flip.
|
||||
|
||||
The orchestrator's `?_sync=1` seam awaits the sequence inline so the
|
||||
test can assert post-conditions on the same event loop tick. Production
|
||||
clients use the spec-described SSE shape via `/graduate/progress`.
|
||||
The orchestrator's `?_sync=1` seam awaits the flip inline so the test can
|
||||
assert post-conditions on the same event loop tick.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -110,7 +110,9 @@ def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_graduate_check_validates_three_fields(app_with_fake_gitea):
|
||||
def test_graduate_check_validates_id_and_owners(app_with_fake_gitea):
|
||||
"""Two-field dialog under meta-only: integer id + owners. No repo
|
||||
name to validate (§13.2)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
@@ -121,57 +123,46 @@ def test_graduate_check_validates_three_fields(app_with_fake_gitea):
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Happy: a fresh RFC-0001 + rfc-0001-ohm repo name.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
|
||||
# Happy: a fresh RFC-0001.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["id"]["ok"] is True
|
||||
assert d["repo"]["ok"] is True
|
||||
assert d["owners"]["ok"] is True
|
||||
assert d["blocking_prs"]["ok"] is True
|
||||
assert d["can_submit"] is True
|
||||
# No repo field in the meta-only check response.
|
||||
assert "repo" not in d
|
||||
|
||||
# ID format error — non-numeric tail.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-abcd", "repo": "rfc-0001-ohm"})
|
||||
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-abcd"})
|
||||
d = r.json()
|
||||
assert d["id"]["ok"] is False
|
||||
assert d["can_submit"] is False
|
||||
|
||||
# Repo name pattern error — leading dot.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-0001", "repo": ".bad"})
|
||||
d = r.json()
|
||||
assert d["repo"]["ok"] is False
|
||||
|
||||
|
||||
def test_graduate_check_refuses_when_no_owners(app_with_fake_gitea):
|
||||
"""An unclaimed super-draft fails the owners precondition; can_submit
|
||||
flips false even with valid id+repo."""
|
||||
flips false even with a valid id."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
# No owners — simulates an unclaimed super-draft.
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=[])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
|
||||
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
|
||||
d = r.json()
|
||||
assert d["owners"]["ok"] is False
|
||||
assert "No owners" in d["owners"]["error"]
|
||||
assert d["can_submit"] is False
|
||||
|
||||
|
||||
def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea):
|
||||
"""The full §13.3 sequence: create repo, seed files, open PR, merge
|
||||
PR, refresh cache. End state: cached_rfcs.state='active', the meta
|
||||
entry's body is stripped, the per-RFC repo has RFC.md, the audit
|
||||
log carries graduate_start → graduate_complete bracketing the
|
||||
per-step rows."""
|
||||
def test_graduate_happy_path_flips_in_place_keeping_body(app_with_fake_gitea):
|
||||
"""The meta-only flip: open + merge a frontmatter PR. End state:
|
||||
cached_rfcs.state='active', the meta entry's body is KEPT, `repo` is
|
||||
null, NO per-RFC repo exists, and the audit log carries graduate_start
|
||||
→ graduate_pr_open → graduate_pr_merge → graduate_complete."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, entry as entry_mod
|
||||
|
||||
@@ -186,60 +177,57 @@ def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea
|
||||
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0042", "repo_name": "rfc-0042-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0042", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["finished"] is True
|
||||
assert d["succeeded"] is True
|
||||
assert d["repo"] == "wiggleverse/rfc-0042-ohm"
|
||||
# No repo in the response, no per-RFC repo on Gitea.
|
||||
assert "repo" not in d
|
||||
assert ("wiggleverse", "rfc-0042-ohm") not in fake.repos
|
||||
assert not any(
|
||||
k[1].startswith("rfc-0042") for k in fake.repos
|
||||
), f"a per-RFC repo was created: {fake.repos}"
|
||||
|
||||
# 1. Per-RFC repo exists on Gitea.
|
||||
assert ("wiggleverse", "rfc-0042-ohm") in fake.repos
|
||||
# 2. Seed files landed on main.
|
||||
assert ("wiggleverse", "rfc-0042-ohm", "main", "RFC.md") in fake.files
|
||||
assert ("wiggleverse", "rfc-0042-ohm", "main", "README.md") in fake.files
|
||||
assert ("wiggleverse", "rfc-0042-ohm", "main", ".rfc/metadata.yaml") in fake.files
|
||||
rfc_md = fake.files[("wiggleverse", "rfc-0042-ohm", "main", "RFC.md")]["content"]
|
||||
assert "Open Human Model is a framework" in rfc_md
|
||||
# 3. Meta entry body is stripped + frontmatter graduated.
|
||||
# Meta entry on main: state flipped, body KEPT, repo null.
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
graduated = entry_mod.parse(meta_text)
|
||||
assert graduated.state == "active"
|
||||
assert graduated.id == "RFC-0042"
|
||||
assert graduated.repo == "wiggleverse/rfc-0042-ohm"
|
||||
assert graduated.repo is None
|
||||
assert graduated.graduated_by == "ben"
|
||||
assert graduated.graduated_at # non-empty ISO date
|
||||
assert graduated.body.strip() == ""
|
||||
# 5. cached_rfcs.state flipped to active via the inline refresh.
|
||||
assert "Open Human Model is a framework" in graduated.body
|
||||
|
||||
# cached_rfcs flipped to active via the inline refresh; body intact.
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id, repo, body FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "active"
|
||||
assert cached["rfc_id"] == "RFC-0042"
|
||||
assert cached["repo"] == "wiggleverse/rfc-0042-ohm"
|
||||
# cached body now mirrors RFC.md from the per-RFC repo.
|
||||
assert cached["repo"] is None
|
||||
assert "Open Human Model is a framework" in cached["body"]
|
||||
|
||||
# Audit log: graduate_start, graduate_repo_create, graduate_repo_seed,
|
||||
# graduate_pr_open, graduate_pr_merge, graduate_complete, in order.
|
||||
kinds = [
|
||||
r["action_kind"]
|
||||
for r in db.conn().execute(
|
||||
row["action_kind"]
|
||||
for row in db.conn().execute(
|
||||
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
|
||||
)
|
||||
]
|
||||
for needed in ("graduate_start", "graduate_repo_create",
|
||||
"graduate_repo_seed", "graduate_pr_open",
|
||||
for needed in ("graduate_start", "graduate_pr_open",
|
||||
"graduate_pr_merge", "graduate_complete"):
|
||||
assert needed in kinds, f"missing audit row {needed}: {kinds}"
|
||||
# The retired per-repo steps must NOT appear.
|
||||
for gone in ("graduate_repo_create", "graduate_repo_seed",
|
||||
"graduate_repo_delete", "graduate_rollback"):
|
||||
assert gone not in kinds, f"retired audit row present: {gone}"
|
||||
|
||||
|
||||
def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
|
||||
"""§9.8: an open meta-repo body-edit PR against rfcs/<slug>.md blocks
|
||||
graduation before the bot starts the sequence — §13.3's rollback
|
||||
complexity does not grow."""
|
||||
def test_graduate_coexists_with_open_body_edit_pr(app_with_fake_gitea):
|
||||
"""§9.8 (meta-only): an open meta-repo body-edit PR no longer blocks
|
||||
graduation — the body is kept, so they coexist. /check stays
|
||||
submittable and the flip succeeds."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
@@ -249,13 +237,11 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
# v0.16.0 (item #12): ben is the RFC owner; alice needs a per-RFC
|
||||
# contributor invitation to cut an edit branch on the super-draft.
|
||||
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
|
||||
# Cut an edit branch and open a body-edit PR (full Slice 4 path).
|
||||
# Cut an edit branch and open a body-edit PR.
|
||||
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
|
||||
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
|
||||
thread_id = view["main_thread_id"]
|
||||
@@ -279,94 +265,31 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
|
||||
f"/api/rfcs/ohm/branches/{branch}/open-pr",
|
||||
json={"title": "Add harm", "description": "Adds harm dimension."},
|
||||
).json()["pr_number"]
|
||||
assert pr_number # PR is open
|
||||
|
||||
# /blocking-prs surfaces it.
|
||||
# /check stays submittable despite the open body-edit PR.
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/rfcs/ohm/blocking-prs")
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["pr_number"] == pr_number
|
||||
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"}).json()
|
||||
assert "blocking_prs" not in d
|
||||
assert d["can_submit"] is True
|
||||
|
||||
# /check refuses can_submit.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
|
||||
d = r.json()
|
||||
assert d["blocking_prs"]["ok"] is False
|
||||
assert d["can_submit"] is False
|
||||
|
||||
# POST refuses with 409 — the bot never starts the sequence.
|
||||
# The flip succeeds — coexists with the open body-edit PR.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
assert "blocking graduation" in r.text or "block" in r.text
|
||||
|
||||
|
||||
def test_graduate_rollback_on_step_2_seed_failure(app_with_fake_gitea):
|
||||
"""Step 2 (seed files) fails partway → the orchestrator rolls back
|
||||
step 1 (delete the repo) and records the rollback in the audit log.
|
||||
The cached_rfcs row stays at 'super-draft'."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
from app.bot import Bot
|
||||
from app.gitea import Gitea, GiteaError
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Monkey-patch the bot to fail on seed_graduated_rfc. The repo
|
||||
# has already been created in step 1; the rollback must delete it.
|
||||
orig_seed = Bot.seed_graduated_rfc
|
||||
async def boom(self, *args, **kwargs):
|
||||
raise GiteaError(500, "simulated seed failure for rollback test")
|
||||
Bot.seed_graduated_rfc = boom
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0003", "repo_name": "rfc-0003-ohm",
|
||||
"owners": ["ben"]},
|
||||
)
|
||||
finally:
|
||||
Bot.seed_graduated_rfc = orig_seed
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["finished"] is True
|
||||
assert d["succeeded"] is False
|
||||
|
||||
# Repo deleted as the rollback inverse.
|
||||
assert ("wiggleverse", "rfc-0003-ohm") not in fake.repos
|
||||
# Meta entry unchanged.
|
||||
assert r.json()["succeeded"] is True
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "super-draft"
|
||||
assert cached["rfc_id"] is None
|
||||
# Audit log carries the rollback row.
|
||||
kinds = [
|
||||
r["action_kind"]
|
||||
for r in db.conn().execute(
|
||||
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
|
||||
)
|
||||
]
|
||||
assert "graduate_start" in kinds
|
||||
assert "graduate_repo_create" in kinds
|
||||
assert "graduate_repo_delete" in kinds
|
||||
assert "graduate_rollback" in kinds
|
||||
assert "graduate_complete" not in kinds
|
||||
assert cached["state"] == "active"
|
||||
|
||||
|
||||
def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
|
||||
"""Step 3 (open PR) fails → the orchestrator rolls back steps 2 and
|
||||
1 (deleting the repo, which reclaims the seed commits at the same
|
||||
time). The meta-repo entry is untouched."""
|
||||
def test_graduate_open_pr_failure_leaves_super_draft(app_with_fake_gitea):
|
||||
"""An open-PR failure creates nothing — the entry stays a super-draft
|
||||
with its body intact and no graduation PR on the meta repo."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
from app.bot import Bot
|
||||
@@ -387,19 +310,92 @@ def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0007", "repo_name": "rfc-0007-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0007", "owners": ["ben"]},
|
||||
)
|
||||
finally:
|
||||
Bot.open_graduation_pr = orig_open_pr
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["succeeded"] is False
|
||||
# Repo torn down.
|
||||
assert ("wiggleverse", "rfc-0007-ohm") not in fake.repos
|
||||
# Meta entry's body still has the pitch (not stripped).
|
||||
|
||||
# Entry untouched: still super-draft, body intact on main.
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "super-draft"
|
||||
assert cached["rfc_id"] is None
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
assert "Open Human Model is a framework" in meta_text
|
||||
|
||||
kinds = [
|
||||
row["action_kind"]
|
||||
for row in db.conn().execute(
|
||||
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
|
||||
)
|
||||
]
|
||||
assert "graduate_start" in kinds
|
||||
assert "graduate_failed" in kinds
|
||||
assert "graduate_complete" not in kinds
|
||||
|
||||
|
||||
def test_graduate_merge_failure_cleans_up_pr(app_with_fake_gitea):
|
||||
"""A merge failure leaves the flip PR open on its dash-suffixed
|
||||
branch; the orchestrator closes the PR and deletes the branch so
|
||||
failed attempts don't accumulate. The entry stays a super-draft —
|
||||
the flip PR's commit was on a branch, not on main."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
from app.bot import Bot
|
||||
from app.gitea import GiteaError
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
orig_merge = Bot.merge_graduation_pr
|
||||
async def boom(self, *args, **kwargs):
|
||||
raise GiteaError(502, "simulated merge failure")
|
||||
Bot.merge_graduation_pr = boom
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0009", "owners": ["ben"]},
|
||||
)
|
||||
finally:
|
||||
Bot.merge_graduation_pr = orig_merge
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["succeeded"] is False
|
||||
|
||||
# Entry stays super-draft on main (the flip never merged).
|
||||
cached = db.conn().execute(
|
||||
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "super-draft"
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
assert "state: super-draft" in meta_text
|
||||
|
||||
# The dash-suffixed graduation branch was cleaned up.
|
||||
grad_branches = [
|
||||
name for (o, repo), branches in fake.branches.items()
|
||||
if (o, repo) == ("wiggleverse", "meta")
|
||||
for name in branches
|
||||
if name.startswith("graduate-ohm-")
|
||||
]
|
||||
assert grad_branches == [], f"leftover graduation branch: {grad_branches}"
|
||||
|
||||
kinds = [
|
||||
row["action_kind"]
|
||||
for row in db.conn().execute(
|
||||
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
|
||||
)
|
||||
]
|
||||
assert "graduate_pr_open" in kinds
|
||||
assert "graduate_failed" in kinds
|
||||
assert "graduate_complete" not in kinds
|
||||
|
||||
|
||||
def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
|
||||
"""A second graduation request for a slug already in-flight is refused."""
|
||||
@@ -414,17 +410,14 @@ def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Seed a synthetic in-flight state so the registry refuses the second.
|
||||
st = api_graduation._new_active(
|
||||
"ohm", rfc_id="RFC-0001", repo_name="rfc-0001-ohm",
|
||||
repo_full="wiggleverse/rfc-0001-ohm", owners=["ben"], arbiters=["ben"],
|
||||
"ohm", rfc_id="RFC-0001", owners=["ben"], arbiters=["ben"],
|
||||
)
|
||||
st.finished = False
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
finally:
|
||||
@@ -432,11 +425,9 @@ def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
|
||||
|
||||
|
||||
def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_gitea):
|
||||
"""§13.4: chat threads on the super-draft's canonical-body view
|
||||
(`branch_name='main'`) are interpreted as the new RFC's main-thread
|
||||
after graduation. The rows don't move — the rfc_slug is canonical
|
||||
per §2.3 — so the same thread surfaces from both before and after
|
||||
the graduation."""
|
||||
"""§13.4: chat threads on the entry's main view (`branch_name='main'`)
|
||||
stay put across the flip — the rfc_slug is canonical per §2.3 — so the
|
||||
same thread surfaces from /branches/main before and after graduation."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
@@ -448,9 +439,6 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Materialize a whole-doc main thread + a message on it. This
|
||||
# mirrors what reading the canonical-body view would create
|
||||
# lazily (§8.12 / api_branches._ensure_branch_chat_thread).
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, created_by)
|
||||
@@ -466,32 +454,26 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git
|
||||
(thread_id,),
|
||||
)
|
||||
|
||||
# Graduate.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0099", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# The thread row's identity is unchanged.
|
||||
row = db.conn().execute(
|
||||
"SELECT id, branch_name FROM threads WHERE id = ?", (thread_id,),
|
||||
).fetchone()
|
||||
assert row["branch_name"] == "main"
|
||||
# The new RFC's main view surfaces the same thread id as its
|
||||
# whole-doc main thread (the entry is now active, the branch
|
||||
# 'main' now points at the per-RFC repo's main, but the
|
||||
# `(rfc_slug, branch_name)` key remains the canonical anchor).
|
||||
r = client.get("/api/rfcs/ohm/branches/main")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["main_thread_id"] == thread_id
|
||||
|
||||
|
||||
def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea):
|
||||
"""§9.8: after graduation, threads on meta-repo edit branches stay
|
||||
attached to their original branch_name and surface from the new
|
||||
RFC's /main response under `pre_graduation_history`."""
|
||||
def test_edit_branch_surfaces_normally_after_graduation(app_with_fake_gitea):
|
||||
"""§13.4 (meta-only): after graduation an edit branch is a *current*
|
||||
branch of the now-active RFC — it surfaces in the normal `branches`
|
||||
list, and there is no separate `pre_graduation_history` set (that
|
||||
affordance is legacy per-repo only)."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
@@ -501,10 +483,8 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
# v0.16.0 (item #12): alice needs per-RFC contributor access.
|
||||
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
|
||||
|
||||
# Alice cuts an edit branch and starts chatting on it.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
|
||||
@@ -513,30 +493,25 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO thread_messages (thread_id, role, author_user_id, text)
|
||||
VALUES (?, 'user', 2, 'pre-graduation note on an edit branch')
|
||||
VALUES (?, 'user', 2, 'note on an edit branch')
|
||||
""",
|
||||
(thread_id,),
|
||||
)
|
||||
|
||||
# Ben graduates.
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0100", "repo_name": "rfc-0100-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0100", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# /main on the now-active RFC surfaces the pre-graduation history.
|
||||
r = client.get("/api/rfcs/ohm/main")
|
||||
d = r.json()
|
||||
d = client.get("/api/rfcs/ohm/main").json()
|
||||
assert d["state"] == "active"
|
||||
hist = d["pre_graduation_history"]
|
||||
assert len(hist) >= 1
|
||||
assert any(h["branch_name"] == branch for h in hist)
|
||||
target = next(h for h in hist if h["branch_name"] == branch)
|
||||
assert target["message_count"] >= 1
|
||||
# The edit branch is a current branch; no pre-graduation hop.
|
||||
assert d["pre_graduation_history"] == []
|
||||
assert any(b["name"] == branch for b in d["branches"]), \
|
||||
f"edit branch not in branches: {[b['name'] for b in d['branches']]}"
|
||||
|
||||
|
||||
def test_claim_opens_meta_pr(app_with_fake_gitea):
|
||||
@@ -560,12 +535,10 @@ def test_claim_opens_meta_pr(app_with_fake_gitea):
|
||||
d = r.json()
|
||||
assert d["branch_name"] == "claim/ohm"
|
||||
|
||||
# The PR body's diff carries Alice in owners.
|
||||
text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"]
|
||||
ent = entry_mod.parse(text)
|
||||
assert "alice" in ent.owners
|
||||
|
||||
# cached_prs records pr_kind='meta_claim' via refresh_meta_pulls.
|
||||
row = db.conn().execute(
|
||||
"SELECT pr_kind FROM cached_prs WHERE pr_number = ?", (d["pr_number"],),
|
||||
).fetchone()
|
||||
|
||||
@@ -339,10 +339,10 @@ def test_hygiene_action_kinds_fire_no_notifications(app_with_fake_gitea):
|
||||
|
||||
|
||||
def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea):
|
||||
"""§19.2 candidate Slice 8 settles: when graduation rolls back
|
||||
after step 3 (open_pr), the `graduate-<slug>-<6hex>` branch is
|
||||
deleted alongside the PR close so failed-graduation branches
|
||||
don't accumulate on the meta repo across retries."""
|
||||
"""Meta-only (§13.3): when the flip's merge fails after the PR is
|
||||
open, the orchestrator closes the PR and deletes its
|
||||
`graduate-<slug>-<6hex>` branch so failed attempts don't accumulate
|
||||
on the meta repo across retries."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
from app.bot import Bot
|
||||
@@ -359,24 +359,23 @@ def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea):
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Force a step-4 (merge_pr) failure so step 3 (open_pr) has
|
||||
# already landed and the rollback exercises the branch cleanup.
|
||||
# Force a merge_pr failure so the flip PR (open_pr) has already
|
||||
# landed and the cleanup exercises the branch deletion.
|
||||
orig_merge = Bot.merge_graduation_pr
|
||||
async def boom(self, *args, **kwargs):
|
||||
raise GiteaError(502, "simulated merge failure for rollback test")
|
||||
raise GiteaError(502, "simulated merge failure for cleanup test")
|
||||
Bot.merge_graduation_pr = boom
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0099", "owners": ["ben"]},
|
||||
)
|
||||
finally:
|
||||
Bot.merge_graduation_pr = orig_merge
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["succeeded"] is False
|
||||
|
||||
# The dash-suffixed graduation branch was deleted on rollback.
|
||||
# The dash-suffixed graduation branch was deleted on cleanup.
|
||||
meta_branches = fake.branches[("wiggleverse", "meta")]
|
||||
graduation_branches = [n for n in meta_branches if n.startswith("graduate-ohm-")]
|
||||
assert graduation_branches == [], (
|
||||
|
||||
@@ -444,6 +444,18 @@ def tmp_env(monkeypatch):
|
||||
# the dev-bypass path monkeypatch `RFC_APP_INSECURE_WEBHOOKS=1`.
|
||||
"GITEA_WEBHOOK_SECRET": "test-webhook-secret-for-signature-verification",
|
||||
"ENABLED_MODELS": "claude",
|
||||
# v0.27.0 (audit 0026 M4): the session cookie now defaults to
|
||||
# Secure. The TestClient talks plain http://testserver, so a
|
||||
# Secure cookie is never sent back and every authenticated flow
|
||||
# would fail. Tests opt out explicitly, exactly as a dev box on
|
||||
# plain http does.
|
||||
"SESSION_COOKIE_SECURE": "false",
|
||||
# v0.27.0 (audit 0026 M5): the bounce webhook fails closed (503)
|
||||
# when its secret is unset. Tests exercise the legacy behavioral
|
||||
# path via the documented dev opt-in, mirroring the
|
||||
# RFC_APP_INSECURE_WEBHOOKS bypass above. Tests that assert the
|
||||
# fail-closed default delenv this key themselves.
|
||||
"RFC_APP_INSECURE_BOUNCE_WEBHOOK": "1",
|
||||
}
|
||||
for k, v in env.items():
|
||||
monkeypatch.setenv(k, v)
|
||||
@@ -565,6 +577,97 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
|
||||
assert ("merge_proposal", "ben") in kinds
|
||||
|
||||
|
||||
def test_merged_idea_pr_with_deleted_branch_clears_proposal(app_with_fake_gitea):
|
||||
"""Regression: a merged idea PR whose branch was deleted must not
|
||||
linger as a 'pending idea' ghost.
|
||||
|
||||
Found via the ROADMAP #35 operator authoring lane: merging an idea
|
||||
PR from the CLI with `--delete-branch` makes Gitea report the PR's
|
||||
`head.ref` as the synthetic `refs/pull/<N>/head` sentinel instead of
|
||||
`propose/<slug>`. `refresh_meta_pulls` derives the slug from the
|
||||
branch name, so the sentinel parsed to slug=None, the row was skipped,
|
||||
and `cached_prs.state` stayed frozen at 'open' — leaving the entry
|
||||
showing as BOTH a super-draft (cached_rfcs reconciled off the push)
|
||||
AND a pending idea (cached_prs never updated). The fix recovers the
|
||||
original branch name from the already-stored cached_prs row.
|
||||
|
||||
The web UX never tripped this because it leaves the branch in place
|
||||
(the repo's default_delete_branch_after_merge is false).
|
||||
|
||||
The bug only manifests on an out-of-band merge (the PR merged +
|
||||
branch deleted directly in Gitea, with the in-app merge endpoint never
|
||||
reconciling the row while the branch still existed) -- which is exactly
|
||||
what the #35 CLI lane does. An in-app merge reconciles cached_prs to
|
||||
'merged' before the branch is gone, so it never trips this; the test
|
||||
therefore drives the Gitea state directly to reproduce the CLI path.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, cache, gitea as gitea_mod
|
||||
from app.config import load_config
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "Informed Consent",
|
||||
"slug": "informed-consent",
|
||||
"pitch": "A first-class definition of consent in OHM.",
|
||||
"tags": [],
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_number = r.json()["pr_number"]
|
||||
|
||||
# The proposal is cached as an open idea PR.
|
||||
items = client.get("/api/proposals").json()["items"]
|
||||
assert any(i["pr_number"] == pr_number for i in items)
|
||||
|
||||
# Out-of-band CLI merge (ROADMAP #35 lane): the PR is merged AND
|
||||
# its branch deleted directly in Gitea, WITHOUT the in-app merge
|
||||
# endpoint ever running. So cached_prs still says state='open' and
|
||||
# Gitea now reports the merged PR's head.ref as the sentinel. This
|
||||
# is the exact state `rfc-authoring.sh pr-merge --delete-branch`
|
||||
# leaves behind.
|
||||
for pr in fake.pulls[("wiggleverse", "meta")]:
|
||||
if pr["number"] == pr_number:
|
||||
# land the file on main (the push side already reconciles
|
||||
# cached_rfcs into a super-draft via the webhook/sweep)
|
||||
for (o, rp, br, p), data in list(fake.files.items()):
|
||||
if (o, rp, br) == ("wiggleverse", "meta", "propose/informed-consent"):
|
||||
fake.files[("wiggleverse", "meta", "main", p)] = dict(data)
|
||||
pr["state"] = "closed"
|
||||
pr["merged"] = True
|
||||
pr["merged_at"] = "2026-05-29T12:13:00Z"
|
||||
pr["closed_at"] = "2026-05-29T12:13:00Z"
|
||||
pr["merge_commit_sha"] = fake._next_sha()
|
||||
pr["head"]["ref"] = f"refs/pull/{pr_number}/head"
|
||||
fake.branches[("wiggleverse", "meta")].pop("propose/informed-consent", None)
|
||||
|
||||
# The reconcile sweep runs (a later webhook, or the 5-min safety net).
|
||||
import asyncio
|
||||
cfg = load_config()
|
||||
gclient = gitea_mod.Gitea(cfg)
|
||||
asyncio.run(cache.refresh_meta_repo(cfg, gclient))
|
||||
asyncio.run(cache.refresh_meta_pulls(cfg, gclient))
|
||||
|
||||
# The bug: this used to still list informed-consent (frozen 'open'
|
||||
# row, slug unparseable from the sentinel). The fix recovers the
|
||||
# stored branch name, so the row reconciles to merged and the ghost
|
||||
# is gone.
|
||||
assert client.get("/api/proposals").json()["items"] == []
|
||||
|
||||
# And the cached_prs row is correctly merged, not a frozen 'open'.
|
||||
row = db.conn().execute(
|
||||
"SELECT state FROM cached_prs WHERE pr_number = ?", (pr_number,)
|
||||
).fetchone()
|
||||
assert row["state"] == "merged", f"expected merged, got {row['state']}"
|
||||
|
||||
# The super-draft itself is unaffected — still in the catalog.
|
||||
items = client.get("/api/rfcs").json()["items"]
|
||||
assert any(i["slug"] == "informed-consent" and i["state"] == "super-draft" for i in items)
|
||||
|
||||
|
||||
def test_slug_uniqueness_enforced(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
app, _fake = app_with_fake_gitea
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""End-to-end vertical for roadmap #26 (rfc-app v0.22.0): the optional
|
||||
"What will you be using this for?" capture on the two propose surfaces.
|
||||
|
||||
Reuses the FakeGitea + session helpers from test_propose_vertical.py and
|
||||
the active-RFC seed from test_rfc_view_vertical.py. Proves:
|
||||
|
||||
(a) propose-RFC persists and returns `proposed_use_case` when supplied,
|
||||
and the value survives onto the merged super-draft's RFC view;
|
||||
(b) propose-RFC accepts a NULL / omitted use case ("left blank");
|
||||
(c) propose-PR persists and returns `proposed_use_case` when supplied,
|
||||
and accepts a NULL / omitted one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_pr_flow_vertical import _cut_branch_and_accept_change
|
||||
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# propose-RFC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_propose_rfc_persists_and_returns_use_case(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
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": "Open Human Model",
|
||||
"slug": "open-human-model",
|
||||
"pitch": "A shared definition of what we mean by *human*.",
|
||||
"tags": ["identity"],
|
||||
"proposed_use_case": "Wiring OHM into the OpenXML consent surface.",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_number = r.json()["pr_number"]
|
||||
|
||||
# The pending-idea list carries the use case.
|
||||
items = client.get("/api/proposals").json()["items"]
|
||||
assert items[0]["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
|
||||
|
||||
# The pending-idea detail view carries it too.
|
||||
proposal = client.get(f"/api/proposals/{pr_number}").json()
|
||||
assert proposal["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
|
||||
|
||||
# Merge as owner; the use case survives onto the RFC view (looked
|
||||
# up by slug from the canonical side table, since the idea PR
|
||||
# closes on merge).
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test")
|
||||
r = client.post(f"/api/proposals/{pr_number}/merge")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
view = client.get("/api/rfcs/open-human-model").json()
|
||||
assert view["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
|
||||
|
||||
|
||||
def test_propose_rfc_use_case_optional(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=3, login="carol", role="contributor")
|
||||
sign_in_as(client, user_id=3, gitea_login="carol", display_name="Carol", role="contributor")
|
||||
|
||||
# Omitted entirely.
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "No Use Case", "slug": "no-use-case", "pitch": "p", "tags": [],
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_a = r.json()["pr_number"]
|
||||
|
||||
# Explicit null.
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "Null Use Case", "slug": "null-use-case", "pitch": "p",
|
||||
"tags": [], "proposed_use_case": None,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_b = r.json()["pr_number"]
|
||||
|
||||
# Blank/whitespace — treated as "left blank", no row written.
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "Blank Use Case", "slug": "blank-use-case", "pitch": "p",
|
||||
"tags": [], "proposed_use_case": " ",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_c = r.json()["pr_number"]
|
||||
|
||||
for pr in (pr_a, pr_b, pr_c):
|
||||
assert client.get(f"/api/proposals/{pr}").json()["proposed_use_case"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# propose-PR (against an active RFC)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_propose_pr_persists_and_returns_use_case(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
branch, _ = _cut_branch_and_accept_change(
|
||||
client, fake, slug="ohm",
|
||||
original="Open Human Model is a framework for representing humans.",
|
||||
proposed="Open Human Model is a framework for representing humans across systems.",
|
||||
)
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/open-pr",
|
||||
json={
|
||||
"title": "Tighten the opening",
|
||||
"description": "Scope to systems.",
|
||||
"proposed_use_case": "Building a cross-system consent registry.",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
pr_number = r.json()["pr_number"]
|
||||
|
||||
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
|
||||
assert pr["proposed_use_case"] == "Building a cross-system consent registry."
|
||||
|
||||
|
||||
def test_propose_pr_use_case_optional(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
branch, _ = _cut_branch_and_accept_change(
|
||||
client, fake, slug="ohm",
|
||||
original="It defines consent, trait, and agency in compatible terms.",
|
||||
proposed="It defines consent, trait, harm, and agency in compatible terms.",
|
||||
)
|
||||
# No proposed_use_case key at all.
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/open-pr",
|
||||
json={"title": "Add harm", "description": "Name harm explicitly."},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
pr_number = r.json()["pr_number"]
|
||||
|
||||
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
|
||||
assert pr["proposed_use_case"] is None
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Roadmap #28 Part 1 — auto-link RFC references in PR text + comments.
|
||||
|
||||
Two layers:
|
||||
|
||||
* Unit tests over the pure scanner (`rfc_links.segment_text` /
|
||||
`_keys_for` / `LinkIndex`) — the matching rules and their
|
||||
false-positive guards, no DB.
|
||||
* End-to-end tests that the PR description, PR review comments, and
|
||||
PR-less discussion comments all surface `*_segments` enriched against
|
||||
the live accepted-RFC corpus, with self-references suppressed.
|
||||
|
||||
Reuses the FakeGitea + session helpers from test_propose_vertical.py and
|
||||
the active-RFC seed from test_rfc_view_vertical.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app import rfc_links
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
grant_rfc_collaborator,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
|
||||
from test_pr_flow_vertical import _cut_branch_and_accept_change
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit — the pure scanner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _idx(*terms):
|
||||
"""Build a LinkIndex from raw (key, slug, title) tuples (keys lower)."""
|
||||
return rfc_links.LinkIndex(list(terms))
|
||||
|
||||
|
||||
def test_empty_text_is_single_empty_segment():
|
||||
assert rfc_links.segment_text("", []) == [{"type": "text", "text": ""}]
|
||||
assert rfc_links.segment_text(None, []) == [{"type": "text", "text": ""}]
|
||||
|
||||
|
||||
def test_no_terms_returns_plain_text():
|
||||
out = rfc_links.segment_text("hello world", [])
|
||||
assert out == [{"type": "text", "text": "hello world"}]
|
||||
|
||||
|
||||
def test_multiword_title_links_and_preserves_casing():
|
||||
idx = _idx(("open human model", "open-human-model", "Open Human Model"))
|
||||
out = idx.segment("See the Open Human Model for details.")
|
||||
assert out == [
|
||||
{"type": "text", "text": "See the "},
|
||||
{"type": "rfc", "slug": "open-human-model", "label": "Open Human Model",
|
||||
"title": "Open Human Model"},
|
||||
{"type": "text", "text": " for details."},
|
||||
]
|
||||
|
||||
|
||||
def test_match_is_case_insensitive():
|
||||
idx = _idx(("open human model", "open-human-model", "Open Human Model"))
|
||||
out = idx.segment("see the OPEN HUMAN MODEL")
|
||||
assert out[-1] == {"type": "rfc", "slug": "open-human-model",
|
||||
"label": "OPEN HUMAN MODEL", "title": "Open Human Model"}
|
||||
|
||||
|
||||
def test_word_boundary_prevents_substring_match():
|
||||
# "harm" must not match inside "charming" / "harmless".
|
||||
idx = _idx(("rfc-0001", "open-human-model", "Open Human Model"))
|
||||
out = idx.segment("a charming rfc-00012 not real")
|
||||
# rfc-0001 is a prefix of rfc-00012 but the trailing '2' is a word char,
|
||||
# so no match — the whole string stays plain text.
|
||||
assert out == [{"type": "text", "text": "a charming rfc-00012 not real"}]
|
||||
|
||||
|
||||
def test_rfc_id_token_links():
|
||||
idx = _idx(("rfc-0001", "open-human-model", "Open Human Model"))
|
||||
out = idx.segment("as established in RFC-0001.")
|
||||
assert out[1] == {"type": "rfc", "slug": "open-human-model",
|
||||
"label": "RFC-0001", "title": "Open Human Model"}
|
||||
|
||||
|
||||
def test_longest_match_wins():
|
||||
# A bare "Open" term and the full title both present; the full title
|
||||
# (longer) must win at the position.
|
||||
idx = _idx(
|
||||
("open", "open", "Open"),
|
||||
("open human model", "open-human-model", "Open Human Model"),
|
||||
)
|
||||
out = idx.segment("the Open Human Model")
|
||||
assert out[-1]["slug"] == "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():
|
||||
keys = lambda **kw: set(rfc_links._keys_for(**kw))
|
||||
# rfc_id always contributes.
|
||||
assert "rfc-0001" in keys(slug="x", title="X", rfc_id="RFC-0001")
|
||||
# multi-word title contributes; single common word does NOT.
|
||||
assert "open human model" in keys(slug="ohm", title="Open Human Model", rfc_id=None)
|
||||
assert keys(slug="human", title="Human", rfc_id=None) == set()
|
||||
# hyphenated slug contributes; single-token slug does NOT.
|
||||
assert "open-human-model" in keys(slug="open-human-model", title="X", rfc_id=None)
|
||||
assert "ohm" not in keys(slug="ohm", title="OHM", rfc_id=None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end — enrichment surfaces on the read paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _open_pr_on(client, fake, *, host_slug: str, description: str):
|
||||
"""Seed branch + accepted change on host_slug and open a PR. Returns
|
||||
the pr_number."""
|
||||
# `original` must exist verbatim in SEED_BODY or the accept is "stale".
|
||||
branch, _ = _cut_branch_and_accept_change(
|
||||
client, fake, slug=host_slug,
|
||||
original="It defines consent, trait, and agency in compatible terms.",
|
||||
proposed="It defines consent, trait, harm, and agency in compatible terms.",
|
||||
)
|
||||
r = client.post(
|
||||
f"/api/rfcs/{host_slug}/branches/{branch}/open-pr",
|
||||
json={"title": "A change", "description": description},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["pr_number"]
|
||||
|
||||
|
||||
def _rfc_segments(segments):
|
||||
return [s for s in segments if s["type"] == "rfc"]
|
||||
|
||||
|
||||
def test_pr_description_autolinks_other_rfc(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
# Two accepted RFCs: a host for the PR + a referenceable target.
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
|
||||
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 definition.",
|
||||
)
|
||||
r = client.get(f"/api/rfcs/ohm/prs/{pr_number}")
|
||||
assert r.status_code == 200, r.text
|
||||
pr = r.json()
|
||||
links = _rfc_segments(pr["description_segments"])
|
||||
assert len(links) == 1
|
||||
assert links[0]["slug"] == "open-human-model"
|
||||
assert links[0]["label"] == "Open Human Model"
|
||||
# The plain text is still present for non-segment callers (the bot
|
||||
# appends a §6.5 On-behalf-of trailer, so this is a containment check).
|
||||
assert "This builds on the Open Human Model definition." in pr["description"]
|
||||
|
||||
|
||||
def test_pr_review_comment_autolinked(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
|
||||
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="plain.")
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/prs/{pr_number}/review",
|
||||
json={"text": "See Open Human Model and RFC-0001.", "anchor_payload": {}},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
|
||||
all_msgs = [m for msgs in pr["messages_by_thread"].values() for m in msgs]
|
||||
review_msgs = [m for m in all_msgs if "Open Human Model" in (m["text"] or "")]
|
||||
assert review_msgs, "review comment not found in payload"
|
||||
links = _rfc_segments(review_msgs[0]["text_segments"])
|
||||
# Both "Open Human Model" (title) and "RFC-0001" (id) point to the
|
||||
# one referenceable RFC.
|
||||
assert {s["slug"] for s in links} == {"open-human-model"}
|
||||
assert {s["label"] for s in links} == {"Open Human Model", "RFC-0001"}
|
||||
|
||||
|
||||
def test_discussion_comment_autolinked(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
|
||||
# alice is the seeded owner of ohm (owners=["alice"]); grant the
|
||||
# per-RFC collaborator row explicitly so the #12 discuss gate passes.
|
||||
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"message": "Compare with the Open Human Model."},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
thread_id = r.json()["thread_id"]
|
||||
|
||||
r = client.get(f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages")
|
||||
assert r.status_code == 200, r.text
|
||||
msgs = r.json()["messages"]
|
||||
assert msgs and "text_segments" in msgs[0]
|
||||
links = _rfc_segments(msgs[0]["text_segments"])
|
||||
assert len(links) == 1
|
||||
assert links[0]["slug"] == "open-human-model"
|
||||
|
||||
|
||||
def test_self_reference_not_linked(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
# The host RFC has a multi-word title, so absent exclude_slug it
|
||||
# WOULD self-link. exclude_slug must suppress it.
|
||||
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
pr_number = _open_pr_on(
|
||||
client, fake, host_slug="open-human-model",
|
||||
description="Refines the Open Human Model definition.",
|
||||
)
|
||||
pr = client.get(f"/api/rfcs/open-human-model/prs/{pr_number}").json()
|
||||
assert _rfc_segments(pr["description_segments"]) == []
|
||||
@@ -0,0 +1,140 @@
|
||||
"""End-to-end integration tests for the v0.23.0 sign-in state-resume
|
||||
vertical (§6.2, roadmap item #29).
|
||||
|
||||
New behavior: each authenticated user's last-viewed route + a small bag
|
||||
of light view state is tracked server-side, so the next sign-in can land
|
||||
them back where they left off rather than on the empty-state home view.
|
||||
|
||||
The tests below prove:
|
||||
|
||||
* `PUT /api/me/last-state` requires auth — an anonymous client gets
|
||||
401, and nothing is stored.
|
||||
* An authenticated PUT upserts the route, and the stored route is
|
||||
read back for that user off `GET /api/auth/me` (`last_route`).
|
||||
* A second PUT overwrites (upsert, one row per user) — the latest
|
||||
route wins.
|
||||
* `last_route_state` round-trips as decoded JSON on `/api/auth/me`.
|
||||
* Per-user isolation: user A's stored route is not visible to user B.
|
||||
* `resume_enabled = 0` disables resume: the PUT no-ops (does not
|
||||
rewrite the stored route) and `/api/auth/me` hands back a null
|
||||
`last_route` even though a stored row exists.
|
||||
|
||||
The fakes from `test_propose_vertical` give us a working app harness +
|
||||
the `sign_in_as` / `provision_user_row` seams.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
def test_put_last_state_requires_auth(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
# Anonymous — no session cookie set.
|
||||
r = client.put("/api/me/last-state", json={"route": "/rfc/open-human-model"})
|
||||
assert r.status_code == 401
|
||||
# Nothing landed in the table.
|
||||
row = db.conn().execute("SELECT COUNT(*) AS n FROM user_session_state").fetchone()
|
||||
assert row["n"] == 0
|
||||
|
||||
|
||||
def test_put_last_state_upserts_and_reads_back(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
|
||||
# First POST stores a route + light state.
|
||||
r = client.put(
|
||||
"/api/me/last-state",
|
||||
json={"route": "/rfc/open-human-model", "state": {"tab": "discussion", "scroll": 420}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["stored"] is True
|
||||
|
||||
# /api/auth/me hands the route + decoded state back.
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["authenticated"] is True
|
||||
assert me["user"]["resume_enabled"] is True
|
||||
assert me["user"]["last_route"] == "/rfc/open-human-model"
|
||||
assert me["user"]["last_route_state"] == {"tab": "discussion", "scroll": 420}
|
||||
|
||||
# A later POST overwrites — one row per user, latest wins.
|
||||
r = client.put("/api/me/last-state", json={"route": "/proposals/7"})
|
||||
assert r.status_code == 200
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["last_route"] == "/proposals/7"
|
||||
# state was omitted on the second POST → cleared to null.
|
||||
assert me["user"]["last_route_state"] is None
|
||||
|
||||
|
||||
def test_last_state_is_per_user(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
client.put("/api/me/last-state", json={"route": "/rfc/alice-route"})
|
||||
|
||||
# Switch to bob — he has no stored route yet.
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor", email="bob@test")
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["last_route"] is None
|
||||
|
||||
client.put("/api/me/last-state", json={"route": "/rfc/bob-route"})
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["last_route"] == "/rfc/bob-route"
|
||||
|
||||
# Back to alice — her route is untouched by bob's write.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["last_route"] == "/rfc/alice-route"
|
||||
|
||||
|
||||
def test_resume_disabled_no_ops_put_and_hides_route(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
|
||||
# Seed a stored row, then flip resume_enabled off directly (the
|
||||
# profile-settings toggle UI to do this from the client is a
|
||||
# follow-up; the column + behavior ship now).
|
||||
client.put("/api/me/last-state", json={"route": "/rfc/before-disable"})
|
||||
db.conn().execute(
|
||||
"UPDATE user_session_state SET resume_enabled = 0 WHERE user_id = ?",
|
||||
(2,),
|
||||
)
|
||||
|
||||
# /api/auth/me reports resume off and hands back a null route
|
||||
# even though a stored row exists.
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["resume_enabled"] is False
|
||||
assert me["user"]["last_route"] is None
|
||||
|
||||
# A PUT while disabled no-ops: stored=False and the stored route
|
||||
# is NOT rewritten.
|
||||
r = client.put("/api/me/last-state", json={"route": "/rfc/after-disable"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["stored"] is False
|
||||
row = db.conn().execute(
|
||||
"SELECT last_route FROM user_session_state WHERE user_id = ?", (2,)
|
||||
).fetchone()
|
||||
assert row["last_route"] == "/rfc/before-disable"
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Vertical + unit coverage for roadmap #27 (rfc-app v0.24.0): Claude
|
||||
Haiku tag suggestions on the propose-RFC modal.
|
||||
|
||||
Reuses the FakeGitea + session helpers from test_propose_vertical.py.
|
||||
The Anthropic call is never made for real — tests monkeypatch the
|
||||
`tag_suggest.haiku_provider` seam with a stub provider whose `send`
|
||||
returns canned text, so the HTTP contract is exercised without a key.
|
||||
|
||||
Proves:
|
||||
(a) the endpoint is contributor-gated (anon → 401);
|
||||
(b) a contributor gets suggestions, filtered to the corpus tag
|
||||
universe, with invented tags dropped;
|
||||
(c) no Anthropic key bound → empty list, not an error;
|
||||
(d) an empty corpus → empty list (model is never even called);
|
||||
(e) the per-user rate limit surfaces as a 429;
|
||||
plus unit coverage of the universe gather, the reply parser's tolerance,
|
||||
and the suggest() orchestration short-circuits.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubProvider:
|
||||
"""A BaseProvider stand-in whose send() returns a fixed string (or
|
||||
raises, to exercise the graceful-failure path)."""
|
||||
|
||||
def __init__(self, reply: str = "[]", *, raises: bool = False):
|
||||
self.reply = reply
|
||||
self.raises = raises
|
||||
self.calls: list[tuple[str, list]] = []
|
||||
|
||||
def send(self, system, history):
|
||||
self.calls.append((system, history))
|
||||
if self.raises:
|
||||
raise RuntimeError("boom")
|
||||
return self.reply
|
||||
|
||||
|
||||
def _seed_tags(slug: str, title: str, tags: list[str], state: str = "active") -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO cached_rfcs (slug, title, state, tags_json) VALUES (?, ?, ?, ?)",
|
||||
(slug, title, state, json.dumps(tags)),
|
||||
)
|
||||
|
||||
|
||||
def _use_provider(monkeypatch, provider) -> None:
|
||||
monkeypatch.setattr("app.tag_suggest.haiku_provider", lambda config: provider)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_rate_limits():
|
||||
from app import tag_suggest
|
||||
|
||||
tag_suggest.reset_rate_limits()
|
||||
yield
|
||||
tag_suggest.reset_rate_limits()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint (vertical)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_cannot_suggest(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/rfcs/suggest-tags", json={"title": "X"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_contributor_gets_filtered_suggestions(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
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")
|
||||
|
||||
_seed_tags("ohm", "OHM", ["identity", "schema", "consent"])
|
||||
_seed_tags("other", "Other", ["identity", "governance"])
|
||||
|
||||
# Model returns two real tags (one lowercased to test canonical
|
||||
# mapping is exact-set anyway), plus one invented tag that MUST
|
||||
# be dropped.
|
||||
reply = json.dumps([
|
||||
{"tag": "identity", "confidence": 0.9},
|
||||
{"tag": "consent", "confidence": 0.7},
|
||||
{"tag": "totally-invented", "confidence": 0.99},
|
||||
])
|
||||
stub = StubProvider(reply=reply)
|
||||
_use_provider(monkeypatch, stub)
|
||||
|
||||
r = client.post("/api/rfcs/suggest-tags", json={
|
||||
"title": "Consent and identity",
|
||||
"pitch": "We need a shared definition of consent tied to identity.",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
tags = [s["tag"] for s in r.json()["suggestions"]]
|
||||
assert tags == ["identity", "consent"]
|
||||
# the model was actually invoked
|
||||
assert len(stub.calls) == 1
|
||||
# the universe (deduped distinct tags) was handed to the model
|
||||
user_msg = stub.calls[0][1][0]["content"]
|
||||
assert "identity" in user_msg and "governance" in user_msg
|
||||
|
||||
|
||||
def test_no_api_key_returns_empty(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
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")
|
||||
_seed_tags("ohm", "OHM", ["identity"])
|
||||
|
||||
# No key bound (test env has no ANTHROPIC_API_KEY) → provider None.
|
||||
# (Explicitly assert the seam returns None given the test config.)
|
||||
from app import tag_suggest
|
||||
assert tag_suggest.haiku_provider(app.state.config) is None
|
||||
|
||||
r = client.post("/api/rfcs/suggest-tags", json={"title": "X", "pitch": "y"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["suggestions"] == []
|
||||
|
||||
|
||||
def test_empty_corpus_returns_empty_without_calling_model(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
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")
|
||||
|
||||
stub = StubProvider(reply=json.dumps([{"tag": "x", "confidence": 1}]))
|
||||
_use_provider(monkeypatch, stub)
|
||||
|
||||
# No cached_rfcs rows → empty universe → suggest() short-circuits.
|
||||
r = client.post("/api/rfcs/suggest-tags", json={"title": "X", "pitch": "y"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["suggestions"] == []
|
||||
assert stub.calls == [] # model never invoked on an empty universe
|
||||
|
||||
|
||||
def test_rate_limit_surfaces_429(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.setenv("TAG_SUGGEST_RATE_MAX", "2")
|
||||
monkeypatch.setenv("TAG_SUGGEST_RATE_WINDOW_SECONDS", "60")
|
||||
|
||||
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")
|
||||
_seed_tags("ohm", "OHM", ["identity"])
|
||||
_use_provider(monkeypatch, StubProvider(reply="[]"))
|
||||
|
||||
body = {"title": "X", "pitch": "y"}
|
||||
assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 200
|
||||
assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 200
|
||||
# Third call inside the window trips the limit.
|
||||
assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 429
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Units
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_gather_tag_universe_dedupes_and_ranks(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import tag_suggest
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
# The db is initialized in the app's lifespan; enter the client
|
||||
# context so cached_rfcs exists before we seed it directly.
|
||||
with TestClient(app):
|
||||
_seed_tags("a", "A", ["identity", "schema"])
|
||||
_seed_tags("b", "B", ["identity", " schema ", "consent", ""]) # whitespace + empty
|
||||
_seed_tags("c", "C", ["identity"])
|
||||
|
||||
universe = tag_suggest.gather_tag_universe()
|
||||
# identity (3) > schema (2) > consent (1); whitespace trimmed/merged,
|
||||
# empties dropped.
|
||||
assert universe == ["identity", "schema", "consent"]
|
||||
|
||||
|
||||
def test_parse_reply_tolerates_junk():
|
||||
from app import tag_suggest
|
||||
|
||||
universe = ["identity", "schema", "consent"]
|
||||
|
||||
# Prose around the JSON, an invented tag, a bare string, a dup, a
|
||||
# missing confidence, and a garbage confidence.
|
||||
text = (
|
||||
"Sure! Here are the tags:\n"
|
||||
'[{"tag": "identity", "confidence": 0.9}, '
|
||||
'{"tag": "invented", "confidence": 1}, '
|
||||
'"schema", '
|
||||
'{"tag": "identity", "confidence": 0.5}, '
|
||||
'{"tag": "consent"}, '
|
||||
'{"tag": "consent", "confidence": "high"}]\n'
|
||||
"Hope that helps!"
|
||||
)
|
||||
out = tag_suggest.parse_reply(text, universe, max_suggestions=6)
|
||||
tags = [s["tag"] for s in out]
|
||||
assert tags == ["identity", "schema", "consent"] # invented dropped, deduped
|
||||
by_tag = {s["tag"]: s["confidence"] for s in out}
|
||||
assert by_tag["identity"] == 0.9
|
||||
assert by_tag["schema"] == 0.5 # bare string defaults to 0.5
|
||||
assert by_tag["consent"] == 0.5 # missing/garbage confidence → 0.5
|
||||
|
||||
|
||||
def test_parse_reply_empty_on_unparseable():
|
||||
from app import tag_suggest
|
||||
|
||||
assert tag_suggest.parse_reply("no json here", ["a"], 6) == []
|
||||
assert tag_suggest.parse_reply("", ["a"], 6) == []
|
||||
assert tag_suggest.parse_reply("[]", ["a"], 6) == []
|
||||
|
||||
|
||||
def test_parse_reply_respects_max():
|
||||
from app import tag_suggest
|
||||
|
||||
universe = ["a", "b", "c", "d", "e"]
|
||||
text = json.dumps([{"tag": t, "confidence": 0.5} for t in universe])
|
||||
out = tag_suggest.parse_reply(text, universe, max_suggestions=3)
|
||||
assert [s["tag"] for s in out] == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_suggest_short_circuits_empty_draft():
|
||||
from app import tag_suggest
|
||||
|
||||
stub = StubProvider(reply=json.dumps([{"tag": "a", "confidence": 1}]))
|
||||
draft = tag_suggest.Draft(title=" ", pitch="", use_case="")
|
||||
assert tag_suggest.suggest(stub, draft, ["a"]) == []
|
||||
assert stub.calls == [] # never called for an empty draft
|
||||
|
||||
|
||||
def test_suggest_returns_empty_on_provider_failure():
|
||||
from app import tag_suggest
|
||||
|
||||
stub = StubProvider(raises=True)
|
||||
draft = tag_suggest.Draft(title="Real title", pitch="a reason")
|
||||
assert tag_suggest.suggest(stub, draft, ["identity"]) == []
|
||||
@@ -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):
|
||||
"""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
|
||||
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 = {}
|
||||
|
||||
def fake_post(url, *, data=None, timeout=None, **kwargs):
|
||||
async def fake_post(url, data):
|
||||
captured["url"] = url
|
||||
captured["data"] = data
|
||||
body = {"success": bool(success)}
|
||||
@@ -70,7 +76,7 @@ def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | No
|
||||
return SimpleNamespace(json=lambda: body)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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("TURNSTILE_REQUIRED", raising=False)
|
||||
# The httpx.post inside turnstile must not be called in this path —
|
||||
# patch it to a sentinel that explodes if it ever runs.
|
||||
# The siteverify call inside turnstile must not be made in this path —
|
||||
# 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
|
||||
|
||||
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")
|
||||
|
||||
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
|
||||
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 _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"
|
||||
|
||||
@@ -18,6 +18,56 @@ server {
|
||||
listen [::]:80;
|
||||
server_name ohm.wiggleverse.org;
|
||||
|
||||
# v0.25.0 security hardening (audit 0026 M2/L8)
|
||||
#
|
||||
# NOTE: certbot promotes THIS server block to the HTTPS listener
|
||||
# (`listen 443 ssl`) and adds a separate port-80 → 443 redirect
|
||||
# block (see the install comment above). These response headers
|
||||
# therefore ride into the HTTPS server block on the VM. They use
|
||||
# `add_header ... always` so they also apply to nginx-generated
|
||||
# error responses (4xx/5xx), not just 200s.
|
||||
#
|
||||
# `server_tokens off` (L8) — suppress the nginx version in the
|
||||
# Server header and on error pages so we don't advertise the
|
||||
# build to scanners.
|
||||
server_tokens off;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Content-Security-Policy (M2). Tuned to what the SPA actually loads:
|
||||
# - default-src 'self': everything not called out below is same-origin.
|
||||
# - script-src 'self' + challenges.cloudflare.com: the only external
|
||||
# <script> tag the app injects is the CloudFlare Turnstile widget
|
||||
# (frontend/src/components/TurnstileWidget.jsx). Amplitude and
|
||||
# mermaid are BUNDLED (dynamic `import()` from node_modules, served
|
||||
# from 'self'), so they need no extra script origin — *.amplitude.com
|
||||
# is listed defensively in case a future SDK build script-injects.
|
||||
# script-src DELIBERATELY OMITS 'unsafe-inline' — no inline <script>
|
||||
# is used, so we keep XSS-via-inline-script blocked.
|
||||
# - style-src 'unsafe-inline' IS REQUIRED by the current build: the
|
||||
# JSX uses inline `style={...}` attributes throughout and mermaid
|
||||
# injects <style> blocks at render time. Removing it would break
|
||||
# layout; tightening this is a future build-side change (nonce/hash).
|
||||
# - img-src 'self' data: https: — markdown/RFC bodies may embed remote
|
||||
# images and data: URIs; svg/mermaid output uses data: too.
|
||||
# - font-src 'self' data: — bundled fonts plus data: webfonts.
|
||||
# - connect-src 'self' + *.amplitude.com + challenges.cloudflare.com:
|
||||
# the app's API/auth/SSE are same-origin (nginx proxy); Amplitude
|
||||
# Analytics + Session Replay (shipped at sampleRate 1) POST to
|
||||
# *.amplitude.com; Turnstile verifies via challenges.cloudflare.com.
|
||||
# - worker-src 'self' blob: — Amplitude Session Replay spins up a
|
||||
# Web Worker from a blob: URL for capture/compression; without
|
||||
# blob: here session replay breaks for every consenting user.
|
||||
# - frame-src challenges.cloudflare.com — the Turnstile challenge
|
||||
# renders in an iframe from that origin.
|
||||
# - frame-ancestors 'none' — clickjacking defense, pairs with
|
||||
# X-Frame-Options DENY for older agents.
|
||||
# - base-uri 'self'; object-src 'none' — lock down <base>/<object>.
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://challenges.cloudflare.com https://*.amplitude.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://*.amplitude.com https://challenges.cloudflare.com; worker-src 'self' blob:; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always;
|
||||
|
||||
# Static SPA assets live in the Vite build output. The systemd unit
|
||||
# runs as user `rfc-app`; make sure nginx (usually `www-data`) can
|
||||
# read this path. Either group-add www-data into rfc-app's group, or
|
||||
|
||||
@@ -42,5 +42,32 @@ ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/opt/rfc-app/backend/data
|
||||
|
||||
# v0.25.0 security hardening (audit 0026 L4) — defense-in-depth.
|
||||
# The service binds 127.0.0.1:8000 and runs plain CPython
|
||||
# (FastAPI/uvicorn + sqlite + bcrypt + httpx), so it needs no
|
||||
# capabilities and no exotic syscalls.
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictNamespaces=true
|
||||
LockPersonality=true
|
||||
# MemoryDenyWriteExecute=true blocks W^X memory — safe for stock
|
||||
# CPython (no JIT) and the pure-Python/C-extension stack here, but
|
||||
# would break a JIT or a C-ext that mmaps W+X. Watch the first
|
||||
# restart's journal for a crash; if uvicorn fails to come up,
|
||||
# comment this one line out and reload.
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallErrorNumber=EPERM
|
||||
SystemCallArchitectures=native
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
Generated
+3
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"version": "0.20.0",
|
||||
"version": "0.24.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rfc-app-frontend",
|
||||
"version": "0.20.0",
|
||||
"version": "0.24.0",
|
||||
"dependencies": {
|
||||
"@amplitude/unified": "^1.1.9",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
@@ -18,6 +18,7 @@
|
||||
"@tiptap/pm": "^3.5.0",
|
||||
"@tiptap/react": "^3.5.0",
|
||||
"@tiptap/starter-kit": "^3.5.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"marked": "^18.0.4",
|
||||
"mermaid": "^11.15.0",
|
||||
"react": "^19.2.6",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.20.0",
|
||||
"version": "0.31.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -19,6 +19,7 @@
|
||||
"@tiptap/pm": "^3.5.0",
|
||||
"@tiptap/react": "^3.5.0",
|
||||
"@tiptap/starter-kit": "^3.5.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"marked": "^18.0.4",
|
||||
"mermaid": "^11.15.0",
|
||||
"react": "^19.2.6",
|
||||
|
||||
+887
-741
File diff suppressed because it is too large
Load Diff
+66
-5
@@ -1,13 +1,15 @@
|
||||
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 { anonymize, EVENTS, identify, track } from './lib/analytics'
|
||||
import { useLastState } from './lib/useLastState'
|
||||
import Catalog from './components/Catalog.jsx'
|
||||
import Inbox from './components/Inbox.jsx'
|
||||
import RFCView from './components/RFCView.jsx'
|
||||
import PRView from './components/PRView.jsx'
|
||||
import ProposalView from './components/ProposalView.jsx'
|
||||
import ProposeModal from './components/ProposeModal.jsx'
|
||||
import ContributeRequestForm from './components/ContributeRequestForm.jsx'
|
||||
import Landing from './components/Landing.jsx'
|
||||
import Login from './components/Login.jsx'
|
||||
import BetaPending from './components/BetaPending.jsx'
|
||||
@@ -42,8 +44,27 @@ export default function App() {
|
||||
// "Privacy & cookies" tab dispatches a `rfc-app:cookie-consent-reopen`
|
||||
// event that bumps this.
|
||||
const [consentReopenTick, setConsentReopenTick] = useState(0)
|
||||
// v0.23.0 / item #29 — flips true once the #21-Part-C identify effect
|
||||
// has fired (or once we've confirmed there's no authenticated user to
|
||||
// identify). useLastState gates its resume redirect on this so the
|
||||
// redirect always happens AFTER identify, preserving identify-then-
|
||||
// track ordering.
|
||||
const [identifyReady, setIdentifyReady] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
// #28 Parts 2–3: 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
|
||||
// route change; the analytics wrapper itself decides whether
|
||||
// anything ships out (consent + key check). The first fire is
|
||||
@@ -86,6 +107,9 @@ export default function App() {
|
||||
if (viewer?.first_sign_in_at) props.first_sign_in_at = ['__setOnce__', viewer.first_sign_in_at]
|
||||
if (viewer?.created_at) props.account_created_at = ['__setOnce__', viewer.created_at]
|
||||
identify({ user_id: String(uid), properties: props })
|
||||
// v0.23.0 / item #29 — identify has now fired for this sign-in;
|
||||
// release useLastState's resume redirect (it waits on this).
|
||||
setIdentifyReady(true)
|
||||
} else if (uid == null && lastUserIdRef.current != null) {
|
||||
// Sign-out edge — App-level reset is handled separately by the
|
||||
// sign-out gesture that fires User Signed Out. Clear our local
|
||||
@@ -94,6 +118,14 @@ export default function App() {
|
||||
}
|
||||
}, [me?.authenticated, me?.user?.id, me?.user?.role, me?.user?.permission_state, me?.user?.passcode_set, me?.user?.device_trusted])
|
||||
|
||||
// v0.23.0 / item #29 — once `me` has resolved, if there's no
|
||||
// authenticated user there is nothing to identify, so release the
|
||||
// resume gate immediately (anonymous boots have no resume to do, but
|
||||
// the hook still needs the gate resolved to be a clean no-op).
|
||||
useEffect(() => {
|
||||
if (me != null && !me.authenticated) setIdentifyReady(true)
|
||||
}, [me])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setConsentReopenTick(t => t + 1)
|
||||
window.addEventListener('rfc-app:cookie-consent-reopen', handler)
|
||||
@@ -107,6 +139,18 @@ export default function App() {
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// v0.23.0 / item #29 — server-side sign-in state resume. The hook
|
||||
// debounce-posts the current route for authenticated users and, once
|
||||
// identify has fired, redirects a fresh sign-in (which hard-lands on
|
||||
// "/") to the user's stored last route. Anonymous users: no-op.
|
||||
useLastState({
|
||||
authenticated: !!me?.authenticated,
|
||||
pathname: location.pathname,
|
||||
identifyReady,
|
||||
lastRoute: me?.authenticated ? me.user?.last_route : null,
|
||||
navigate,
|
||||
})
|
||||
|
||||
// §15.3 — subscribe to the live SSE stream for authenticated viewers
|
||||
// so the badge counter and the toast surface stay in lockstep with
|
||||
// the inbox. Tabs that miss an event because they were closed pick
|
||||
@@ -188,9 +232,17 @@ export default function App() {
|
||||
<button
|
||||
className="inbox-trigger"
|
||||
onClick={() => setInboxOpen(o => !o)}
|
||||
title="Notifications inbox (§15.2)"
|
||||
aria-label="Inbox"
|
||||
title="Inbox (§15.2)"
|
||||
>
|
||||
<span aria-hidden>📮</span>
|
||||
<svg
|
||||
width="18" height="18" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" strokeWidth="1.75"
|
||||
strokeLinecap="round" strokeLinejoin="round" aria-hidden
|
||||
>
|
||||
<path d="M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z" />
|
||||
<path d="m3.5 6.5 8.5 6 8.5-6" />
|
||||
</svg>
|
||||
{unreadCount > 0 && (
|
||||
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||
)}
|
||||
@@ -275,17 +327,26 @@ export default function App() {
|
||||
} />
|
||||
</Routes>
|
||||
</div>
|
||||
{proposeOpen && viewer && (
|
||||
{(proposeOpen || proposeParam != null) && viewer && (
|
||||
<ProposeModal
|
||||
viewer={viewer}
|
||||
onClose={() => setProposeOpen(false)}
|
||||
initialTitle={proposeParam || ''}
|
||||
onClose={() => { setProposeOpen(false); clearParams('propose') }}
|
||||
onSubmitted={({ pr_number }) => {
|
||||
setProposeOpen(false)
|
||||
clearParams('propose')
|
||||
setCatalogVersion(v => v + 1)
|
||||
navigate(`/proposals/${pr_number}`)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{contributeSlug && viewer && (
|
||||
<ContributeRequestForm
|
||||
slug={contributeSlug}
|
||||
term={contributeTerm || ''}
|
||||
onClose={() => clearParams('contribute', 'term')}
|
||||
/>
|
||||
)}
|
||||
{inboxOpen && viewer && (
|
||||
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
|
||||
)}
|
||||
|
||||
+95
-8
@@ -25,6 +25,25 @@ export async function getMe() {
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── v0.23.0: sign-in state resume (§6.2, roadmap item #29) ───────────────
|
||||
//
|
||||
// The route-change hook (useLastState) debounce-posts the user's current
|
||||
// route + a small bag of *light* view state here for authenticated users.
|
||||
// The next sign-in reads `last_route` off `/api/auth/me` and redirects.
|
||||
// Privacy: `state` carries ephemeral view state ONLY — never draft-buffer
|
||||
// contents (see SPEC §6.2). Best-effort: callers ignore failures (an
|
||||
// offline/401 POST must never disrupt navigation).
|
||||
export async function putLastState(route, state) {
|
||||
const body = { route }
|
||||
if (state != null) body.state = state
|
||||
const res = await fetch('/api/me/last-state', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── v0.7.0: email + one-time-code sign-in (§6.2) ─────────────────────────
|
||||
//
|
||||
// The legacy /auth/login → /auth/callback OAuth flow remains during the
|
||||
@@ -166,15 +185,81 @@ export async function getProposal(prNumber) {
|
||||
return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`))
|
||||
}
|
||||
|
||||
export async function proposeRFC({ title, slug, pitch, tags }) {
|
||||
export async function proposeRFC({ title, slug, pitch, tags, proposedUseCase }) {
|
||||
const res = await fetch('/api/rfcs/propose', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, slug, pitch, tags: tags || [] }),
|
||||
// #26: proposed_use_case is optional; send null when blank so the
|
||||
// backend treats it as "left blank".
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
slug,
|
||||
pitch,
|
||||
tags: tags || [],
|
||||
proposed_use_case: proposedUseCase || null,
|
||||
}),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Roadmap #27: Claude Haiku tag suggestions for the propose-RFC modal.
|
||||
// Returns a (possibly empty) array of { tag, confidence }. Deliberately
|
||||
// forgiving — any non-OK response (rate limit, transient error, no key
|
||||
// configured server-side) resolves to [] so the modal just shows nothing
|
||||
// rather than surfacing an error for what is a best-effort assist.
|
||||
export async function suggestTags({ title, pitch, useCase }) {
|
||||
try {
|
||||
const res = await fetch('/api/rfcs/suggest-tags', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title || '',
|
||||
pitch: pitch || '',
|
||||
use_case: useCase || '',
|
||||
}),
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return Array.isArray(data.suggestions) ? data.suggestions : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
@@ -445,18 +530,19 @@ export async function listBlockingPRs(slug) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/blocking-prs`))
|
||||
}
|
||||
|
||||
export async function graduateCheck(slug, { id, repo }) {
|
||||
export async function graduateCheck(slug, { id }) {
|
||||
// Meta-only topology (§13.2): two fields — integer id + owners. No
|
||||
// repo name to validate.
|
||||
const params = new URLSearchParams()
|
||||
if (id != null) params.set('id', id)
|
||||
if (repo != null) params.set('repo', repo)
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/graduate/check?${params}`))
|
||||
}
|
||||
|
||||
export async function startGraduation(slug, { rfcId, repoName, owners }) {
|
||||
export async function startGraduation(slug, { rfcId, owners }) {
|
||||
const res = await fetch(`/api/rfcs/${slug}/graduate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rfc_id: rfcId, repo_name: repoName, owners }),
|
||||
body: JSON.stringify({ rfc_id: rfcId, owners }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
@@ -492,13 +578,14 @@ export async function draftPRText(slug, branch) {
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function openPR(slug, branch, { title, description }) {
|
||||
export async function openPR(slug, branch, { title, description, proposedUseCase }) {
|
||||
const res = await fetch(
|
||||
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/open-pr`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, description }),
|
||||
// #26: proposed_use_case is optional; null when blank.
|
||||
body: JSON.stringify({ title, description, proposed_use_case: proposedUseCase || null }),
|
||||
},
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
|
||||
@@ -181,7 +181,20 @@ function UsersTab() {
|
||||
return (
|
||||
<div className="admin-tab">
|
||||
<header className="admin-tab-header">
|
||||
<h2>Users</h2>
|
||||
<div className="admin-tab-heading">
|
||||
<h2>Users</h2>
|
||||
{/* v0.17.0 — roadmap item #16. The "Create user + invite"
|
||||
affordance opens a modal that provisions a fresh users row
|
||||
with the chosen role and sends an invite email with a
|
||||
single-use claim link. */}
|
||||
<div className="admin-tab-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => setInviteModalOpen(true)}
|
||||
>Create user + invite</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="muted">
|
||||
The pending bucket is the beta-access review queue (§6.1 /
|
||||
v0.8.0). Grant or revoke writes to <code>permission_events</code>
|
||||
@@ -190,17 +203,6 @@ function UsersTab() {
|
||||
retain their v0.7.0 semantics — promote to admin to remove a
|
||||
user's ability to write without silencing them.
|
||||
</p>
|
||||
{/* v0.17.0 — roadmap item #16. The "Create user + invite"
|
||||
affordance opens a modal that provisions a fresh users row
|
||||
with the chosen role and sends an invite email with a
|
||||
single-use claim link. */}
|
||||
<div className="admin-tab-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => setInviteModalOpen(true)}
|
||||
>Create user + invite</button>
|
||||
</div>
|
||||
</header>
|
||||
{error && <p className="settings-note warning">{error}</p>}
|
||||
{inviteModalOpen && (
|
||||
@@ -270,21 +272,27 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
|
||||
// the admin sees at a glance which rows are real users vs. unclaimed
|
||||
// invites.
|
||||
const pendingInvite = u.pending_invite
|
||||
// When there's no gitea_login the handle already IS the email, so the
|
||||
// subline would otherwise repeat it. Only append the email when it adds
|
||||
// something the handle doesn't already show.
|
||||
const showEmail = u.email && u.email !== handle
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td>
|
||||
<div className="user-cell">
|
||||
<span className="user-handle">{handle}</span>
|
||||
{pendingInvite && (
|
||||
<span
|
||||
className="invite-badge"
|
||||
title={`Admin-created invite; expires ${pendingInvite.expires_at}`}
|
||||
>(pending invite)</span>
|
||||
)}
|
||||
<div className="user-cell-handle">
|
||||
<span className="user-handle">{handle}</span>
|
||||
{pendingInvite && (
|
||||
<span
|
||||
className="invite-badge"
|
||||
title={`Admin-created invite; expires ${pendingInvite.expires_at}`}
|
||||
>pending invite</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="muted">
|
||||
{fullName || u.display_name}
|
||||
{u.email ? ` · ${u.email}` : ''}
|
||||
{showEmail ? ` · ${u.email}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
@@ -317,8 +325,8 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
|
||||
<span className="muted">N/A</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="muted">{u.created_at || '—'}</td>
|
||||
<td className="muted">{u.last_seen_at || '—'}</td>
|
||||
<TimeCell value={u.created_at} />
|
||||
<TimeCell value={u.last_seen_at} />
|
||||
</tr>
|
||||
{state === 'pending' && u.beta_request_reason ? (
|
||||
<tr className="user-row-reason">
|
||||
@@ -334,6 +342,21 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
|
||||
)
|
||||
}
|
||||
|
||||
// Render a "YYYY-MM-DD HH:MM:SS" timestamp as an intentional date-over-time
|
||||
// stack (date prominent, time quiet below) rather than letting a narrow
|
||||
// column wrap the value mid-string. Falls back to an em-dash when absent.
|
||||
function TimeCell({ value }) {
|
||||
if (!value) return <td className="muted">—</td>
|
||||
const [date, ...rest] = String(value).split(' ')
|
||||
const time = rest.join(' ')
|
||||
return (
|
||||
<td className="user-when">
|
||||
<span className="user-when-date">{date}</span>
|
||||
{time && <span className="user-when-time muted">{time}</span>}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
function PermissionCell({ user: u, busy, onFlipPermission }) {
|
||||
const state = u.permission_state || 'granted'
|
||||
const decidedSuffix = u.permission_decided_at
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/* Docs.css — docs-surface polish scoped to v0.21.0 / roadmap item #32.
|
||||
*
|
||||
* This sheet owns ONLY the classes introduced by item #32 (the
|
||||
* transcript metadata header and the session-root sibling list). The
|
||||
* pre-existing docs classes (.docs-article, .docs-empty, .docs-error,
|
||||
* .docs-source-link, .philosophy-body, .muted) live in App.css and are
|
||||
* deliberately NOT touched here — redefining them would race the #31
|
||||
* App.css token sweep for the same selectors. Every value below reads
|
||||
* a token from tokens.css so the new surfaces sit on the same
|
||||
* spacing/type/color scale as the rest of the docs chrome.
|
||||
*
|
||||
* Imported from DocsSessionTranscript.jsx + DocsSessionIndex.jsx (the
|
||||
* two components that render these elements). CSS custom properties are
|
||||
* not import-order-sensitive at use time, so the import site doesn't
|
||||
* matter for correctness.
|
||||
*/
|
||||
|
||||
/* ── Transcript metadata header ─────────────────────────────────────
|
||||
* A compact card above the rendered transcript body: title, the
|
||||
* started/ended/duration grid, an optional TL;DR, and the external
|
||||
* "view source" link. */
|
||||
.docs-transcript-meta {
|
||||
margin: 0 0 var(--space-9);
|
||||
padding: var(--space-7);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.docs-transcript-meta-title {
|
||||
margin: 0 0 var(--space-5);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--weight-semibold);
|
||||
line-height: var(--leading-tight);
|
||||
color: var(--color-text-strong);
|
||||
font-family: var(--font-mono);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.docs-transcript-meta-grid {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: var(--space-2) var(--space-7);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.docs-transcript-meta-row {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.docs-transcript-meta-grid dt {
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.docs-transcript-meta-grid dd {
|
||||
margin: 0;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.docs-transcript-meta-tldr {
|
||||
margin: var(--space-6) 0 0;
|
||||
padding-top: var(--space-6);
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.docs-transcript-meta-source {
|
||||
display: inline-block;
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
/* ── Session-root sibling-transcript list ───────────────────────────
|
||||
* Rendered above the inlined primary transcript when a session has
|
||||
* more than one transcript (driver `.0` + subagents). The primary is
|
||||
* marked "(shown below)"; the rest link to their standalone routes. */
|
||||
.docs-session-siblings {
|
||||
margin: 0 0 var(--space-9);
|
||||
padding: var(--space-6) var(--space-7);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-muted);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3) var(--space-6);
|
||||
}
|
||||
|
||||
.docs-session-siblings-label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.docs-session-siblings-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-5);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.docs-session-siblings-list a {
|
||||
color: var(--color-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.docs-session-siblings-list a:hover {
|
||||
color: var(--color-accent-strong);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.docs-session-siblings-current {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
@@ -1,46 +1,86 @@
|
||||
// DocsSessionIndex.jsx — v0.20.0 (was v0.19.0 / roadmap item #30).
|
||||
// DocsSessionIndex.jsx — v0.21.0 (was v0.20.0 / roadmap item #30).
|
||||
//
|
||||
// Per-session landing at `/docs/sessions/:nnnn`. v0.19.0 listed the
|
||||
// transcripts as body links; v0.20.0 drops the body list — navigation
|
||||
// is via the left flyout nav (which renders each session's transcripts
|
||||
// nested under the session row). The body now serves as a
|
||||
// session-overview card with the title, file count, and a hint to
|
||||
// pick a transcript from the nav.
|
||||
// Per-session landing at `/docs/sessions/:nnnn`. v0.20.0 rendered a
|
||||
// dead-end "N transcript(s) in this session. Select one from the
|
||||
// navigation." placeholder. v0.21.0 / roadmap item #32 collapses that:
|
||||
// the session root now renders a transcript INLINE so the URL is never
|
||||
// an empty stop.
|
||||
//
|
||||
// The transcript count still comes from `/api/docs/sessions/:nnnn/index`
|
||||
// - Exactly one transcript → render it inline at the session root.
|
||||
// - Multiple transcripts → render the `.0` driver transcript
|
||||
// inline (fall back to the first file by
|
||||
// sort order if there's no `.0`), AND
|
||||
// list/link the remaining transcripts so
|
||||
// the siblings are one click away.
|
||||
//
|
||||
// The URL stays stable to the session number — this is an inline
|
||||
// render, not a 301/redirect. The per-transcript route
|
||||
// (`/docs/sessions/:nnnn/:filename`) still exists and is what the
|
||||
// sibling links and the left-nav transcript rows point at.
|
||||
//
|
||||
// The transcript count + filenames come from `/api/docs/sessions/:nnnn/index`
|
||||
// so the empty-state ("no transcripts yet"), not-found, and error
|
||||
// paths remain meaningful — the page still does something useful when
|
||||
// the upstream is mid-publish or unreachable.
|
||||
// paths remain meaningful when the upstream is mid-publish or
|
||||
// unreachable. The metadata header + body rendering are imported from
|
||||
// DocsSessionTranscript.jsx so the inline view is byte-identical to the
|
||||
// standalone per-transcript view.
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { getSessionsManifest, getSessionIndex } from '../api.js'
|
||||
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||
import {
|
||||
getSessionsManifest,
|
||||
getSessionIndex,
|
||||
getSessionTranscript,
|
||||
} from '../api.js'
|
||||
import {
|
||||
TranscriptMetaHeader,
|
||||
transcriptOrdinal,
|
||||
} from './DocsSessionTranscript.jsx'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
import './Docs.css'
|
||||
|
||||
// Pick the transcript to render inline at the session root: prefer the
|
||||
// `.0` driver transcript; otherwise the first file by sort order. The
|
||||
// backend already returns the file list sorted, so `files[0]` is a
|
||||
// stable fallback.
|
||||
function pickPrimary(files) {
|
||||
if (!files || files.length === 0) return null
|
||||
const driver = files.find(f => /^SESSION-\d{4}\.0-TRANSCRIPT/.test(f))
|
||||
return driver || files[0]
|
||||
}
|
||||
|
||||
export default function DocsSessionIndex() {
|
||||
const { nnnn } = useParams()
|
||||
const [title, setTitle] = useState('')
|
||||
const [tldr, setTldr] = useState('')
|
||||
const [files, setFiles] = useState([])
|
||||
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
||||
const [reloadTick, setReloadTick] = useState(0)
|
||||
|
||||
// The inline body for the primary transcript.
|
||||
const [body, setBody] = useState('')
|
||||
const [bodyStatus, setBodyStatus] = useState('idle') // idle | loading | ok | notfound | error
|
||||
|
||||
useEffect(() => {
|
||||
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}` })
|
||||
}, [nnnn])
|
||||
|
||||
// Title from manifest — cheap, manifest is cached server-side.
|
||||
// Title + optional TL;DR from the manifest — cheap, cached server-side.
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
getSessionsManifest()
|
||||
.then(payload => {
|
||||
if (!active) return
|
||||
const entry = payload && payload[nnnn]
|
||||
setTitle((entry && entry.title) || '')
|
||||
const entry = (payload && payload[nnnn]) || {}
|
||||
setTitle(entry.title || '')
|
||||
// `tldr` is an optional manifest field (string). Absent ⇒ the
|
||||
// header renders no TL;DR line (graceful degrade).
|
||||
setTldr(typeof entry.tldr === 'string' ? entry.tldr : '')
|
||||
})
|
||||
.catch(() => {
|
||||
// Title is decorative; failure to load just leaves the header
|
||||
// showing the bare NNNN. The transcript list fetch below is
|
||||
// the load-bearing one.
|
||||
// Title + TL;DR are decorative; the transcript list + body
|
||||
// fetches below are the load-bearing ones.
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [nnnn])
|
||||
@@ -66,14 +106,41 @@ export default function DocsSessionIndex() {
|
||||
return () => { active = false }
|
||||
}, [nnnn, reloadTick])
|
||||
|
||||
const primary = status === 'ok' ? pickPrimary(files) : null
|
||||
|
||||
// Fetch the primary transcript body once we know which file it is.
|
||||
useEffect(() => {
|
||||
if (!primary) {
|
||||
setBody('')
|
||||
setBodyStatus('idle')
|
||||
return
|
||||
}
|
||||
let active = true
|
||||
setBodyStatus('loading')
|
||||
getSessionTranscript(nnnn, primary)
|
||||
.then(text => {
|
||||
if (!active) return
|
||||
setBody(text || '')
|
||||
setBodyStatus('ok')
|
||||
})
|
||||
.catch(e => {
|
||||
if (!active) return
|
||||
setBodyStatus(e.status === 404 ? 'notfound' : 'error')
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [nnnn, primary, reloadTick])
|
||||
|
||||
const retry = useCallback(() => setReloadTick(t => t + 1), [])
|
||||
|
||||
const header = title ? `${nnnn} — ${title}` : `Session ${nnnn}`
|
||||
const siblings = primary ? files.filter(f => f !== primary) : []
|
||||
|
||||
return (
|
||||
<article className="docs-article">
|
||||
<h1 className="docs-article-title">{header}</h1>
|
||||
|
||||
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||
|
||||
{status === 'notfound' && (
|
||||
<div className="docs-empty">
|
||||
<p>
|
||||
@@ -88,6 +155,7 @@ export default function DocsSessionIndex() {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="docs-error" role="alert">
|
||||
<p>Couldn't reach the session-history repo.</p>
|
||||
@@ -101,20 +169,77 @@ export default function DocsSessionIndex() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'ok' && files.length === 0 && (
|
||||
<div className="docs-empty">
|
||||
<p>This session has no transcripts published.</p>
|
||||
</div>
|
||||
)}
|
||||
{status === 'ok' && files.length > 0 && (
|
||||
<div className="docs-session-overview">
|
||||
<p>
|
||||
{files.length === 1
|
||||
? '1 transcript in this session.'
|
||||
: `${files.length} transcripts in this session.`}{' '}
|
||||
Select one from the navigation on the left.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status === 'ok' && primary && (
|
||||
<>
|
||||
{siblings.length > 0 && (
|
||||
<nav className="docs-session-siblings" aria-label="Transcripts in this session">
|
||||
<span className="docs-session-siblings-label">Transcripts</span>
|
||||
<ul className="docs-session-siblings-list">
|
||||
<li>
|
||||
<span
|
||||
className="docs-session-siblings-current"
|
||||
aria-current="true"
|
||||
>
|
||||
{transcriptOrdinal(primary)} (shown below)
|
||||
</span>
|
||||
</li>
|
||||
{siblings.map(f => (
|
||||
<li key={f}>
|
||||
<Link
|
||||
to={`/docs/sessions/${nnnn}/${f}`}
|
||||
aria-label={`Transcript ${transcriptOrdinal(f)}`}
|
||||
data-amp-track-name="Docs Session Sibling Transcript"
|
||||
data-amp-track-session={nnnn}
|
||||
data-amp-track-filename={f}
|
||||
>
|
||||
{transcriptOrdinal(f)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{bodyStatus === 'loading' && <p className="muted">Loading transcript…</p>}
|
||||
{bodyStatus === 'notfound' && (
|
||||
<div className="docs-empty">
|
||||
<p>This transcript isn't published yet.</p>
|
||||
</div>
|
||||
)}
|
||||
{bodyStatus === 'error' && (
|
||||
<div className="docs-error" role="alert">
|
||||
<p>Couldn't reach the session-history repo.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={retry}
|
||||
aria-label="Retry"
|
||||
data-amp-track-name="Docs Session Inline Retry"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{bodyStatus === 'ok' && (
|
||||
<>
|
||||
<TranscriptMetaHeader
|
||||
nnnn={nnnn}
|
||||
filename={primary}
|
||||
title={title}
|
||||
tldr={tldr}
|
||||
/>
|
||||
<div className="philosophy-body">
|
||||
<MarkdownPreview content={body} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
// DocsSessionTranscript.jsx — v0.19.0 / roadmap item #30.
|
||||
// DocsSessionTranscript.jsx — v0.21.0 (was v0.19.0 / roadmap item #30).
|
||||
//
|
||||
// Per-transcript view at `/docs/sessions/:nnnn/:filename`. Fetches the
|
||||
// transcript body via the backend mediator and renders it through the
|
||||
// shared MarkdownPreview.
|
||||
//
|
||||
// v0.21.0 / roadmap item #32:
|
||||
// - A compact metadata header now sits above the rendered body
|
||||
// (title, started/ended, duration, optional TL;DR, and a
|
||||
// "View source on git.wiggleverse.org" external link). The parse
|
||||
// + render helpers (`parseTranscriptMeta`, `TranscriptMetaHeader`,
|
||||
// `gitSourceUrl`) are exported here so the session-root inline-
|
||||
// collapse view (DocsSessionIndex.jsx) reuses the exact same
|
||||
// rendering for the transcript(s) it inlines.
|
||||
//
|
||||
// Empty-state contract:
|
||||
// 404 → "This transcript isn't published yet" with a link back to
|
||||
// the parent session index
|
||||
@@ -12,12 +21,149 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||
import { getSessionTranscript } from '../api.js'
|
||||
import { getSessionTranscript, getSessionsManifest } from '../api.js'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
import './Docs.css'
|
||||
|
||||
// The canonical published-repo source URL for a transcript file, per
|
||||
// SESSION-PROTOCOL.md §1's folder layout (one folder per session).
|
||||
export function gitSourceUrl(nnnn, filename) {
|
||||
return (
|
||||
'https://git.wiggleverse.org/wiggleverse/ohm-session-history/src/branch/main/' +
|
||||
`${encodeURIComponent(nnnn)}/${encodeURIComponent(filename)}`
|
||||
)
|
||||
}
|
||||
|
||||
// Extract the `.N` ordinal from a transcript filename:
|
||||
// "SESSION-0014.1-TRANSCRIPT-...md" → "0014.1"
|
||||
// "SESSION-0013.1.1-TRANSCRIPT-...md" → "0013.1.1" (nested subagent)
|
||||
export function transcriptOrdinal(filename) {
|
||||
const m = /^SESSION-(\d{4}\.\d+(?:\.\d+)*)-TRANSCRIPT/.exec(filename || '')
|
||||
return m ? m[1] : filename || ''
|
||||
}
|
||||
|
||||
// Parse the `<start>--<end>` ISO segment out of a transcript filename.
|
||||
// Per the protocol the segment is `YYYY-MM-DDTHH-MM--YYYY-MM-DDTHH-MM`
|
||||
// (colons replaced by dashes for filesystem portability, minute
|
||||
// precision, PST implied). Legacy renamed-letter transcripts omit the
|
||||
// segment entirely; in that case every derived field comes back null
|
||||
// and the header degrades gracefully.
|
||||
//
|
||||
// Returns { ordinal, start: Date|null, end: Date|null, durationMs: number|null }.
|
||||
export function parseTranscriptMeta(filename) {
|
||||
const ordinal = transcriptOrdinal(filename)
|
||||
const m = /-TRANSCRIPT-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})--(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})\.md$/.exec(
|
||||
filename || ''
|
||||
)
|
||||
if (!m) {
|
||||
return { ordinal, start: null, end: null, durationMs: null }
|
||||
}
|
||||
const [, sDate, sH, sM, eDate, eH, eM] = m
|
||||
// Parse as local wall-clock time. The filename carries no timezone
|
||||
// (PST is implied per the protocol); we render the wall-clock value
|
||||
// verbatim rather than shifting it, so we build a local Date and read
|
||||
// it back with the same calendar fields. Duration is a difference of
|
||||
// two local Dates, so the implied-timezone ambiguity cancels out.
|
||||
const start = new Date(`${sDate}T${sH}:${sM}:00`)
|
||||
const end = new Date(`${eDate}T${eH}:${eM}:00`)
|
||||
const startOk = !Number.isNaN(start.getTime())
|
||||
const endOk = !Number.isNaN(end.getTime())
|
||||
const durationMs =
|
||||
startOk && endOk && end.getTime() >= start.getTime()
|
||||
? end.getTime() - start.getTime()
|
||||
: null
|
||||
return {
|
||||
ordinal,
|
||||
start: startOk ? start : null,
|
||||
end: endOk ? end : null,
|
||||
durationMs,
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDateTime(d) {
|
||||
if (!d) return null
|
||||
// e.g. "May 28, 2026, 11:11 AM" — human-readable, wall-clock.
|
||||
try {
|
||||
return d.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return d.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDuration(ms) {
|
||||
if (ms == null || ms <= 0) return null
|
||||
const totalMin = Math.round(ms / 60000)
|
||||
const h = Math.floor(totalMin / 60)
|
||||
const m = totalMin % 60
|
||||
if (h > 0 && m > 0) return `${h}h ${m}m`
|
||||
if (h > 0) return `${h}h`
|
||||
return `${m}m`
|
||||
}
|
||||
|
||||
// The compact metadata block rendered above every transcript body.
|
||||
// Shared between the standalone transcript route and the session-root
|
||||
// inline-collapse view. `tldr` is optional — absent ⇒ rendered nothing
|
||||
// (graceful degrade, per the manifest schema where `tldr` may be unset).
|
||||
export function TranscriptMetaHeader({ nnnn, filename, title, tldr }) {
|
||||
const { ordinal, start, end, durationMs } = parseTranscriptMeta(filename)
|
||||
const started = fmtDateTime(start)
|
||||
const ended = fmtDateTime(end)
|
||||
const duration = fmtDuration(durationMs)
|
||||
const heading = title ? `${ordinal} — ${title}` : `Session ${ordinal}`
|
||||
|
||||
return (
|
||||
<header className="docs-transcript-meta">
|
||||
<h2 className="docs-transcript-meta-title">{heading}</h2>
|
||||
{(started || ended || duration) && (
|
||||
<dl className="docs-transcript-meta-grid">
|
||||
{started && (
|
||||
<div className="docs-transcript-meta-row">
|
||||
<dt>Started</dt>
|
||||
<dd>{started}</dd>
|
||||
</div>
|
||||
)}
|
||||
{ended && (
|
||||
<div className="docs-transcript-meta-row">
|
||||
<dt>Ended</dt>
|
||||
<dd>{ended}</dd>
|
||||
</div>
|
||||
)}
|
||||
{duration && (
|
||||
<div className="docs-transcript-meta-row">
|
||||
<dt>Duration</dt>
|
||||
<dd>{duration}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
{tldr && <p className="docs-transcript-meta-tldr">{tldr}</p>}
|
||||
<a
|
||||
className="docs-source-link docs-transcript-meta-source"
|
||||
href={gitSourceUrl(nnnn, filename)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`View transcript ${ordinal} source on git.wiggleverse.org`}
|
||||
data-amp-track-name="Docs Transcript Source Link"
|
||||
data-amp-track-session={nnnn}
|
||||
data-amp-track-filename={filename}
|
||||
>
|
||||
View source on git.wiggleverse.org ↗
|
||||
</a>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DocsSessionTranscript() {
|
||||
const { nnnn, filename } = useParams()
|
||||
const [body, setBody] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [tldr, setTldr] = useState('')
|
||||
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
||||
const [reloadTick, setReloadTick] = useState(0)
|
||||
|
||||
@@ -25,6 +171,22 @@ export default function DocsSessionTranscript() {
|
||||
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}/${filename}` })
|
||||
}, [nnnn, filename])
|
||||
|
||||
// Title + optional tl;dr from the manifest — decorative metadata that
|
||||
// feeds the header. Failure leaves the header showing the bare NNNN
|
||||
// and no TL;DR; the body fetch below is the load-bearing one.
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
getSessionsManifest()
|
||||
.then(payload => {
|
||||
if (!active) return
|
||||
const entry = (payload && payload[nnnn]) || {}
|
||||
setTitle(entry.title || '')
|
||||
setTldr(typeof entry.tldr === 'string' ? entry.tldr : '')
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => { active = false }
|
||||
}, [nnnn])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setStatus('loading')
|
||||
@@ -87,9 +249,17 @@ export default function DocsSessionTranscript() {
|
||||
</div>
|
||||
)}
|
||||
{status === 'ok' && (
|
||||
<div className="philosophy-body">
|
||||
<MarkdownPreview content={body} />
|
||||
</div>
|
||||
<>
|
||||
<TranscriptMetaHeader
|
||||
nnnn={nnnn}
|
||||
filename={filename}
|
||||
title={title}
|
||||
tldr={tldr}
|
||||
/>
|
||||
<div className="philosophy-body">
|
||||
<MarkdownPreview content={body} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -7,15 +7,21 @@
|
||||
// `MarkdownPreview` (the same component the `/philosophy` route uses,
|
||||
// so we don't introduce a second markdown library).
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
// v0.21.0 / roadmap item #31: the loading + error states are brought
|
||||
// onto the same `.docs-empty` / `.docs-error` convention every other
|
||||
// docs surface uses (was a bare `<p className="error">`), with a retry
|
||||
// button so a transient `/api/docs` failure isn't a dead end.
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||
import { getDocs } from '../api.js'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
export default function DocsUserGuide() {
|
||||
const [body, setBody] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [status, setStatus] = useState('loading') // loading | ok | error
|
||||
const [reloadTick, setReloadTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
track(EVENTS.DOC_VIEWED, { section: 'user-guide' })
|
||||
@@ -23,19 +29,49 @@ export default function DocsUserGuide() {
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setStatus('loading')
|
||||
getDocs()
|
||||
.then(r => { if (active) setBody(r.body || '') })
|
||||
.catch(e => { if (active) setError(e.message || String(e)) })
|
||||
.finally(() => { if (active) setLoading(false) })
|
||||
.then(r => {
|
||||
if (!active) return
|
||||
setBody(r.body || '')
|
||||
setStatus('ok')
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return
|
||||
setStatus('error')
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
}, [reloadTick])
|
||||
|
||||
const retry = useCallback(() => setReloadTick(t => t + 1), [])
|
||||
|
||||
return (
|
||||
<article className="docs-article">
|
||||
<h1 className="docs-article-title">User guide</h1>
|
||||
{loading && <p className="muted">Loading…</p>}
|
||||
{error && <p className="error">Could not load the guide: {error}</p>}
|
||||
{!loading && !error && (
|
||||
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||
{status === 'error' && (
|
||||
<div className="docs-error" role="alert">
|
||||
<p>Couldn't load the user guide.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={retry}
|
||||
aria-label="Retry"
|
||||
data-amp-track-name="Docs User Guide Retry"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<p>
|
||||
<Link
|
||||
to="/docs/sessions/about"
|
||||
aria-label="About sessions"
|
||||
data-amp-track-name="Docs User Guide Error About Link"
|
||||
>
|
||||
About sessions
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{status === 'ok' && (
|
||||
<div className="philosophy-body">
|
||||
<MarkdownPreview content={body} />
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { useEditor, EditorContent, Extension } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { marked } from 'marked'
|
||||
import { renderMarkdown } from '../lib/sanitizeHtml'
|
||||
import { Plugin, PluginKey } from 'prosemirror-state'
|
||||
import { Decoration, DecorationSet } from 'prosemirror-view'
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function Editor({
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || content == null) return
|
||||
const html = marked.parse(content)
|
||||
const html = renderMarkdown(content)
|
||||
editor.commands.setContent(html, false)
|
||||
}, [content, editor])
|
||||
|
||||
|
||||
@@ -1,70 +1,55 @@
|
||||
// GraduateDialog.jsx — the §13.2 Graduate dialog and the §13.3 step stack.
|
||||
// GraduateDialog.jsx — the §13.2 Graduate dialog and the §13.3 flip.
|
||||
//
|
||||
// Renders three editable fields (integer ID, repo name, initial owners)
|
||||
// with debounced server-side validation per §13.2 and a precondition
|
||||
// popover backed by /blocking-prs for the §9.8 open-body-edit-PR gate.
|
||||
//
|
||||
// On confirm, opens the §13.3 SSE stream and renders the five named
|
||||
// steps with per-step states. On failure, the rollback step's events
|
||||
// append to the stack and a "What happened" panel renders below until
|
||||
// the admin dismisses it.
|
||||
// Meta-only topology (SPEC §1): graduation is an in-place state flip on
|
||||
// the meta entry — no per-RFC repo is created and the body is kept. The
|
||||
// dialog renders two editable fields (integer ID + initial owners) with
|
||||
// debounced server-side validation per §13.2. On confirm it opens the
|
||||
// §13.3 SSE stream and renders the two named steps (open the flip PR,
|
||||
// merge it). There is no rollback step and no repo-name field.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
graduateCheck,
|
||||
listBlockingPRs,
|
||||
openGraduationProgress,
|
||||
startGraduation,
|
||||
} from '../api'
|
||||
|
||||
const CHECK_DEBOUNCE_MS = 250
|
||||
|
||||
const STEP_KEY_ORDER = ['create_repo', 'seed_files', 'open_pr', 'merge_pr', 'refresh_cache']
|
||||
const STEP_KEY_ORDER = ['open_pr', 'merge_pr']
|
||||
|
||||
export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
// Suggest defaults from the catalog.
|
||||
const suggestedId = useMemo(() => suggestNextRfcId(entry?.allKnownIds || []), [entry])
|
||||
const [rfcId, setRfcId] = useState(suggestedId)
|
||||
const [repoName, setRepoName] = useState(`rfc-${stripPrefix(suggestedId)}-${slug}`)
|
||||
const [owners, setOwners] = useState(entry?.owners?.length ? entry.owners : [])
|
||||
const [newOwner, setNewOwner] = useState('')
|
||||
|
||||
const [checkResult, setCheckResult] = useState(null)
|
||||
const [blockingPRs, setBlockingPRs] = useState([])
|
||||
const [precondPopover, setPrecondPopover] = useState(false)
|
||||
const [phase, setPhase] = useState('idle') // idle | running | done | rolled_back | error
|
||||
const [phase, setPhase] = useState('idle') // idle | running | done | failed | error
|
||||
const [streamState, setStreamState] = useState(null)
|
||||
const [submitError, setSubmitError] = useState(null)
|
||||
|
||||
const esRef = useRef(null)
|
||||
|
||||
// Initial blocking-PRs probe + ongoing /check polling.
|
||||
useEffect(() => {
|
||||
listBlockingPRs(slug).then(({ items }) => setBlockingPRs(items || [])).catch(() => {})
|
||||
}, [slug])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
graduateCheck(slug, { id: rfcId, repo: repoName })
|
||||
graduateCheck(slug, { id: rfcId })
|
||||
.then(setCheckResult)
|
||||
.catch(() => {})
|
||||
}, CHECK_DEBOUNCE_MS)
|
||||
return () => clearTimeout(t)
|
||||
}, [slug, rfcId, repoName])
|
||||
}, [slug, rfcId])
|
||||
|
||||
useEffect(() => () => { esRef.current?.close() }, [])
|
||||
|
||||
const idError = checkResult?.id?.error || null
|
||||
const repoError = checkResult?.repo?.error || null
|
||||
const ownersOk = owners.length > 0
|
||||
const ownersError = ownersOk ? null : 'Add at least one initial owner'
|
||||
const blockingError = blockingPRs.length > 0
|
||||
? `${blockingPRs.length} open body-edit PR${blockingPRs.length === 1 ? '' : 's'} blocking graduation`
|
||||
: null
|
||||
|
||||
// First-blocker tooltip text per §13.2.
|
||||
const firstBlocker = idError || repoError || ownersError || blockingError
|
||||
const canSubmit = !firstBlocker && phase === 'idle' && checkResult?.id?.ok && checkResult?.repo?.ok
|
||||
const firstBlocker = idError || ownersError
|
||||
const canSubmit = !firstBlocker && phase === 'idle' && checkResult?.id?.ok && ownersOk
|
||||
|
||||
const handleAddOwner = useCallback(() => {
|
||||
const v = newOwner.trim().toLowerCase()
|
||||
@@ -81,7 +66,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
setSubmitError(null)
|
||||
setPhase('running')
|
||||
try {
|
||||
await startGraduation(slug, { rfcId, repoName, owners })
|
||||
await startGraduation(slug, { rfcId, owners })
|
||||
} catch (err) {
|
||||
setPhase('idle')
|
||||
setSubmitError(err.message)
|
||||
@@ -96,7 +81,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
// Short hold per §13.3, then dismiss.
|
||||
setTimeout(() => onCompleted?.(payload), 1500)
|
||||
} else {
|
||||
setPhase('rolled_back')
|
||||
setPhase('failed')
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -105,7 +90,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
setPhase('error')
|
||||
},
|
||||
})
|
||||
}, [slug, rfcId, repoName, owners, onCompleted])
|
||||
}, [slug, rfcId, owners, onCompleted])
|
||||
|
||||
// ----- Render -----
|
||||
|
||||
@@ -122,10 +107,10 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
{!showStack && (
|
||||
<div className="modal-body">
|
||||
<p className="modal-intro">
|
||||
§13: graduate the super-draft to its own repo. The meta-repo entry
|
||||
becomes frontmatter-only; the canonical body moves to `RFC.md` in
|
||||
the new repo. The sequence runs as five transactional steps with
|
||||
rollback per §13.3.
|
||||
§13: graduate the super-draft to active. This is an in-place
|
||||
state flip — the entry keeps its body and stays in the meta
|
||||
repo; only the frontmatter changes (state, integer ID, and the
|
||||
graduation stamps). No new repository is created.
|
||||
</p>
|
||||
|
||||
<div className="form-row">
|
||||
@@ -141,19 +126,6 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
{idError && <p className="field-error">{idError}</p>}
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label>Repo name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={repoName}
|
||||
onChange={(e) => setRepoName(e.target.value.trim())}
|
||||
placeholder="rfc-NNNN-slug"
|
||||
disabled={phase !== 'idle'}
|
||||
/>
|
||||
<p className="field-help">Becomes `<org>/{repoName || 'rfc-…'}` on Gitea.</p>
|
||||
{repoError && <p className="field-error">{repoError}</p>}
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label>Initial owners</label>
|
||||
<div className="owner-list">
|
||||
@@ -189,68 +161,24 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
</div>
|
||||
{ownersError && <p className="field-error">{ownersError}</p>}
|
||||
</div>
|
||||
|
||||
{blockingPRs.length > 0 && (
|
||||
<div className="precondition-block">
|
||||
<button
|
||||
type="button"
|
||||
className="precondition-toggle"
|
||||
onClick={() => setPrecondPopover(p => !p)}
|
||||
>
|
||||
{blockingPRs.length} open body-edit PR{blockingPRs.length === 1 ? '' : 's'} blocking graduation
|
||||
{precondPopover ? '▾' : '▸'}
|
||||
</button>
|
||||
{precondPopover && (
|
||||
<div className="precondition-popover">
|
||||
{blockingPRs.map(pr => (
|
||||
<div key={pr.pr_number} className="precondition-row">
|
||||
<div className="precondition-row-main">
|
||||
<strong>PR #{pr.pr_number}</strong> — {pr.title || '(no title)'}
|
||||
<div className="precondition-row-meta">
|
||||
{pr.author ? `by @${pr.author}` : ''}
|
||||
{pr.last_activity_at ? ` · ${pr.last_activity_at.slice(0, 10)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="precondition-row-actions">
|
||||
<a
|
||||
className="btn-link"
|
||||
href={`/rfc/${slug}/pr/${pr.pr_number}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>Open ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="precondition-help">
|
||||
§9.8: open body-edit PRs would attempt to re-introduce a
|
||||
body to a frontmatter-only entry after step 3. Resolve
|
||||
them (merge or withdraw) and re-open this dialog.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showStack && (
|
||||
<div className="modal-body">
|
||||
<StepStack
|
||||
steps={streamState?.steps || []}
|
||||
rollbackSteps={streamState?.rollback_steps || []}
|
||||
/>
|
||||
{phase === 'rolled_back' && (
|
||||
<StepStack steps={streamState?.steps || []} />
|
||||
{phase === 'failed' && (
|
||||
<div className="what-happened">
|
||||
<h3>What happened</h3>
|
||||
<p>
|
||||
The graduation could not complete. The app rolled back the
|
||||
steps that had already run; nothing was left half-applied on
|
||||
Gitea. Error: <code>{streamState?.error || 'unknown'}</code>.
|
||||
The graduation could not complete. Because it is a single
|
||||
in-place flip, nothing was left half-applied — `{slug}` stays
|
||||
a super-draft. Error: <code>{streamState?.error || 'unknown'}</code>.
|
||||
</p>
|
||||
<p>
|
||||
Read the failure detail next to the red step above. Resolve
|
||||
the underlying cause (a repo-name collision, a network flake,
|
||||
a concurrent PR landing on `rfcs/{slug}.md`) and try again.
|
||||
Read the failure detail next to the red step above, resolve
|
||||
the underlying cause (a concurrent PR landing on{' '}
|
||||
`rfcs/{slug}.md`, a network flake), and try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -258,9 +186,8 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
<div className="graduation-complete">
|
||||
<h3>Graduation complete</h3>
|
||||
<p>
|
||||
`{slug}` is now active as <strong>{streamState?.rfc_id}</strong>{' '}
|
||||
at <code>{streamState?.repo_full}</code>. The catalog and the
|
||||
RFC view reflect the new state.
|
||||
`{slug}` is now active as <strong>{streamState?.rfc_id}</strong>.
|
||||
The catalog and the RFC view reflect the new state.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -277,23 +204,23 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
disabled={!canSubmit}
|
||||
title={canSubmit ? '' : firstBlocker || ''}
|
||||
>
|
||||
Graduate to RFC repo
|
||||
Graduate
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{phase === 'running' && (
|
||||
<span className="modal-progress-note">Running graduation sequence…</span>
|
||||
<span className="modal-progress-note">Graduating…</span>
|
||||
)}
|
||||
{(phase === 'rolled_back' || phase === 'error') && (
|
||||
{(phase === 'failed' || phase === 'error') && (
|
||||
<button className="btn-secondary" onClick={onClose}>Close</button>
|
||||
)}
|
||||
{phase === 'done' && (
|
||||
<button className="btn-primary" onClick={() => onCompleted?.(streamState)}>
|
||||
View the new RFC
|
||||
View the RFC
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{submitError && phase !== 'rolled_back' && (
|
||||
{submitError && phase !== 'failed' && (
|
||||
<div className="modal-error">Error: {submitError}</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -302,14 +229,10 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
}
|
||||
|
||||
|
||||
function StepStack({ steps, rollbackSteps }) {
|
||||
function StepStack({ steps }) {
|
||||
return (
|
||||
<div className="step-stack">
|
||||
{steps.map(s => <StepRow key={s.key} step={s} />)}
|
||||
{rollbackSteps.length > 0 && (
|
||||
<div className="rollback-divider">Rollback</div>
|
||||
)}
|
||||
{rollbackSteps.map(s => <StepRow key={s.key} step={s} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -350,8 +273,3 @@ function suggestNextRfcId(existing) {
|
||||
const next = used.size === 0 ? 1 : (Math.max(...used) + 1)
|
||||
return `RFC-${String(next).padStart(4, '0')}`
|
||||
}
|
||||
|
||||
|
||||
function stripPrefix(rfcId) {
|
||||
return rfcId?.startsWith('RFC-') ? rfcId.slice(4) : rfcId
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/* Inbox.css — §15.2 inbox panel refinements (roadmap #25, light pass).
|
||||
*
|
||||
* The base inbox layout/structure lives in App.css (the §15 / Slice 6
|
||||
* block). This sheet is a TOKENIZED polish layer on top of it: it does
|
||||
* NOT re-lay-out the panel, it sharpens the unread/read distinction,
|
||||
* adds the per-row "mark read" affordance + the unread dot, and gives
|
||||
* the empty/loading states real copy and spacing.
|
||||
*
|
||||
* Cascade note: Inbox.jsx is imported by App.jsx (line 6) BEFORE the
|
||||
* App.css import (line 30), so under ESM depth-first evaluation this
|
||||
* sheet is injected FIRST and App.css wins on equal specificity. Any
|
||||
* rule here that must override an App.css value is therefore written
|
||||
* one notch more specific (e.g. `.inbox-list .inbox-row.unread`).
|
||||
* New classes that App.css doesn't define need no such guard.
|
||||
*/
|
||||
|
||||
/* ===== Unread vs. read distinction ===== */
|
||||
|
||||
/* A clear left accent bar + warmer tint on unread; read rows sit calm. */
|
||||
.inbox-list .inbox-row {
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
transition: background var(--motion-fast) var(--ease-out);
|
||||
}
|
||||
.inbox-list .inbox-row.unread {
|
||||
background: var(--color-warning-bg-soft, var(--c-warning-bg-soft));
|
||||
box-shadow: inset 3px 0 0 var(--color-accent);
|
||||
}
|
||||
.inbox-list .inbox-row.read .inbox-summary {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--weight-normal);
|
||||
}
|
||||
.inbox-list .inbox-row.unread .inbox-summary {
|
||||
color: var(--color-text);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
|
||||
/* The dot is a NEW affordance: a filled accent dot for unread, hidden
|
||||
* (but space-reserved) for read so summaries stay column-aligned. */
|
||||
.inbox-unread-dot {
|
||||
flex: 0 0 auto;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent);
|
||||
}
|
||||
.inbox-row.read .inbox-unread-dot {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ===== Per-row "mark read" affordance ===== */
|
||||
|
||||
/* The row is a flex Link followed by this button; pin the button to the
|
||||
* right edge, revealed on row hover/focus and always visible on touch. */
|
||||
.inbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.inbox-row .inbox-row-link {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.inbox-row-dismiss {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-right: var(--space-5);
|
||||
padding: 0;
|
||||
color: var(--color-text-subtle);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--motion-fast) var(--ease-out),
|
||||
color var(--motion-fast) var(--ease-out),
|
||||
background var(--motion-fast) var(--ease-out),
|
||||
border-color var(--motion-fast) var(--ease-out);
|
||||
}
|
||||
.inbox-row:hover .inbox-row-dismiss,
|
||||
.inbox-row:focus-within .inbox-row-dismiss,
|
||||
.inbox-row-dismiss:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.inbox-row-dismiss:hover {
|
||||
color: var(--color-success-fg);
|
||||
background: var(--color-success-bg);
|
||||
border-color: var(--color-success-bg);
|
||||
}
|
||||
.inbox-row-dismiss:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
/* Coarse pointers (touch) have no hover; keep the affordance discoverable. */
|
||||
@media (hover: none) {
|
||||
.inbox-row-dismiss { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ===== Mark-all-read button ===== */
|
||||
.inbox-mark-all {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ===== Empty / loading states ===== */
|
||||
.inbox-state {
|
||||
padding: var(--space-9) var(--space-7);
|
||||
text-align: center;
|
||||
}
|
||||
.inbox-empty {
|
||||
padding: var(--space-11) var(--space-7);
|
||||
text-align: center;
|
||||
}
|
||||
.inbox-empty-title {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
.inbox-empty .muted {
|
||||
margin: 0;
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
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; }
|
||||
@@ -11,10 +11,13 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
acceptContributionRequest,
|
||||
declineContributionRequest,
|
||||
listNotifications,
|
||||
markNotificationRead,
|
||||
markNotificationsReadByFilter,
|
||||
} from '../api.js'
|
||||
import './Inbox.css'
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: '', label: 'All categories' },
|
||||
@@ -56,11 +59,15 @@ export default function Inbox({ onClose, lastChangeTick }) {
|
||||
return Array.from(seen.entries())
|
||||
}, [items])
|
||||
|
||||
async function markOneRead(item) {
|
||||
if (item.read_at) return
|
||||
await markNotificationRead(item.id)
|
||||
setItems(prev => prev.map(p => p.id === item.id ? { ...p, read_at: new Date().toISOString() } : p))
|
||||
setUnreadCount(c => Math.max(0, c - 1))
|
||||
}
|
||||
|
||||
async function handleRowClick(item) {
|
||||
if (!item.read_at) {
|
||||
await markNotificationRead(item.id)
|
||||
setItems(prev => prev.map(p => p.id === item.id ? { ...p, read_at: new Date().toISOString() } : p))
|
||||
}
|
||||
await markOneRead(item)
|
||||
}
|
||||
|
||||
async function markAllUnderFilter() {
|
||||
@@ -121,22 +128,36 @@ export default function Inbox({ onClose, lastChangeTick }) {
|
||||
</label>
|
||||
|
||||
<button
|
||||
className="btn-link"
|
||||
className="btn-link inbox-mark-all"
|
||||
onClick={markAllUnderFilter}
|
||||
disabled={items.every(i => i.read_at)}
|
||||
title="Mark every notification matching the current filter as read"
|
||||
>
|
||||
Mark all read (under filter)
|
||||
Mark all read
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="inbox-body">
|
||||
{loading && <p className="muted">Loading…</p>}
|
||||
{loading && <p className="inbox-state muted">Loading your inbox…</p>}
|
||||
{!loading && items.length === 0 && (
|
||||
<p className="muted">No notifications match. Try a different filter, or come back later.</p>
|
||||
<div className="inbox-empty">
|
||||
<p className="inbox-empty-title">You're all caught up.</p>
|
||||
<p className="muted">
|
||||
{filters.unread || filters.rfcSlug || filters.category
|
||||
? 'Nothing matches the current filters. Clear them to see everything.'
|
||||
: 'New activity on RFCs you follow will show up here.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ul className="inbox-list">
|
||||
{items.map(item => (
|
||||
<InboxRow key={item.id} item={item} onClick={handleRowClick} onClose={onClose} />
|
||||
<InboxRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={handleRowClick}
|
||||
onMarkRead={markOneRead}
|
||||
onClose={onClose}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -145,16 +166,88 @@ export default function Inbox({ onClose, lastChangeTick }) {
|
||||
)
|
||||
}
|
||||
|
||||
function InboxRow({ item, onClick, onClose }) {
|
||||
// #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 }) {
|
||||
if (item.event_kind === 'contribution_request_on_pending_rfc') {
|
||||
return <ContributionRequestRow item={item} onMarkRead={onMarkRead} />
|
||||
}
|
||||
const unread = !item.read_at
|
||||
const target = deepLink(item)
|
||||
const handle = async () => {
|
||||
await onClick(item)
|
||||
if (target) onClose?.()
|
||||
}
|
||||
const handleMarkRead = async (e) => {
|
||||
// Don't let the row's Link fire — this affordance only marks read,
|
||||
// it never navigates.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
await onMarkRead(item)
|
||||
}
|
||||
return (
|
||||
<li className={`inbox-row ${unread ? 'unread' : ''}`}>
|
||||
<li className={`inbox-row ${unread ? 'unread' : 'read'}`}>
|
||||
<Link to={target || '#'} onClick={handle} className="inbox-row-link">
|
||||
<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>
|
||||
{item.bundled_count > 1 && (
|
||||
@@ -162,6 +255,24 @@ function InboxRow({ item, onClick, onClose }) {
|
||||
)}
|
||||
<span className="inbox-when">{formatWhen(item.created_at)}</span>
|
||||
</Link>
|
||||
{unread && (
|
||||
<button
|
||||
type="button"
|
||||
className="inbox-row-dismiss"
|
||||
onClick={handleMarkRead}
|
||||
aria-label="Mark as read"
|
||||
title="Mark as read"
|
||||
>
|
||||
{/* check glyph — dependency-free inline SVG */}
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" strokeWidth="2.25"
|
||||
strokeLinecap="round" strokeLinejoin="round" aria-hidden
|
||||
>
|
||||
<path d="m5 13 4 4 10-11" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// LinkedText.jsx — roadmap #28 (Parts 1–3).
|
||||
//
|
||||
// Renders a backend-provided list of text/link segments (see
|
||||
// backend/app/rfc_links.py). References in PR descriptions and comments
|
||||
// arrive pre-scanned as structured segments — this component maps them
|
||||
// onto plain text runs, anchors, and inline affordances. It never renders
|
||||
// HTML from the server (no dangerouslySetInnerHTML), so the surface is
|
||||
// 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
|
||||
// the field is absent (an older cached response, or a caller that didn't
|
||||
// pass segments).
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function LinkedText({ segments, text, viewer, canCreate }) {
|
||||
if (!Array.isArray(segments) || segments.length === 0) {
|
||||
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 (
|
||||
<>
|
||||
{segments.map((seg, i) => {
|
||||
if (seg.type === 'rfc') {
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
className="rfc-autolink"
|
||||
href={`/rfc/${seg.slug}`}
|
||||
title={seg.title ? `RFC: ${seg.title}` : undefined}
|
||||
>
|
||||
{seg.label}
|
||||
</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>
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { Marked } from 'marked'
|
||||
import { sanitizeHtml } from '../lib/sanitizeHtml'
|
||||
import { decorateAcceptedChanges } from './trackedOverlay.js'
|
||||
import ChangeTooltip from './ChangeTooltip.jsx'
|
||||
|
||||
@@ -98,7 +99,7 @@ export default function MarkdownPreview({
|
||||
// synchronously with the body itself — no flash of un-decorated text.
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) return
|
||||
const html = previewMarked.parse(content || '')
|
||||
const html = sanitizeHtml(previewMarked.parse(content || ''))
|
||||
hostRef.current.innerHTML = html
|
||||
const token = ++renderTokenRef.current
|
||||
// Reset memo so the new block set re-renders from scratch.
|
||||
|
||||
@@ -15,6 +15,8 @@ import { EVENTS, track } from '../lib/analytics'
|
||||
export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpened }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
// #26: optional ground-truth use case for this change.
|
||||
const [useCase, setUseCase] = useState('')
|
||||
const [drafting, setDrafting] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [confirmed, setConfirmed] = useState(!branchIsPrivate)
|
||||
@@ -39,7 +41,11 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { pr_number } = await openPR(slug, branch, { title: title.trim(), description: description.trim() })
|
||||
const { pr_number } = await openPR(slug, branch, {
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
proposedUseCase: useCase.trim() || null,
|
||||
})
|
||||
// v0.15.0 — analytics: fire on §10.2 PR-open success. slug
|
||||
// and pr_number are the join keys; title/description stay out.
|
||||
track(EVENTS.PR_OPENED, { rfc_slug: slug, pr_number })
|
||||
@@ -104,6 +110,20 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen
|
||||
what was argued, what shifted, what the arbiters are asked
|
||||
to consider.
|
||||
</p>
|
||||
<label className="modal-label">What will you be using this change for? (optional)</label>
|
||||
<textarea
|
||||
className="modal-textarea"
|
||||
value={useCase}
|
||||
onChange={e => setUseCase(e.target.value)}
|
||||
placeholder="The concrete thing this change unlocks for you. Optional."
|
||||
disabled={drafting || submitting}
|
||||
rows={3}
|
||||
maxLength={8000}
|
||||
/>
|
||||
<p className="field-help">
|
||||
#26: the concrete ground-truth use case — distinct from "why
|
||||
it's needed" above. Leave blank if you'd rather not say.
|
||||
</p>
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
withdrawPR,
|
||||
} from '../api'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
import LinkedText from './LinkedText'
|
||||
|
||||
export default function PRView({ viewer }) {
|
||||
const { slug, prNumber: prNumberParam } = useParams()
|
||||
@@ -218,8 +219,21 @@ export default function PRView({ viewer }) {
|
||||
<>
|
||||
<h1 className="pr-title">{pr.title}</h1>
|
||||
{pr.description && (
|
||||
<p className="pr-description">{pr.description}</p>
|
||||
<p className="pr-description">
|
||||
<LinkedText segments={pr.description_segments} text={pr.description} viewer={viewer} canCreate={viewer?.permission_state === 'granted'} />
|
||||
</p>
|
||||
)}
|
||||
{/* #26: the optional ground-truth use case for this change,
|
||||
captured when the PR was opened. Muted "left blank"
|
||||
treatment when none was supplied. */}
|
||||
<div className="pr-use-case" style={{ margin: '6px 0', fontSize: 13 }}>
|
||||
<span style={{ fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', fontSize: 11 }}>
|
||||
Intended use case:
|
||||
</span>{' '}
|
||||
{pr.proposed_use_case
|
||||
? <span style={{ whiteSpace: 'pre-wrap' }}>{pr.proposed_use_case}</span>
|
||||
: <span style={{ color: '#999', fontStyle: 'italic' }}>left blank</span>}
|
||||
</div>
|
||||
{pr.capabilities?.can_edit_text && (
|
||||
<button className="btn-link" onClick={startHeaderEdit}>Edit title & description</button>
|
||||
)}
|
||||
@@ -429,7 +443,9 @@ function PRConversation({ threads, messagesByThread, threadsByKind, seenMsgId })
|
||||
{isNew && <span className="chat-msg-new-pip" title="New since your last visit">●</span>}
|
||||
</div>
|
||||
{m.quote && <pre className="chat-msg-quote">{m.quote}</pre>}
|
||||
<div className="chat-msg-body">{m.text}</div>
|
||||
<div className="chat-msg-body">
|
||||
<LinkedText segments={m.text_segments} text={m.text} viewer={viewer} canCreate={viewer?.permission_state === 'granted'} />
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { marked } from 'marked'
|
||||
import { renderMarkdown } from '../lib/sanitizeHtml'
|
||||
import { getProposal, mergeProposal, declineProposal, withdrawProposal } from '../api'
|
||||
|
||||
export default function ProposalView({ viewer, onChange }) {
|
||||
@@ -161,8 +161,16 @@ export default function ProposalView({ viewer, onChange }) {
|
||||
</h3>
|
||||
<div
|
||||
className="entry-body"
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(data.entry?.body || '') }}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(data.entry?.body || '') }}
|
||||
/>
|
||||
|
||||
{/* #26: the optional ground-truth use case the proposer supplied. */}
|
||||
<h3 style={{ fontSize: 13, fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: 24 }}>
|
||||
Intended use case
|
||||
</h3>
|
||||
{data.proposed_use_case
|
||||
? <div className="entry-body" dangerouslySetInnerHTML={{ __html: renderMarkdown(data.proposed_use_case) }} />
|
||||
: <p style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</p>}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,17 +2,24 @@
|
||||
//
|
||||
// Title (required) and pitch (required textarea), with a slug field
|
||||
// that auto-fills from the title via the same deterministic kebab-case
|
||||
// the backend uses. Tags are chip-input (free-form for slice 1; the
|
||||
// AI-suggested chips of §9.1 are deferred to Slice 2 when the AI surface
|
||||
// is wired up).
|
||||
// the backend uses. Tags are chip-input (free-form), with the §9.1
|
||||
// Slice 2 AI-suggested chips (roadmap #27) wired in: as the draft fills
|
||||
// in, the backend asks Claude Haiku for tags drawn from the corpus's
|
||||
// existing tag set, surfaced as clickable suggestion chips. The assist
|
||||
// is best-effort — it stays silent when unavailable.
|
||||
//
|
||||
// The submit button drives the §17 POST /api/rfcs/propose endpoint;
|
||||
// success navigates the proposer to the pending-idea view per §9.3.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { proposeRFC } from '../api'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { proposeRFC, suggestTags } from '../api'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
// How long the draft must sit unchanged before we ask for suggestions —
|
||||
// long enough to fire on typing pauses / field-blur, not on every
|
||||
// keystroke (the backend is also per-user rate-limited as a backstop).
|
||||
const SUGGEST_DEBOUNCE_MS = 700
|
||||
|
||||
function slugify(title) {
|
||||
return title
|
||||
.toLowerCase()
|
||||
@@ -21,26 +28,64 @@ function slugify(title) {
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
||||
const [title, setTitle] = useState('')
|
||||
export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '' }) {
|
||||
// #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 [slugEdited, setSlugEdited] = useState(false)
|
||||
const [pitch, setPitch] = useState('')
|
||||
// #26: optional ground-truth use case, sibling to the required pitch.
|
||||
const [useCase, setUseCase] = useState('')
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
const [tags, setTags] = useState([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
// #27: Claude Haiku tag suggestions. `suggestions` is the latest
|
||||
// ranked list from the backend ({ tag, confidence }); we render the
|
||||
// subset not already chosen. `suggestedOnce` gates the disclosure +
|
||||
// row so they only appear after the assist has actually run.
|
||||
const [suggestions, setSuggestions] = useState([])
|
||||
const [suggestedOnce, setSuggestedOnce] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!slugEdited) setSlug(slugify(title))
|
||||
}, [title, slugEdited])
|
||||
|
||||
// #27: debounced tag-suggestion fetch. Fires after the draft sits
|
||||
// unchanged for SUGGEST_DEBOUNCE_MS, only once there's something to go
|
||||
// on (a title). A stale-response guard keeps an earlier in-flight
|
||||
// request from clobbering a newer one.
|
||||
const suggestSeq = useRef(0)
|
||||
useEffect(() => {
|
||||
if (!title.trim()) {
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
const handle = setTimeout(async () => {
|
||||
const seq = ++suggestSeq.current
|
||||
const result = await suggestTags({ title, pitch, useCase })
|
||||
if (seq !== suggestSeq.current) return // a newer request superseded us
|
||||
setSuggestions(Array.isArray(result) ? result : [])
|
||||
if (result && result.length) setSuggestedOnce(true)
|
||||
}, SUGGEST_DEBOUNCE_MS)
|
||||
return () => clearTimeout(handle)
|
||||
}, [title, pitch, useCase])
|
||||
|
||||
// Suggestions the user hasn't already added.
|
||||
const freshSuggestions = suggestions.filter(s => !tags.includes(s.tag))
|
||||
|
||||
function addTag() {
|
||||
const t = tagInput.trim()
|
||||
if (t && !tags.includes(t)) setTags([...tags, t])
|
||||
setTagInput('')
|
||||
}
|
||||
|
||||
function addSuggested(tag) {
|
||||
if (!tags.includes(tag)) setTags([...tags, tag])
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!title.trim() || !slug || !pitch.trim()) return
|
||||
@@ -52,6 +97,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
||||
slug,
|
||||
pitch: pitch.trim(),
|
||||
tags,
|
||||
proposedUseCase: useCase.trim() || null,
|
||||
})
|
||||
// v0.15.0 — analytics: fire on the §9.1 propose-RFC submit.
|
||||
// Slug is a stable, low-cardinality identifier (kebab-case
|
||||
@@ -105,6 +151,19 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
||||
required
|
||||
/>
|
||||
|
||||
<label htmlFor="propose-use-case">What will you be using this RFC for? (optional)</label>
|
||||
<textarea
|
||||
id="propose-use-case"
|
||||
value={useCase}
|
||||
onChange={e => setUseCase(e.target.value)}
|
||||
placeholder="The concrete thing you intend to build or do with this RFC. Optional, but it helps ground the work."
|
||||
rows={3}
|
||||
/>
|
||||
<p className="field-help">
|
||||
The concrete ground-truth use case — distinct from "why it's
|
||||
needed" above. Leave blank if you'd rather not say.
|
||||
</p>
|
||||
|
||||
<label htmlFor="propose-tag">Tags (optional)</label>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
|
||||
<input
|
||||
@@ -136,6 +195,45 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* #27: Claude Haiku suggested tags. Clickable chips that add
|
||||
to the tag list; nothing auto-applies. The disclosure is
|
||||
required — the draft text is sent to Anthropic to generate
|
||||
these — so it renders whenever the suggestion row does. */}
|
||||
{(freshSuggestions.length > 0 || (suggestedOnce && suggestions.length > 0)) && (
|
||||
<div style={{ marginTop: 4, marginBottom: 14 }}>
|
||||
{freshSuggestions.length > 0 && (
|
||||
<>
|
||||
<p className="field-help" style={{ marginTop: 0, marginBottom: 4 }}>
|
||||
Suggested tags — click to add:
|
||||
</p>
|
||||
<div>
|
||||
{freshSuggestions.map(s => (
|
||||
<button
|
||||
key={s.tag}
|
||||
type="button"
|
||||
className="entry-tag"
|
||||
onClick={() => addSuggested(s.tag)}
|
||||
title={`Add "${s.tag}"`}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
marginRight: 4,
|
||||
marginBottom: 4,
|
||||
border: '1px dashed var(--color-border, #ccc)',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>+ {s.tag}</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="field-help" style={{ marginTop: 4, marginBottom: 0, fontStyle: 'italic' }}>
|
||||
Suggestions are generated by Claude (Anthropic). The text you've
|
||||
entered above is sent to Anthropic to produce them.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewer && (
|
||||
<p className="field-help" style={{ marginTop: 14, marginBottom: 0 }}>
|
||||
Owner: <strong>{viewer.display_name || viewer.gitea_login}</strong> — you'll be the first owner of this super-draft. Additional owners can claim later (§13.1).
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
resolveDiscussionThread,
|
||||
} from '../api'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
import LinkedText from './LinkedText'
|
||||
|
||||
export default function RFCDiscussionPanel({ slug, viewer }) {
|
||||
const [threads, setThreads] = useState([])
|
||||
@@ -190,7 +191,7 @@ export default function RFCDiscussionPanel({ slug, viewer }) {
|
||||
</div>
|
||||
)}
|
||||
{activeMessages.map(msg => (
|
||||
<DiscussionMessage key={msg.id} message={msg} />
|
||||
<DiscussionMessage key={msg.id} message={msg} viewer={viewer} />
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
@@ -257,7 +258,7 @@ export default function RFCDiscussionPanel({ slug, viewer }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DiscussionMessage({ message }) {
|
||||
function DiscussionMessage({ message, viewer }) {
|
||||
const isSystem = message.role === 'system'
|
||||
if (isSystem) {
|
||||
return (
|
||||
@@ -279,7 +280,9 @@ function DiscussionMessage({ message }) {
|
||||
{message.quote && (
|
||||
<div className="discussion-message-quote">"{message.quote}"</div>
|
||||
)}
|
||||
<div className="discussion-message-body">{message.text}</div>
|
||||
<div className="discussion-message-body">
|
||||
<LinkedText segments={message.text_segments} text={message.text} viewer={viewer} canCreate={viewer?.permission_state === 'granted'} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -669,6 +669,19 @@ export default function RFCView({ viewer }) {
|
||||
: 'main is read-only — PRs are the only path to change it. Open a branch to propose edits.'}
|
||||
</div>
|
||||
)}
|
||||
{/* #26: the optional ground-truth use case captured at propose
|
||||
time. Shown on the canonical (main) view; muted "left blank"
|
||||
treatment when the proposer didn't supply one. */}
|
||||
{branchParam === 'main' && (
|
||||
<div className="rfc-use-case" style={{ margin: '8px 0 16px', padding: '10px 14px', borderLeft: '3px solid #e0e0e0', background: '#fafafa' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>
|
||||
Intended use case
|
||||
</div>
|
||||
{entry.proposed_use_case
|
||||
? <div style={{ whiteSpace: 'pre-wrap' }}>{entry.proposed_use_case}</div>
|
||||
: <span style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</span>}
|
||||
</div>
|
||||
)}
|
||||
{inDiscuss && branchParam !== 'main' && (
|
||||
<div className="discuss-mode-banner">
|
||||
Discuss mode on <strong>{branchParam}</strong> — chat freely;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
:root {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||
Arial, sans-serif;
|
||||
color: #1a1a1a;
|
||||
background: #fafaf8;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// sanitizeHtml.js — the single chokepoint for turning user-authored
|
||||
// markdown into DOM-bound HTML.
|
||||
//
|
||||
// Security audit 0026 (finding C1, Critical): every `marked.parse(...)`
|
||||
// result that reaches an `innerHTML` / `dangerouslySetInnerHTML` sink was
|
||||
// previously written raw. `marked` passes through embedded HTML and
|
||||
// `javascript:`/event-handler attributes verbatim, so any user-authored
|
||||
// document (RFC body, proposal body, proposed_use_case, transcript) was a
|
||||
// stored-XSS vector — a contributor's payload executed in the session of
|
||||
// whoever viewed it, including an admin/owner during review.
|
||||
//
|
||||
// Fix: route EVERY markdown render through `renderMarkdown` (or, for
|
||||
// already-rendered HTML, `sanitizeHtml`). DOMPurify's defaults already
|
||||
// strip <script>, on* event handlers, and javascript:/unsafe-data: URIs;
|
||||
// we add a hook so any link opening a new tab carries rel="noopener
|
||||
// noreferrer". The html profile keeps the standard markdown tag set plus
|
||||
// class + data-* attributes (the latter is what MarkdownPreview's mermaid
|
||||
// placeholder relies on); mermaid renders its SVG into the DOM *after*
|
||||
// sanitization and is itself locked down with securityLevel:'strict'.
|
||||
|
||||
import DOMPurify from 'dompurify'
|
||||
import { marked } from 'marked'
|
||||
|
||||
let _hookInstalled = false
|
||||
function ensureHook() {
|
||||
if (_hookInstalled) return
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName === 'A' && node.getAttribute('target') === '_blank') {
|
||||
node.setAttribute('rel', 'noopener noreferrer')
|
||||
}
|
||||
})
|
||||
_hookInstalled = true
|
||||
}
|
||||
|
||||
// Sanitize an already-rendered HTML string. Use when the HTML did not come
|
||||
// from `marked` (rare) or when a caller parses markdown with a bespoke
|
||||
// `Marked` instance and only needs the sanitize step.
|
||||
export function sanitizeHtml(html) {
|
||||
ensureHook()
|
||||
return DOMPurify.sanitize(html || '', { USE_PROFILES: { html: true } })
|
||||
}
|
||||
|
||||
// Parse markdown with the shared `marked` and sanitize the result. This is
|
||||
// the drop-in replacement for `marked.parse(src)` at any HTML sink.
|
||||
export function renderMarkdown(src) {
|
||||
return sanitizeHtml(marked.parse(src || ''))
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// useLastState — v0.23.0 / roadmap item #29: server-side sign-in state
|
||||
// resume. Two responsibilities, kept in one small hook so App.jsx's
|
||||
// surface stays minimal:
|
||||
//
|
||||
// 1. Debounce-post the authenticated user's current route to
|
||||
// `PUT /api/me/last-state` on every route change (~1s debounce so
|
||||
// a fast click-through doesn't spray the endpoint). Anonymous
|
||||
// users: no-op. Best-effort: a failed POST never disrupts nav.
|
||||
//
|
||||
// 2. On the first authenticated load, read the stored `last_route`
|
||||
// (handed back on `/api/auth/me`) and `navigate()` to it ONCE.
|
||||
// Stale routes (a withdrawn RFC, a slug the user lost rights to)
|
||||
// are not special-cased here: navigating lands on whatever that
|
||||
// route renders today, and the existing routing already falls
|
||||
// through to the catalog/empty-state for a missing RFC. Keeping
|
||||
// this dumb is deliberate (see SPEC §6.2 + the #29 scope note).
|
||||
//
|
||||
// Ordering with #21 Part C (Amplitude identify): App.jsx fires
|
||||
// `identify` in its own effect when `me.user.id` first appears. This
|
||||
// hook's resume redirect is gated on `identifyReady` — App.jsx flips it
|
||||
// true only after the identify effect has run — so the redirect always
|
||||
// happens AFTER identify, preserving the identify-then-track ordering
|
||||
// the roadmap calls out.
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { putLastState } from '../api'
|
||||
|
||||
// ~1s debounce on the route-change POST. A frontend constant, not a
|
||||
// server knob — the backend takes whatever lands.
|
||||
const DEBOUNCE_MS = 1000
|
||||
|
||||
// Routes we never want to resume *to* — auth/landing surfaces that
|
||||
// would be nonsensical or hostile to drop a returning user onto. We
|
||||
// still record them (cheap, and the user may legitimately be sitting on
|
||||
// /docs), but the resume redirect skips them and falls through to the
|
||||
// default landing. Anything not listed resumes normally.
|
||||
const NON_RESUMABLE_PREFIXES = ['/login', '/welcome', '/invites/', '/invitations/']
|
||||
|
||||
function isResumable(route) {
|
||||
if (!route || typeof route !== 'string') return false
|
||||
if (route === '/') return false // "/" is already the default landing
|
||||
return !NON_RESUMABLE_PREFIXES.some(p => route.startsWith(p))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {boolean} opts.authenticated whether a viewer is signed in
|
||||
* @param {string} opts.pathname current location.pathname
|
||||
* @param {boolean} opts.identifyReady App flips true after identify fires
|
||||
* @param {string|null} opts.lastRoute stored route from /api/auth/me
|
||||
* @param {function} opts.navigate react-router navigate()
|
||||
*/
|
||||
export function useLastState({ authenticated, pathname, identifyReady, lastRoute, navigate }) {
|
||||
// ── 1. Debounced route-change POST ────────────────────────────────
|
||||
const timerRef = useRef(null)
|
||||
useEffect(() => {
|
||||
if (!authenticated) return undefined
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => {
|
||||
// Best-effort — swallow failures so an offline/401 POST never
|
||||
// surfaces as a navigation error.
|
||||
putLastState(pathname).catch(() => {})
|
||||
}, DEBOUNCE_MS)
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [authenticated, pathname])
|
||||
|
||||
// ── 2. One-time resume redirect ───────────────────────────────────
|
||||
// Fires once, after identify is ready, when there's a resumable
|
||||
// stored route AND the user is currently sitting on the default
|
||||
// landing ("/"). We only redirect from "/" so we never yank a user
|
||||
// who deep-linked somewhere specific (or refreshed mid-RFC) back to
|
||||
// their last route.
|
||||
const resumedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (resumedRef.current) return
|
||||
if (!authenticated || !identifyReady) return
|
||||
// Only resume when the app booted on the default landing — a hard
|
||||
// sign-in nav lands on "/", which is exactly the case we want.
|
||||
if (pathname !== '/') {
|
||||
resumedRef.current = true // user deep-linked; don't resume later either
|
||||
return
|
||||
}
|
||||
if (isResumable(lastRoute)) {
|
||||
resumedRef.current = true
|
||||
navigate(lastRoute, { replace: true })
|
||||
} else {
|
||||
resumedRef.current = true // nothing to resume to; keep default landing
|
||||
}
|
||||
}, [authenticated, identifyReady, lastRoute, pathname, navigate])
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import './styles/tokens.css'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/* tokens.css — the design-token foundation for rfc-app's UI.
|
||||
*
|
||||
* Roadmap item #31 (comprehensive UX polish). Before this file the app
|
||||
* had ~98 distinct hardcoded hex colors, font sizes scattered across 16
|
||||
* values with no scale, and radii across 13 values — classic prototype
|
||||
* sprawl. This module establishes ONE coherent system; the App.css sweep
|
||||
* (and component-scoped CSS) reference these custom properties instead of
|
||||
* literal values, so "what color/size/space is this" has a single answer.
|
||||
*
|
||||
* Imported FIRST in main.jsx so :root is defined before any other sheet.
|
||||
* Custom properties are not cascade-order-sensitive at use time, but
|
||||
* importing first keeps the dependency obvious.
|
||||
*
|
||||
* Conventions for anyone sweeping values to these tokens:
|
||||
* - Map each literal to the NEAREST semantic token, then fall back to a
|
||||
* primitive ramp step. Consolidating near-duplicate grays is the point.
|
||||
* - Never invent a new literal in a component; add a token here instead.
|
||||
* - Spacing/radii/type use the scales below — no off-scale px values.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ===== Color primitives — neutral ramp ===== */
|
||||
--c-white: #ffffff;
|
||||
--c-gray-50: #fafafa;
|
||||
--c-gray-100: #f3f4f6;
|
||||
--c-gray-150: #f0f0ee; /* the app's warm canvas tint */
|
||||
--c-gray-200: #e5e7eb;
|
||||
--c-gray-300: #d1d5db;
|
||||
--c-gray-400: #9ca3af;
|
||||
--c-gray-500: #6b7280;
|
||||
--c-gray-600: #4b5563;
|
||||
--c-gray-700: #374151;
|
||||
--c-gray-800: #1f2937;
|
||||
--c-gray-900: #111111;
|
||||
--c-ink: #1a1a1a; /* near-black used for the header + body text */
|
||||
|
||||
/* ===== Color primitives — accent (indigo/violet) ===== */
|
||||
--c-accent: #5b5bd6;
|
||||
--c-accent-strong: #4338ca;
|
||||
--c-violet: #7c3aed;
|
||||
|
||||
/* ===== Color primitives — status ===== */
|
||||
--c-success-fg: #166534;
|
||||
--c-success-bg: #dcfce7;
|
||||
--c-danger-fg: #991b1b;
|
||||
--c-danger-fg-strong: #b91c1c;
|
||||
--c-danger-bg: #fef2f2;
|
||||
--c-danger-border: #fecaca;
|
||||
--c-warning-fg: #92400e;
|
||||
--c-warning-accent: #b45309;
|
||||
--c-warning-bg: #fef3c7;
|
||||
--c-warning-bg-soft:#fffbeb;
|
||||
|
||||
/* ===== Semantic colors ===== */
|
||||
--color-bg: var(--c-gray-150);
|
||||
--color-surface: var(--c-white);
|
||||
--color-surface-sunken: var(--c-gray-50);
|
||||
--color-surface-muted: var(--c-gray-100);
|
||||
--color-header-bg: var(--c-ink);
|
||||
|
||||
--color-text: var(--c-ink);
|
||||
--color-text-strong: var(--c-gray-900);
|
||||
--color-text-muted: var(--c-gray-500);
|
||||
--color-text-subtle: var(--c-gray-400);
|
||||
--color-text-inverse: var(--c-white);
|
||||
|
||||
--color-border: var(--c-gray-200);
|
||||
--color-border-strong: var(--c-gray-300);
|
||||
|
||||
--color-link: var(--c-accent);
|
||||
--color-accent: var(--c-accent);
|
||||
--color-accent-strong: var(--c-accent-strong);
|
||||
--color-accent-contrast: var(--c-white);
|
||||
|
||||
--color-success-fg: var(--c-success-fg);
|
||||
--color-success-bg: var(--c-success-bg);
|
||||
--color-danger-fg: var(--c-danger-fg);
|
||||
--color-danger-bg: var(--c-danger-bg);
|
||||
--color-warning-fg: var(--c-warning-fg);
|
||||
--color-warning-bg: var(--c-warning-bg);
|
||||
|
||||
/* On the dark header, translucent white is the established pattern. */
|
||||
--color-on-dark-soft: rgba(255, 255, 255, 0.15);
|
||||
--color-on-dark-hover: rgba(255, 255, 255, 0.25);
|
||||
--color-on-dark-muted: #dddddd;
|
||||
|
||||
--color-focus-ring: rgba(91, 91, 214, 0.45);
|
||||
|
||||
/* ===== Type ===== */
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||
monospace;
|
||||
|
||||
--text-2xs: 10px;
|
||||
--text-xs: 11px;
|
||||
--text-sm: 12px;
|
||||
--text-base: 13px; /* the app's dominant body size */
|
||||
--text-md: 14px;
|
||||
--text-lg: 16px;
|
||||
--text-xl: 18px;
|
||||
--text-2xl: 22px;
|
||||
--text-3xl: 28px;
|
||||
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.65;
|
||||
|
||||
--weight-normal: 400;
|
||||
--weight-medium: 500;
|
||||
--weight-semibold: 600;
|
||||
--weight-bold: 700;
|
||||
|
||||
/* ===== Spacing scale (4-based, with the 2/6/10 half-steps the app
|
||||
* already leans on heavily) ===== */
|
||||
--space-0: 0;
|
||||
--space-1: 2px;
|
||||
--space-2: 4px;
|
||||
--space-3: 6px;
|
||||
--space-4: 8px;
|
||||
--space-5: 10px;
|
||||
--space-6: 12px;
|
||||
--space-7: 16px;
|
||||
--space-8: 20px;
|
||||
--space-9: 24px;
|
||||
--space-10: 32px;
|
||||
--space-11: 48px;
|
||||
--space-12: 64px;
|
||||
|
||||
/* ===== Radius ===== */
|
||||
--radius-xs: 2px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
/* ===== Elevation ===== */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
|
||||
/* ===== Motion ===== */
|
||||
--motion-fast: 120ms;
|
||||
--motion-base: 150ms;
|
||||
--motion-slow: 200ms;
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ===== Layout ===== */
|
||||
--header-height: 48px;
|
||||
}
|
||||
|
||||
/* Honor reduced-motion globally — any transition/animation that reads
|
||||
* these duration tokens collapses to instant. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--motion-fast: 0ms;
|
||||
--motion-base: 0ms;
|
||||
--motion-slow: 0ms;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user