Slice 1: scaffolding + propose-to-super-draft vertical

Brings the §1 bot wrapper, the §4 cache (webhook + reconciler), the
§5 schema (six numbered migrations), Gitea OAuth + §6 user
provisioning, the §7 catalog left pane, and the propose-to-merge
vertical: propose modal opens an idea PR against the meta repo, an
owner merges from the pending-idea view, the cache picks it up via
webhook or reconciler sweep, and the catalog renders the new
super-draft.

Per §1 the bot is the only Git writer; every commit, branch
creation, and PR merge carries the §6.5 On-behalf-of: trailer and
an `actions` audit row. Per §4 the cache is never written from a
user action — it's webhook+reconciler only.

Covered by `backend/tests/test_propose_vertical.py` against an
in-process Gitea simulator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 04:31:11 -07:00
commit 779ba6db59
42 changed files with 10385 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
# Node
node_modules/
dist/
.vite/
# Local state
.env
.env.local
*.db
*.db-journal
*.db-wal
*.db-shm
data/
# OS
.DS_Store
.idea/
.vscode/
+62
View File
@@ -0,0 +1,62 @@
You're starting the implementation phase of the Wiggleverse RFC framework — a standardization process for the natural-language vocabulary that digital representations of humans, and the systems that interact with them, need to share. Thirteen settlement sessions have produced SPEC.md, which captures every structural decision v1 needs. Per §19.1, the spec is implementation-ready, and this session is the first build session.
The application is a single-process FastAPI + SQLite + React + Vite + Tiptap (ProseMirror) app running against a Gitea instance. The app's bot service account performs all Git operations on behalf of users authenticated via Gitea OAuth; per §1, the bot is the only Git writer. Single-operator deployment shape (one Python process colocated with one SQLite file). The deliverable across all build sessions is the materialization of §§115 end-to-end; this first session establishes the spine and a single working vertical slice.
Before writing any code:
1. Read SPEC.md end to end. It is the binding contract. Every load-bearing decision is in there, and the implementation does not get to second-guess them. Pay particular attention to:
* §1 (the bot-as-only-actor framing and what it implies for the wrapper layer)
* §4 (the cache architecture — webhook + reconciler, both writing to the cache; user actions never write the cache directly)
* §5 (the canonical app tables — implement these with SQLite-appropriate column types and the appropriate FK / cascade rules)
* §6 (the four-role permission model and per-RFC delegated authority; this is enforced in the app, not in Gitea)
* §18 (the carryovers — these are confirmed unchanged from the prototype and should be re-used, not rewritten)
* §19.3 (the working agreement for the build phase)
2. Read PHILOSOPHY.md. The spec's decisions answer to it. Where the spec's letter is silent, the philosophy's spirit decides — the transcript is the evidence, public-async work is the default, the system is a tool for thought rather than a feed.
3. Glance at /Users/benstull/projects/wiggleverse/rfc-app-prototype/ — *reference only*. The §18 carryovers (FastAPI + SSE, Tiptap + ProseMirror, the `<change>` / `<original>` / `<proposed>` / `<reason>` protocol, multi-provider LLM support, Gitea OAuth) have working code in the prototype; lift these directly where possible. Everything else in the prototype is superseded by the spec. The prototype's CSS, component decomposition, prompt-bar machinery, and DiffView rendering are likely re-usable; the prototype's data model, permission shape, and notification absence are not.
Where to build: /Users/benstull/git/rfc-app/ is the project root. SPEC.md and PHILOSOPHY.md live at the root as content (versioned alongside the code). The implementation lives in subdirectories — backend/ for the FastAPI app and its SQLite migrations, frontend/ for the React + Vite + Tiptap app, docs/ for any operational documentation.
Sequencing — this session establishes the spine and one working vertical slice, not every flow at once. The right slice order across all build sessions is:
* **Slice 1 (this session):** repository scaffolding (backend/, frontend/, dev-environment script, a README explaining local bring-up); the full §5 schema as a migrations directory with appropriate indexes and FK rules; Gitea OAuth per §18 + user provisioning into `users`; the bot client wrapper that every Git operation flows through with the §6.5 `On-behalf-of:` trailer applied; the §4.1 webhook receiver and reconciler both writing to the metadata cache; a minimal left pane per §7 (catalog list, sort + filter chips + search, the "Pending ideas" disclosure); and *one end-to-end vertical*: propose a new RFC per §9.1 → idea PR opens against the meta repo → owner merges → super-draft appears in the catalog → super-draft view opens and renders the body. That slice exercises the bot wrapper, the cache, the permission model, the catalog, and the super-draft view in one path, which is the right shape for a first build session.
* **Slice 2:** the RFC view for active RFCs per §8 — the editor, branch creation, per-branch chat, AI participation, the change-card panel, accept/decline/edit, manual-edit flushes, sub-threads, flags, DiffView.
* **Slice 3:** the PR flow per §10 — opening, review surface, the seen-cursor mechanism, review threads, merge, post-merge, conflict resolution.
* **Slice 4:** super-draft body editing per §9.5 + §9.6 — meta-repo edit branches as the unit of work.
* **Slice 5:** graduation per §13 — the dialog, the transactional sequence, rollback, the pre-graduation history affordance.
* **Slice 6:** notifications per §15 — last, because every other surface produces signals that the notification surface receives, and notification correctness depends on those producers being in place first.
* **Slice 7:** the §14 chrome — landing page, /philosophy route, About link.
* **Slice 8:** hardening — end-to-end tests, dev/prod deployment shape, the 30/90 branch hygiene timers from §12.
Working agreement (modified from §19.3 for the build phase):
1. Implement the slice. The spec is the source of truth. The build does not extend it.
2. Where running code reveals the spec was wrong or underspecified at an *implementation-detail* level (the explicit "exact value is an implementation detail" callouts in §8.6, §8.11, §8.13, §8.15, etc.), make the decision and document it inline in code with a comment referencing the §. Do not amend the spec.
3. Where running code reveals the spec was wrong or underspecified at a *structural* level — a section that contradicts another, a decision that prevents a downstream flow from working, an assertion that breaks under real use — correct the spec in the appropriate numbered section with a brief note explaining what running code revealed. Match the spec's voice: declarative, structural, em-dashes for asides, prose rather than bullets where bullets don't earn their keep. Spec corrections during the build should be rare and surgical, not expansive.
4. Where running code surfaces a new design question that isn't resolvable as an implementation detail, add it to §19.2 as a new candidate topic in the existing shape. Don't try to settle it inline; that's what topic sessions are for.
5. The build does not add features that aren't in the spec. If a "wouldn't it be nice" comes up, add it to §19.2 and move on.
Practical guardrails:
* Prefer the simplest thing that materializes the spec. The spec is intentionally specific about what to build and intentionally silent about how to build it. Use the silence.
* Accept §16 (deferred items) as deferred. Don't ship body full-text search, the per-RFC model picker, the funder role, persistent accepted-change markup, slug renames, or any other §16 / §19.2 candidate.
* The §18 carryovers should be re-used from the prototype where they can be, not rewritten. SSE streaming, Tiptap configuration, the `<change>` parser, the prompt-bar selection-quote machinery — these work in the prototype and the spec confirms them unchanged.
* The bot service account is the only Git writer. Every Git operation in the codebase goes through one wrapper. If you find yourself wanting to bypass it, the spec is right and you are wrong — the wrapper is the chokepoint that makes the on-behalf-of trailer and the app-level authorization both consistent.
* The cache is never written from a user action. User actions trigger Git operations via the bot wrapper; the resulting Gitea webhook (or the reconciler) is what writes the cache. This invariant is what makes §4's "Git is truth" claim hold operationally.
* The app-wide write-mute (§6.2) and the per-RFC / per-user notification mutes (§15.8) are structurally distinct and live in separate columns. They share a word and nothing else. Don't conflate them in code any more than the spec conflates them in prose.
What to deliver from this session:
* The slice you actually got through, working end-to-end against a local Gitea instance.
* A README at /Users/benstull/git/rfc-app/README.md explaining how to bring the app up locally — Gitea setup, OAuth app registration, env vars, run commands for backend and frontend, how to seed the meta repo.
* docs/DEV.md (or similar) with the slicing plan you're working against, what's done, what's next, any environment-specific gotchas.
* Any spec corrections per rule 3 above, in their proper numbered sections.
* §19.2 updated with any new candidate topics that surfaced during the build.
What to update for the session after this one:
* §19.1 should be rewritten to name the next slice the build needs (e.g., "Slice 2: the RFC view and per-branch chat — §8 in full"), with a brief paragraph summarizing what was completed in this session and what state the codebase is in for the next session to pick up.
* The handoff is the prompt for the next build session. Preserve enough context that a fresh agent can read SPEC.md, README.md, docs/DEV.md, and §19.1 and pick up cleanly without needing to be re-briefed.
Voice note: READMEs, dev docs, and any spec corrections should match the spec's voice. Declarative, structural, em-dashes, prose where prose works. The framework is making a claim about how words behave; the documentation should sound like it believes the claim.
+228
View File
@@ -0,0 +1,228 @@
# Words first
*A standards process for shared meaning between humans and machines.*
Large language models work brilliantly with programming languages because
every word in Python or C has a definitive meaning enforced by tooling.
They struggle with natural language because no such dictionary exists for
words like *consent*, *trait*, or *agency* — words that do enormous work
in any system that interacts with humans. Trained on the open corpus,
LLMs inherit and amplify the ambiguity, producing text that looks crisp
and is, on inspection, fog.
The Wiggleverse RFC framework is a standardization process for natural-
language vocabulary, modeled on the way ISO C, POSIX, and the IETF RFCs
produced the standards that underwrite modern computing. Each RFC defines
one word: its meaning, its relationships to other defined words, and the
protocol by which humans and machines interact with it. The Open Human
Model is the first specification this process will produce. Together,
the graduated RFCs form a stack — a shared vocabulary that digital
representations of humans, and the systems that interact with them, can
be built on without re-litigating what every word means.
This is public work. Humans and machines are both invited and both
required. The shared understanding the framework is reaching for — how
things work, in the physical and digital realms, between humans and
other humans, between machines and other machines, and between humans
and machines — cannot be produced by any one of those participants alone,
and we do not intend to try.
---
## The asymmetry
Large language models have been transformative for code in a way they have
not been for prose. The gap is wider than enthusiasm; it is structural.
When a model writes Python, every word in the language has a definitive
meaning. `for`, `range`, `await`, `class` — these are not approximations.
A compiler will refuse to run text that uses them incorrectly. The model
was trained on millions of examples where these words behave the same way
every time. Beneath the model is a substrate of agreement: programmer,
machine, and language designer all converge on what the word does. Output
flows downhill from that agreement, and the productivity gains are real.
When a model writes a strategy memo, none of this is true. *Inclusive*,
*scalable*, *user-centric*, *ethical* — these words do enormous work, and
the work they do is to mean different things to different readers. There
is no compiler. There is no refusal. The model averages across every
meaning the training data ever held and produces text that looks crisp
and is, on inspection, fog. Two readers extract two memos. Three months
later, when someone asks why the decisions diverged from the intent, the
document cannot answer, because the document never held a single intent
to begin with.
This is not a model failure. It is a *prerequisite* failure. We are
asking LLMs to compute in a language we never finished defining.
## Code's dictionary; natural language's absence of one
Programming languages have spent fifty years building the dictionary.
Every keyword has a specification. Every type has an interface. Every
API surface has a contract. The dictionary is enforced by tooling: type
checkers, linters, runtime errors. Drift between what you meant and what
you wrote is caught at the interface, loudly, by a machine whose job is
to refuse.
Natural language has no such dictionary, except locally, briefly, and by
accident — within a small team that has worked together long enough to
triangulate one. Outside that team, the dictionary evaporates. LLMs
trained on the open corpus inherit that evaporation. They are, at scale,
drift amplifiers. The more fluent the model, the more confidently the
drift compounds.
This is the entropy the framework is trying to address.
## What this actually is
What the framework produces is a stack of specifications.
The closer analogues are not programming languages — Python, JavaScript —
but the standards underneath them: ISO C, POSIX, the IETF RFCs that
define HTTP and TCP, the W3C recommendations that define HTML. Each is a
document, painstakingly argued and then formally adopted, that other
systems reference rather than reinvent. POSIX did not write the
operating systems that use it; it specified the surface those systems
could agree on. HTTP does not implement any particular web server; it
specifies the surface every web server has to honor.
The Wiggleverse RFC framework is the standardization process. The RFCs
it produces are the specifications. The Open Human Model is the first
of them. Together they form a stack — a shared vocabulary that every
digital representation of a human, and every system that interacts with
one, can be built on without re-litigating what *consent*, *trait*, or
*agency* means each time.
The analogy stretches in one important way, and the stretch is worth
naming. POSIX worked because it codified convention that already
existed in fragmented form across Unix vendors; the committee's job was
to harmonize working implementations. This framework has the harder
job. There are no working implementations of *consent* or *agency* to
harmonize — only twenty arguments per term and no convergence. We are
specifying the vocabulary in the first place. The reason this is now
tractable, and was not in any previous attempt, is the LLM as
participant: it provides the surface area and surfaces the cases humans
alone could never enumerate, while the humans provide the refusal and
the judgment. That dyad is what every previous attempt at a natural-
language standard at this resolution has lacked.
## The proposal
Before we build *with* LLMs in any domain that touches natural-language
concepts — identity, consent, value, harm, fairness, intent — we have to
build the dictionary. And the dictionary has to be built collaboratively,
with humans and machines together, because neither can do it alone.
Humans cannot. The history of dictionary-building by committee is a
history of dictionaries no one consults. We do not have the labor or the
patience to enumerate the surface area at the resolution machines need,
and we do not have the introspective access to our own usage to surface
the cases that actually matter.
Machines cannot either. A model trained on existing text has already
internalized the ambiguity it is meant to resolve. Asking it to dictate
definitions is asking the disease to write the cure.
But a human and a model in a careful argument can pin down what neither
could pin down alone. The model proposes; the human refuses or refines;
the argument is captured; the definition tightens. The transcript of the
argument becomes a thing both can index against later. This is what the
RFC framework is for.
## What it means to define a word
A definition, in the sense this framework cares about, has three parts.
The first is the **meaning** — a tight, unambiguous statement of what
the word picks out in the world. Not a dictionary gloss; a specification,
written so that a careful reader and a careful model would agree about
whether any given thing is or is not in the word's extension.
The second is the **relationships** — how the word connects to other
defined words. Definitions in isolation drift. A definition embedded in
an ontology — *is-a*, *part-of*, *implies*, *excludes* — is anchored.
The graph of definitions becomes the substrate that future definitions
stand on. This is the same move programming languages make when they
let one type be defined in terms of others.
The third is the **protocol** — how humans interact with the word, and
how machines interact with the word. A definition that humans use and a
definition that machines use are not separately valid; they have to be
the same definition, expressed in two registers. The RFC document is
the human register. The structured metadata around it is the machine
register. Both pass through the same review.
A word is "defined" when all three parts exist, have been argued over,
and have graduated to canonical status. Until then, it is a draft, and
anything built on top of it inherits the draftness.
## How the framework operationalizes this
The structural decisions in `SPEC.md` are not arbitrary. Each one is in
service of the philosophy above.
- The **meta-repository** holds the catalog of definitions. Every
defined word, and every word currently being defined, is one entry.
The catalog is itself a piece of public infrastructure — version-
controlled, branchable, auditable.
- **Super-drafts** are the moment a word enters the conversation.
Proposing a definition costs nothing in identifier space, because
most proposals will not survive the argument, and that is fine.
- **Graduation** is the moment a definition becomes load-bearing. It
gets a stable identifier (`RFC-NNNN`), its own repository, and a
permanent home. From that point forward, other RFCs can build on
it. Graduation is rare and ceremonial because what comes after it
is dependency.
- **AI participation in chat** is not a feature; it is the mechanism.
The model is one of the participants in the argument that produces
the definition. Its proposals are subject to refusal and refinement,
the same as any contributor's. The transcript of the argument is
preserved because the argument is the evidence the definition was
earned.
- **Branches, PRs, and tracked changes** exist because definitions
evolve, and the framework needs to make that evolution legible — to
humans reading later, and to machines computing against the current
state.
The first RFC the framework will produce is the Open Human Model: a
shared definition of what we mean by *human*, and the constellation of
words around it — *trait*, *preference*, *consent*, *harm*, *agency*.
This is not a small project. It is, in the most literal sense, the
dictionary that everything else built here will stand on.
## An invitation
This is public work, and it is meant to be.
The vocabulary the framework is producing is for anyone who will need
to interact across the humanmachine boundary, or across the machine
machine boundary where the machines are acting on behalf of humans.
That is, in the limit, everyone. The framework is therefore designed
as public infrastructure: every entry sits in a public meta-repository,
every argument lives in a public chat thread, every change to a
graduated definition is a public PR with a reviewable trail.
The invitation is to participate. Propose a word. Argue against a
draft. Refine a definition. Flag a relationship the current ontology
is missing. Humans and machines are both invited, and both are
required. The shared understanding the framework is reaching for —
how things work, in the physical and digital realms, between humans
and other humans, between machines and other machines, and between
humans and machines — cannot be produced by any one of those
participants alone, and we do not intend to try.
## A note on humility
This work will not finish. Languages do not finish; they accrete, drift,
get pruned. The RFC framework is not a one-time act of definition; it is
a sustained practice of definition, of arguing about words in public, of
being willing to refine canonical entries when use reveals what spec
missed.
The claim is not that we can finalize meaning. The claim is that we
cannot build responsibly with LLMs in domains we have not even tried to
define. The work begins with one word, argued carefully, with a model
and a human together. Then another. Then the relationships between them.
Then the systems those definitions enable.
Build the dictionary first.
+199
View File
@@ -0,0 +1,199 @@
# RFC App
A single-process FastAPI + SQLite + React + Vite + Tiptap app that
materializes the Wiggleverse RFC framework specified in
[`SPEC.md`](./SPEC.md). The framework's mission lives in
[`PHILOSOPHY.md`](./PHILOSOPHY.md); the spec is the binding contract;
this README is how to bring the app up against a local Gitea instance
and exercise the slice the build session has shipped so far.
The implementation is in progress. See [`docs/DEV.md`](./docs/DEV.md)
for the slicing plan and the current state.
## What the app expects to talk to
- **A Gitea instance** at `GITEA_URL`. The instance hosts the meta
repository and (eventually) one repository per graduated RFC.
- **A bot service account** in that Gitea, with a personal access
token in `GITEA_BOT_TOKEN`. Per §1 the bot is the only writer in
the system — every commit, branch, and PR the app produces flows
through one wrapper that applies the §6.5 `On-behalf-of:` trailer
and records a row in the `actions` audit log.
- **An OAuth2 application** registered against that Gitea, with the
callback URL set to `{APP_URL}/auth/callback`. Real human users
authenticate via Gitea OAuth (the §18 carryover); the app reads
their Gitea profile, provisions a row in `users`, and layers §6's
app-owned permission model on top.
## Local bring-up
The shortest path from a clean checkout to a working app is:
### 1. Stand up a local Gitea
Anything that exposes the Gitea REST API works. The fastest path is
Docker:
```sh
docker run -d --name gitea \
-p 3000:3000 -p 222:22 \
-v gitea-data:/data \
gitea/gitea:1.21
```
Open `http://localhost:3000`, walk through the install wizard
(SQLite, default port), and create your owner-zero account.
### 2. Create the bot service account
In Gitea, sign in as your owner account and **Site Administration →
User Accounts → Create User Account**. Give it a name like `rfc-bot`
and an email. Then sign in as the bot, open **Settings → Applications
→ Generate New Token**, and grant it the `write:repository`,
`write:user`, and `write:admin` scopes (admin is needed because the
bot will create per-RFC repos on graduation; in v1 you can scope down
to `repo` and `org` if you want to defer admin until Slice 5).
Copy the token; you will paste it into `.env`.
### 3. Create the org that will host the meta repo
The seed script creates the meta repo *inside* an org. Create the org
(e.g. `wiggleverse`) in Gitea and add `rfc-bot` to it as an
**Owner**.
### 4. Register the OAuth2 application
In Gitea: **Site Administration → Integrations → OAuth2 Applications
→ Create**. Name it whatever you like, set the redirect URI to
`http://localhost:8000/auth/callback`. Copy the client id and client
secret — they go into `.env`.
### 5. Configure the app
```sh
cd backend
cp .env.example .env
$EDITOR .env # fill in every variable
```
Required values:
| Variable | What it is |
| -------------------------- | --------------------------------------------------------- |
| `GITEA_URL` | Base URL of the Gitea instance (no trailing slash). |
| `GITEA_BOT_USER` | The bot account's login. |
| `GITEA_BOT_TOKEN` | The bot account's access token. |
| `GITEA_ORG` | The org that owns the meta repo. |
| `META_REPO` | The meta repo's name (default `meta`). |
| `OAUTH_CLIENT_ID` | From the OAuth app you registered. |
| `OAUTH_CLIENT_SECRET` | Likewise. |
| `APP_URL` | The URL the app is reachable at locally. |
| `SECRET_KEY` | A long random string for cookie signing. |
| `OWNER_GITEA_LOGIN` | Your owner-zero Gitea login — gets the owner role on first sign-in. |
| `GITEA_WEBHOOK_SECRET` | A shared secret for the §4.1 webhook signature. |
The LLM-provider settings (`ENABLED_MODELS`, `ANTHROPIC_API_KEY`,
etc.) are not exercised by Slice 1 but are wired through `config.py`
so the next slice can pick them up.
### 6. Install dependencies
Backend:
```sh
cd backend
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
```
Frontend:
```sh
cd ../frontend
npm install
```
### 7. Seed the meta repo
The seed script creates `wiggleverse/meta` if it does not exist,
populates it with `PHILOSOPHY.md`, `README.md`, `CONTRIBUTING.md`,
the regenerate-index workflow placeholder, and an empty `rfcs/`
directory, and registers the Gitea webhook the app needs:
```sh
cd backend
.venv/bin/python ../scripts/seed_meta_repo.py
```
Re-running is safe — every step is upsert-shaped.
### 8. Run the app
In two terminals:
```sh
# Terminal 1 — backend
cd backend
.venv/bin/uvicorn app.main:app --reload --port 8000
```
```sh
# Terminal 2 — frontend
cd frontend
npm run dev
```
Open `http://localhost:5173`. Sign in with your owner-zero Gitea
account. The catalog should appear empty; the **+ Propose New RFC**
button at the bottom opens the propose modal.
## What slice 1 lets you do
End-to-end: propose a new RFC → an idea PR opens against the meta
repo → an owner merges from the pending-idea view → the super-draft
appears in the catalog → opening it renders the body.
This exercises the §4 cache (webhook + reconciler), the §6 permission
model (the owner-only merge button, the contributor-only propose
modal), the §1 bot wrapper (every Git write goes through it, every
commit and PR carries the `On-behalf-of:` trailer), and the §9
propose-merge-render path.
Out of scope for slice 1: the active-RFC view (§8), per-branch chat,
AI participation, the change-card panel, PRs against per-RFC repos,
graduation, notifications, the landing page's full polish. Those
slices are listed in [`docs/DEV.md`](./docs/DEV.md).
## Verifying it worked
After bring-up:
- `http://localhost:8000/docs` lists the API routes the build session
has wired so far.
- `sqlite3 backend/data/rfc-app.db .schema` shows the §5 schema.
- `gitea ls /api/v1/repos/wiggleverse/meta/contents/rfcs` after a
proposal merges should show one new `<slug>.md`.
## Troubleshooting
- **The catalog stays empty after a merge.** Check that the webhook
is reaching the app: the reconciler runs every five minutes and
will catch up, but a missing or misconfigured webhook is the most
common reason for sub-second freshness to fail. The seed script
registers the webhook for you; if you bring up Gitea on a different
host (e.g. a Codespace, a tunnel), re-run the seed against the new
`APP_URL`.
- **OAuth callback errors.** The redirect URI in Gitea has to match
`APP_URL` exactly, including the protocol and port.
- **The bot can't merge.** The bot needs Maintainer or Owner on the
meta repo (membership in `GITEA_ORG` as Owner gives it both).
## Where to read further
- [`SPEC.md`](./SPEC.md) — the binding contract. Every load-bearing
decision is there.
- [`PHILOSOPHY.md`](./PHILOSOPHY.md) — why this framework exists.
The spec's decisions answer to it.
- [`docs/DEV.md`](./docs/DEV.md) — the build's slicing plan, the
current state, and the next slice's brief.
+2605
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
# --- Gitea ---
# Base URL of the Gitea instance the app speaks to.
GITEA_URL=http://localhost:3000
# The bot service account that performs every Git operation per §1.
# Provision a real Gitea user, generate a personal access token with
# repo and admin (or at minimum: repo, write:repository) scopes, and
# put the token here. The bot is the only Git writer.
GITEA_BOT_USER=rfc-bot
GITEA_BOT_TOKEN=
# The Gitea org or user that owns the meta repo and every RFC repo
# the bot will create on graduation.
GITEA_ORG=wiggleverse
META_REPO=meta
# --- OAuth (Gitea) ---
# In Gitea: Site Administration → Applications → Add OAuth2 Application.
# Redirect URI: {APP_URL}/auth/callback
OAUTH_CLIENT_ID=
OAUTH_CLIENT_SECRET=
# --- App ---
APP_URL=http://localhost:8000
SECRET_KEY=change-me-to-a-long-random-string
DATABASE_PATH=data/rfc-app.db
# Per §1: owner zero. The Gitea login that gets the owner role on
# first sign-in.
OWNER_GITEA_LOGIN=ben
# Webhook signature secret. Gitea sends X-Gitea-Signature as the
# HMAC-SHA256 of the body using this secret. Per §4.1 the webhook is
# one of two cache writers; signing keeps spurious writes out.
GITEA_WEBHOOK_SECRET=change-me-to-a-shared-secret
# --- LLM providers (carryover §18) ---
# 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=
GOOGLE_API_KEY=
OPENAI_API_KEY=
View File
+396
View File
@@ -0,0 +1,396 @@
"""API surface for Slice 1.
Carries the §17 endpoints exercised by the propose-to-super-draft
vertical, plus the catalog read endpoints (§7) and the super-draft view
read endpoints (§9.4). The rest of §17 lands in the relevant later
slices; the dispatch shape here leaves room for them.
Routing follows the §17 layout literally `/api/rfcs`,
`/api/proposals/<n>`, etc. so the next slice can extend the same
modules rather than untangling a layout that drifted.
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import auth, db, entry as entry_mod, cache
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
class ProposeBody(BaseModel):
title: str = Field(min_length=1, max_length=200)
slug: str = Field(min_length=1, max_length=80)
pitch: str = Field(min_length=1)
tags: list[str] = Field(default_factory=list)
class DeclineBody(BaseModel):
comment: str = Field(min_length=1, max_length=4000)
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
router = APIRouter()
# ---------------------------------------------------------------
# Auth surface — extends the prototype's pattern but reads role
# from our users table per §6.
# ---------------------------------------------------------------
@router.get("/api/auth/me")
async def auth_me(request: Request) -> dict[str, Any]:
user = auth.current_user(request)
if user is None:
return {"authenticated": False, "user": None}
return {
"authenticated": True,
"user": {
"id": user.user_id,
"gitea_login": user.gitea_login,
"display_name": user.display_name,
"email": user.email,
"avatar_url": user.avatar_url,
"role": user.role,
},
}
# ---------------------------------------------------------------
# §7: the catalog
# ---------------------------------------------------------------
@router.get("/api/rfcs")
async def list_rfcs(request: Request) -> dict[str, Any]:
"""§7's left pane data.
The chip-filter / sort / search combinatorics live on the
client the server returns the full set and lets the chips
narrow it. The set is small (hundreds, not thousands) for the
foreseeable future, so paginating here would buy nothing.
"""
viewer = auth.current_user(request)
viewer_id = viewer.user_id if viewer else None
rows = db.conn().execute(
"""
SELECT slug, title, state, rfc_id, repo,
owners_json, arbiters_json, tags_json,
last_main_commit_at, last_entry_commit_at, updated_at
FROM cached_rfcs
WHERE state IN ('super-draft', 'active')
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
"""
).fetchall()
starred = set()
if viewer_id is not None:
starred = {
r["rfc_slug"]
for r in db.conn().execute(
"SELECT rfc_slug FROM stars WHERE user_id = ?", (viewer_id,)
)
}
items = []
for r in rows:
items.append(
{
"slug": r["slug"],
"title": r["title"],
"state": r["state"],
"id": r["rfc_id"],
"repo": r["repo"],
"owners": json.loads(r["owners_json"] or "[]"),
"arbiters": json.loads(r["arbiters_json"] or "[]"),
"tags": json.loads(r["tags_json"] or "[]"),
"last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"],
"starred_by_me": r["slug"] in starred,
"has_open_prs": False, # wired in Slice 2 when per-RFC repos exist
}
)
return {"items": items}
@router.get("/api/rfcs/{slug}")
async def get_rfc(slug: str) -> dict[str, Any]:
row = db.conn().execute(
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if row is None:
raise HTTPException(404, "Not found")
return _serialize_rfc(row)
# ---------------------------------------------------------------
# §7.3 / §9.3: pending ideas
# ---------------------------------------------------------------
@router.get("/api/proposals")
async def list_proposals() -> dict[str, Any]:
rows = db.conn().execute(
"""
SELECT rfc_slug, pr_number, title, description, opened_by, opened_at, state
FROM cached_prs
WHERE pr_kind = 'idea' AND state = 'open'
ORDER BY opened_at DESC
"""
).fetchall()
return {
"items": [
{
"slug": r["rfc_slug"],
"pr_number": r["pr_number"],
"title": r["title"],
"description": r["description"],
"opened_by": r["opened_by"],
"opened_at": r["opened_at"],
}
for r in rows
]
}
@router.get("/api/proposals/{pr_number}")
async def get_proposal(pr_number: int, request: Request) -> dict[str, Any]:
"""§9.3 pending-idea view data.
Reads the proposed file from the proposer's branch on the meta
repo, so the viewer sees the entry as it will land. The chat
thread is not yet implemented; thread_id surfaces as null until
Slice 2's chat wiring lands.
"""
row = db.conn().execute(
"""
SELECT * FROM cached_prs
WHERE pr_kind = 'idea' AND pr_number = ?
""",
(pr_number,),
).fetchone()
if row is None:
raise HTTPException(404, "Not a proposal PR")
# Read the proposed entry file from the head branch.
slug = row["rfc_slug"]
head = row["head_branch"]
result = await gitea.read_file(config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref=head)
entry_payload: dict[str, Any] | None = None
if result:
text, _sha = result
try:
entry = entry_mod.parse(text)
entry_payload = _entry_payload(entry)
except Exception:
entry_payload = None
viewer = auth.current_user(request)
affordances = _proposal_affordances(viewer, row)
return {
"slug": slug,
"pr_number": pr_number,
"title": row["title"],
"description": row["description"],
"state": row["state"],
"opened_by": row["opened_by"],
"opened_at": row["opened_at"],
"entry": entry_payload,
"affordances": affordances,
}
# ---------------------------------------------------------------
# §9.1: propose a new RFC
# ---------------------------------------------------------------
@router.post("/api/rfcs/propose")
async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
slug = payload.slug.strip().lower()
if not entry_mod.is_valid_slug(slug):
raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes")
# §9.1 uniqueness — against rfcs/ on main *and* against open idea PRs.
# We re-check atomically here even though the client also checks
# on every keystroke, since a concurrent submission could land
# between dialog-open and submit.
clash = db.conn().execute(
"SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if clash:
raise HTTPException(409, f"Slug `{slug}` is already taken")
idea_clash = db.conn().execute(
"SELECT 1 FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' AND rfc_slug = ?",
(slug,),
).fetchone()
if idea_clash:
raise HTTPException(409, f"Slug `{slug}` is already reserved by an open proposal")
entry = entry_mod.Entry(
slug=slug,
title=payload.title.strip(),
state="super-draft",
id=None,
repo=None,
proposed_by=user.email or user.gitea_login,
proposed_at=entry_mod.today(),
graduated_at=None,
graduated_by=None,
owners=[],
arbiters=[],
tags=[t.strip() for t in payload.tags if t.strip()],
body=payload.pitch.strip() + "\n",
)
contents = entry_mod.serialize(entry)
pr_title = f"Propose: {entry.title}"
# Slice 1's AI-drafted PR description is deferred — the v1 spec
# calls for it (§9.2) but the wiring belongs with the rest of
# the AI-on-the-propose-modal work; for now we send the pitch.
pr_description = (
f"**Topic:** {entry.title}\n\n"
f"{payload.pitch.strip()}"
)
try:
pr = await bot.open_idea_pr(
user.as_actor(),
org=config.gitea_org,
meta_repo=config.meta_repo,
slug=slug,
file_contents=contents,
pr_title=pr_title,
pr_description=pr_description,
)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
# Refresh the meta-PRs cache so the proposer sees the new entry
# on the pending-ideas disclosure immediately, without waiting
# for the webhook to arrive. (The webhook will arrive too; the
# cache write is idempotent.)
await cache.refresh_meta_pulls(config, gitea)
return {"pr_number": pr["number"], "slug": slug}
# ---------------------------------------------------------------
# §9.3: merge / decline / withdraw an idea PR
# ---------------------------------------------------------------
@router.post("/api/proposals/{pr_number}/merge")
async def merge_proposal(pr_number: int, request: Request) -> dict[str, Any]:
user = auth.require_admin(request)
row = _require_open_idea_pr(pr_number)
try:
await bot.merge_idea_pr(
user.as_actor(),
org=config.gitea_org,
meta_repo=config.meta_repo,
pr_number=pr_number,
slug=row["rfc_slug"],
)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
# Refresh both surfaces — the entry is now on main, and the PR
# is now closed.
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
return {"ok": True, "slug": row["rfc_slug"]}
@router.post("/api/proposals/{pr_number}/decline")
async def decline_proposal(pr_number: int, body: DeclineBody, request: Request) -> dict[str, Any]:
user = auth.require_admin(request)
row = _require_open_idea_pr(pr_number)
try:
await bot.decline_idea_pr(
user.as_actor(),
org=config.gitea_org,
meta_repo=config.meta_repo,
pr_number=pr_number,
slug=row["rfc_slug"],
comment=body.comment,
)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
await cache.refresh_meta_pulls(config, gitea)
return {"ok": True}
@router.post("/api/proposals/{pr_number}/withdraw")
async def withdraw_proposal(pr_number: int, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
row = _require_open_idea_pr(pr_number)
# Only the proposer can withdraw their own proposal, except that
# owner/admin can also act (they have all contributor powers per
# §6.1, and the withdraw path here doesn't expose decline-only
# affordances).
if row["opened_by"] != user.gitea_login and user.role not in ("owner", "admin"):
raise HTTPException(403, "Only the proposer can withdraw")
try:
await bot.withdraw_idea_pr(
user.as_actor(),
org=config.gitea_org,
meta_repo=config.meta_repo,
pr_number=pr_number,
slug=row["rfc_slug"],
)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
await cache.refresh_meta_pulls(config, gitea)
return {"ok": True}
# ---------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------
def _require_open_idea_pr(pr_number: int):
row = db.conn().execute(
"""
SELECT * FROM cached_prs
WHERE pr_kind = 'idea' AND pr_number = ? AND state = 'open'
""",
(pr_number,),
).fetchone()
if row is None:
raise HTTPException(404, "Not an open proposal PR")
return row
return router
def _serialize_rfc(row) -> dict[str, Any]:
return {
"slug": row["slug"],
"title": row["title"],
"state": row["state"],
"id": row["rfc_id"],
"repo": row["repo"],
"proposed_by": row["proposed_by"],
"proposed_at": row["proposed_at"],
"graduated_at": row["graduated_at"],
"graduated_by": row["graduated_by"],
"owners": json.loads(row["owners_json"] or "[]"),
"arbiters": json.loads(row["arbiters_json"] or "[]"),
"tags": json.loads(row["tags_json"] or "[]"),
"body": row["body"] or "",
}
def _entry_payload(entry: entry_mod.Entry) -> dict[str, Any]:
return {
"slug": entry.slug,
"title": entry.title,
"state": entry.state,
"id": entry.id,
"repo": entry.repo,
"proposed_by": entry.proposed_by,
"proposed_at": entry.proposed_at,
"owners": entry.owners,
"arbiters": entry.arbiters,
"tags": entry.tags,
"body": entry.body,
}
def _proposal_affordances(viewer, row) -> dict[str, bool]:
"""§9.3 header strip affordances by role."""
is_owner_admin = viewer is not None and viewer.role in ("owner", "admin")
is_proposer = viewer is not None and row["opened_by"] == viewer.gitea_login
return {
"merge": is_owner_admin,
"decline": is_owner_admin,
"withdraw": is_proposer or is_owner_admin,
}
+195
View File
@@ -0,0 +1,195 @@
"""Gitea OAuth and user provisioning.
OAuth identity is the basis for the app's user account per §18; the §6
authorization layer is built on top by reading from the users table. On
first sign-in we insert a row with role='contributor' (or 'owner' if the
gitea_login matches the configured OWNER_GITEA_LOGIN bootstrapping for
owner zero per §6.1). On subsequent sign-ins we refresh the display name
and avatar from Gitea so a rename in Gitea propagates here.
"""
from __future__ import annotations
import secrets
from dataclasses import dataclass
from typing import Any
import httpx
from fastapi import HTTPException, Request
from . import db
from .bot import Actor
from .config import Config
@dataclass
class SessionUser:
user_id: int
gitea_id: int
gitea_login: str
display_name: str
email: str
avatar_url: str
role: str
def as_actor(self) -> Actor:
return Actor(
user_id=self.user_id,
gitea_login=self.gitea_login,
display_name=self.display_name,
email=self.email,
)
def authorization_url(config: Config, state: str) -> str:
return (
f"{config.gitea_url}/login/oauth/authorize"
f"?client_id={config.oauth_client_id}"
f"&redirect_uri={config.redirect_uri}"
f"&response_type=code"
f"&state={state}"
)
async def exchange_code(config: Config, code: str) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
f"{config.gitea_url}/login/oauth/access_token",
json={
"client_id": config.oauth_client_id,
"client_secret": config.oauth_client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": config.redirect_uri,
},
headers={"Accept": "application/json"},
)
resp.raise_for_status()
return resp.json()
async def fetch_user_profile(config: Config, access_token: str) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.get(
f"{config.gitea_url}/api/v1/user",
headers={"Authorization": f"token {access_token}"},
)
resp.raise_for_status()
return resp.json()
def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
"""Insert or update the users row for this Gitea profile.
Owner zero (§6.1) is whoever's gitea_login matches OWNER_GITEA_LOGIN.
The owner role is granted on first sign-in and never revoked from a
later config change once owner, always owner until an explicit
role transition (which lives in §6.1 and isn't part of slice 1).
"""
gitea_id = profile["id"]
login = profile["login"]
display = profile.get("full_name") or login
email = profile.get("email") or ""
avatar = profile.get("avatar_url") or ""
c = db.conn()
existing = c.execute("SELECT * FROM users WHERE gitea_id = ?", (gitea_id,)).fetchone()
if existing is None:
role = "owner" if config.owner_gitea_login and login == config.owner_gitea_login else "contributor"
cur = c.execute(
"""
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role)
VALUES (?, ?, ?, ?, ?, ?)
""",
(gitea_id, login, email, display, avatar, role),
)
user_id = cur.lastrowid
else:
user_id = existing["id"]
role = existing["role"]
c.execute(
"""
UPDATE users
SET gitea_login = ?, email = ?, display_name = ?, avatar_url = ?, last_seen_at = datetime('now')
WHERE id = ?
""",
(login, email, display, avatar, user_id),
)
return SessionUser(
user_id=user_id,
gitea_id=gitea_id,
gitea_login=login,
display_name=display,
email=email,
avatar_url=avatar,
role=role,
)
# ----- Session helpers -----
SESSION_USER_KEY = "user"
SESSION_STATE_KEY = "oauth_state"
def store_session(request: Request, user: SessionUser) -> None:
request.session[SESSION_USER_KEY] = {
"user_id": user.user_id,
"gitea_id": user.gitea_id,
"gitea_login": user.gitea_login,
"display_name": user.display_name,
"email": user.email,
"avatar_url": user.avatar_url,
"role": user.role,
}
def current_user(request: Request) -> SessionUser | None:
raw = request.session.get(SESSION_USER_KEY)
if not raw:
return None
# Re-read the role from the database every request so role changes
# take effect on the next API call without forcing a logout.
row = db.conn().execute(
"SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role FROM users WHERE id = ?",
(raw["user_id"],),
).fetchone()
if row is None:
return None
return SessionUser(
user_id=row["id"],
gitea_id=row["gitea_id"],
gitea_login=row["gitea_login"],
display_name=row["display_name"],
email=row["email"] or "",
avatar_url=row["avatar_url"] or "",
role=row["role"],
)
def require_user(request: Request) -> SessionUser:
user = current_user(request)
if user is None:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
def require_contributor(request: Request) -> SessionUser:
"""§6.1: authenticated, not write-muted."""
user = require_user(request)
row = db.conn().execute("SELECT muted FROM users WHERE id = ?", (user.user_id,)).fetchone()
if row and row["muted"]:
raise HTTPException(status_code=403, detail="Your account is muted")
return user
def require_admin(request: Request) -> SessionUser:
"""§6.1: owner or admin."""
user = require_user(request)
if user.role not in ("owner", "admin"):
raise HTTPException(status_code=403, detail="Admin or owner role required")
return user
def new_state() -> str:
return secrets.token_urlsafe(16)
+222
View File
@@ -0,0 +1,222 @@
"""The bot wrapper.
Per §1: the bot service account is the only Git writer in the system.
Per §6.5: every commit, branch creation, and PR merge carries an
On-behalf-of: trailer naming the acting user.
This module is the single chokepoint. Every write to Gitea file
creation, branch creation, PR open, PR merge, PR close flows through
a Bot method that takes an `actor` (the authenticated user whose gesture
produced the action) and an `action_kind` (one of the values recorded
in the `actions` table). The wrapper:
- calls the Gitea HTTP client with the bot's credentials,
- appends the trailer to commit/PR/comment bodies,
- records a row in `actions` so the app's accountability surface and
the Git log carry the same record.
If you find yourself wanting to import gitea.py directly to perform a
write, the spec is right and you are wrong: the wrapper is the
invariant. Read operations live in `gitea.py` and can be called from
anywhere.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from . import db
from .gitea import Gitea
@dataclass(frozen=True)
class Actor:
"""The user whose gesture is producing a write."""
user_id: int
gitea_login: str
display_name: str
email: str
def _trailer(actor: Actor) -> str:
return f"On-behalf-of: {actor.display_name} <{actor.gitea_login}>"
def _stamp(message_subject: str, message_body: str, actor: Actor) -> tuple[str, str]:
"""Compose subject + body with the On-behalf-of trailer appended.
Subject and body are returned separately because Gitea's merge API
takes them on distinct fields; for file commits we hand back a
single string in the caller.
"""
body = message_body.rstrip()
trailer = _trailer(actor)
if body:
return message_subject, f"{body}\n\n{trailer}"
return message_subject, trailer
def _stamp_single(message: str, actor: Actor) -> str:
subject, _, rest = message.partition("\n")
subject, body = _stamp(subject, rest.lstrip(), actor)
return f"{subject}\n\n{body}".rstrip()
def _log(
actor: Actor,
action_kind: str,
*,
rfc_slug: str | None = None,
branch_name: str | None = None,
pr_number: int | None = None,
bot_commit_sha: str | None = None,
details: dict | None = None,
) -> None:
db.conn().execute(
"""
INSERT INTO actions
(actor_user_id, on_behalf_of, action_kind, rfc_slug, branch_name, pr_number, bot_commit_sha, details)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
actor.user_id,
actor.gitea_login,
action_kind,
rfc_slug,
branch_name,
pr_number,
bot_commit_sha,
json.dumps(details) if details else None,
),
)
class Bot:
def __init__(self, gitea: Gitea):
self._gitea = gitea
# ----- Meta repo: idea PRs (§9.1 / §9.2) -----
async def open_idea_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
slug: str,
file_contents: str,
pr_title: str,
pr_description: str,
) -> dict:
"""Per §9.1: open a meta-repo PR adding one file under rfcs/.
One file per PR keeps idea submissions atomic and conflict-free.
The PR title and the file-add commit subject share §9.2's fixed
pattern; callers compose `pr_title` as `Propose: <Title>`.
"""
branch = f"propose/{slug}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
commit_subject = pr_title # §9.2: shared pattern
commit_message = _stamp_single(commit_subject, actor)
created = await self._gitea.create_file(
org,
meta_repo,
f"rfcs/{slug}.md",
content=file_contents,
message=commit_message,
branch=branch,
author_name=actor.display_name,
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
)
commit_sha = created.get("commit", {}).get("sha")
pr_body_subject, pr_body = _stamp("", pr_description, actor)
del pr_body_subject # only the body matters here
pr = await self._gitea.create_pull(
org,
meta_repo,
title=pr_title,
body=pr_body,
head=branch,
base="main",
)
_log(
actor,
"propose_rfc",
rfc_slug=slug,
branch_name=branch,
pr_number=pr["number"],
bot_commit_sha=commit_sha,
details={"pr_title": pr_title},
)
return pr
async def merge_idea_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
pr_number: int,
slug: str,
) -> None:
"""Per §9.3: owner/admin merges an idea PR, creating the super-draft."""
subject = f"Merge proposal: {slug}"
body = _trailer(actor)
await self._gitea.merge_pull(
org,
meta_repo,
pr_number,
merge_message_title=subject,
merge_message_body=body,
)
_log(
actor,
"merge_proposal",
rfc_slug=slug,
pr_number=pr_number,
)
async def decline_idea_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
pr_number: int,
slug: str,
comment: str,
) -> None:
"""Per §9.3: owner/admin declines an idea PR with a required comment.
The comment is posted to the PR (the durable Git artifact) and a
mirroring system-author thread_messages row is written by the
caller so the chat record carries the act inline.
"""
commented = comment.strip() or "(no comment provided)"
body = f"{commented}\n\n{_trailer(actor)}"
await self._gitea.create_issue_comment(org, meta_repo, pr_number, body)
await self._gitea.close_pull(org, meta_repo, pr_number)
_log(
actor,
"decline_proposal",
rfc_slug=slug,
pr_number=pr_number,
details={"comment": commented},
)
async def withdraw_idea_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
pr_number: int,
slug: str,
) -> None:
await self._gitea.close_pull(org, meta_repo, pr_number)
_log(
actor,
"withdraw_proposal",
rfc_slug=slug,
pr_number=pr_number,
)
+312
View File
@@ -0,0 +1,312 @@
"""The §4 metadata cache and its two writers.
Per §4: Gitea is truth. The cache mirrors only what the left pane and
the read surfaces need, and it is rebuildable from Gitea at any time.
Per §4.1: two writers the webhook handler and the periodic reconciler
both read from Gitea and write to the cache. User actions never write
to the cache directly; they trigger Git operations through the bot
(`bot.py`), and the resulting webhook (or the next reconciler sweep)
is what updates the cache.
This module provides:
- `refresh_meta_repo()` reads rfcs/ on the meta repo and reconciles
cached_rfcs against what's there. Used by both the webhook handler
(on meta-repo merge events) and the reconciler.
- `refresh_meta_pulls()` reads open meta-repo PRs and reconciles
cached_prs for pr_kind='idea' and friends. Backs the §7.3
pending-ideas disclosure.
Per §4.2's "single SQLite file colocated with the FastAPI process," the
cache writes happen on the same process that serves reads; lock
contention is bounded by the small mutation surface (a few hundred
rows at most for v1) and SQLite's WAL mode.
"""
from __future__ import annotations
import asyncio
import json
import logging
from . import db, entry as entry_mod
from .config import Config
from .gitea import Gitea, GiteaError
log = logging.getLogger(__name__)
async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
"""Re-read rfcs/ on the meta repo and reconcile cached_rfcs.
Idempotent. Safe to call on every meta-repo webhook and on every
reconciler sweep.
"""
org, repo = config.gitea_org, config.meta_repo
try:
files = await gitea.list_dir(org, repo, "rfcs", ref="main")
except GiteaError as e:
log.warning("refresh_meta_repo: cannot list rfcs/: %s", e)
return
seen_slugs: set[str] = set()
for f in files:
if f.get("type") != "file" or not f.get("name", "").endswith(".md"):
continue
result = await gitea.read_file(org, repo, f["path"], ref="main")
if not result:
continue
text, sha = result
try:
entry = entry_mod.parse(text)
except Exception as parse_err:
log.warning("refresh_meta_repo: skipping %s: %s", f["path"], parse_err)
continue
if not entry.slug:
log.warning("refresh_meta_repo: skipping %s: missing slug", f["path"])
continue
seen_slugs.add(entry.slug)
_upsert_cached_rfc(entry, body_sha=sha)
# Mark entries removed from the meta repo as withdrawn-without-trace.
# In practice the spec keeps withdrawn entries in rfcs/ as historical
# record (§3), so this branch fires only for entries deleted out of
# band. We leave the row but flag it for reconciler attention.
existing = {row["slug"] for row in db.conn().execute("SELECT slug FROM cached_rfcs")}
for missing in existing - seen_slugs:
log.info("refresh_meta_repo: %s no longer in rfcs/ — leaving cache row in place", missing)
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
db.conn().execute(
"""
INSERT INTO cached_rfcs
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
body, body_sha, last_entry_commit_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
ON CONFLICT(slug) DO UPDATE SET
title = excluded.title,
state = excluded.state,
rfc_id = excluded.rfc_id,
repo = excluded.repo,
proposed_by = excluded.proposed_by,
proposed_at = excluded.proposed_at,
graduated_at = excluded.graduated_at,
graduated_by = excluded.graduated_by,
owners_json = excluded.owners_json,
arbiters_json = excluded.arbiters_json,
tags_json = excluded.tags_json,
body = excluded.body,
body_sha = excluded.body_sha,
last_entry_commit_at = datetime('now'),
updated_at = datetime('now')
""",
(
entry.slug,
entry.title,
entry.state,
entry.id,
entry.repo,
entry.proposed_by,
entry.proposed_at,
entry.graduated_at,
entry.graduated_by,
json.dumps(entry.owners),
json.dumps(entry.arbiters),
json.dumps(entry.tags),
entry.body,
body_sha,
),
)
async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
"""Reconcile open meta-repo PRs into cached_prs.
For Slice 1 we care about pr_kind='idea' (proposing a new entry).
Other meta-repo PR kinds (body edits, metadata edits, claims) will
be wired in their respective slices.
`opened_by` is the **underlying actor**, not the bot login Gitea
reports per §15.9's framing for notifications and per §6.5's
On-behalf-of accountability shape. We recover the actor by joining
against the `actions` audit log; if no row matches (cache rebuilt
from scratch on a deployment that pre-dates the actions log, or a
pull we did not author), we fall back to parsing the
`On-behalf-of:` trailer from the PR body, then to the raw Gitea
login as last resort.
"""
org, repo = config.gitea_org, config.meta_repo
repo_full = f"{org}/{repo}"
try:
open_pulls = await gitea.list_pulls(org, repo, state="open")
closed_pulls = await gitea.list_pulls(org, repo, state="closed")
except GiteaError as e:
log.warning("refresh_meta_pulls: %s", e)
return
bot_login = config.gitea_bot_user
for pull in open_pulls + closed_pulls:
head_branch = pull.get("head", {}).get("ref", "")
slug = _slug_from_head_branch(head_branch)
if slug is None:
continue
pr_kind = _kind_from_branch(head_branch)
state = _state_from_pull(pull)
gitea_opener = (pull.get("user") or {}).get("login") or ""
opened_by = _resolve_actor(
gitea_opener,
bot_login,
slug,
pull["number"],
pull.get("body") or "",
)
db.conn().execute(
"""
INSERT INTO cached_prs
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
opened_by, opened_at, merged_at, closed_at,
head_branch, base_branch, head_sha)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(repo, pr_number) DO UPDATE SET
title = excluded.title,
description = excluded.description,
state = excluded.state,
opened_by = excluded.opened_by,
merged_at = excluded.merged_at,
closed_at = excluded.closed_at,
head_sha = excluded.head_sha
""",
(
slug,
pr_kind,
repo_full,
pull["number"],
pull.get("title") or "",
pull.get("body") or "",
state,
opened_by,
pull.get("created_at"),
pull.get("merged_at"),
pull.get("closed_at"),
head_branch,
(pull.get("base") or {}).get("ref") or "main",
(pull.get("head") or {}).get("sha"),
),
)
_TRAILER_RE = None
def _resolve_actor(gitea_opener: str, bot_login: str, slug: str, pr_number: int, body: str) -> str:
"""Best effort: collapse the bot's authorship to the underlying actor."""
if gitea_opener and gitea_opener != bot_login:
return gitea_opener
# Prefer the audit log.
row = db.conn().execute(
"""
SELECT on_behalf_of FROM actions
WHERE action_kind IN ('propose_rfc', 'open_body_edit_pr', 'open_claim_pr', 'open_metadata_pr')
AND rfc_slug = ? AND pr_number = ?
ORDER BY id LIMIT 1
""",
(slug, pr_number),
).fetchone()
if row and row["on_behalf_of"]:
return row["on_behalf_of"]
# Fall back to parsing the On-behalf-of trailer.
import re as _re
global _TRAILER_RE
if _TRAILER_RE is None:
_TRAILER_RE = _re.compile(r"On-behalf-of:\s+.*?<([^>]+)>", _re.MULTILINE)
m = _TRAILER_RE.search(body)
if m:
return m.group(1)
return gitea_opener or bot_login
def _slug_from_head_branch(head_branch: str) -> str | None:
if head_branch.startswith("propose/"):
return head_branch[len("propose/") :]
if head_branch.startswith("edit/"):
parts = head_branch.split("/", 2)
if len(parts) >= 2:
return parts[1]
if head_branch.startswith("claim/"):
return head_branch[len("claim/") :]
if head_branch.startswith("metadata/"):
return head_branch[len("metadata/") :]
return None
def _kind_from_branch(head_branch: str) -> str:
if head_branch.startswith("propose/"):
return "idea"
if head_branch.startswith("edit/"):
return "meta_body_edit"
if head_branch.startswith("claim/"):
return "meta_claim"
if head_branch.startswith("metadata/"):
return "meta_metadata"
return "idea" # fallback
def _state_from_pull(pull: dict) -> str:
if pull.get("merged"):
return "merged"
if pull.get("state") == "closed":
return "closed"
return "open"
# ----- Reconciler -----
class Reconciler:
"""Per §4.1: periodic safety-net sweep.
Runs in the background, every five minutes by default. Catches up
on any webhook the bot missed (downtime, network failure, Gitea
flake). If the cache is corrupted, the reconciler rebuilds from
scratch that's the contract.
"""
def __init__(self, config: Config, gitea: Gitea, interval_seconds: int = 300):
self._config = config
self._gitea = gitea
self._interval = interval_seconds
self._task: asyncio.Task | None = None
self._stop = asyncio.Event()
async def _loop(self) -> None:
# One sweep at startup, then on the interval. The startup sweep
# is what brings a fresh cache to life on first boot.
await self.sweep()
while not self._stop.is_set():
try:
await asyncio.wait_for(self._stop.wait(), timeout=self._interval)
except asyncio.TimeoutError:
pass
if self._stop.is_set():
break
await self.sweep()
async def sweep(self) -> None:
log.info("reconciler: starting sweep")
try:
await refresh_meta_repo(self._config, self._gitea)
await refresh_meta_pulls(self._config, self._gitea)
except Exception:
log.exception("reconciler: sweep failed")
else:
log.info("reconciler: sweep complete")
def start(self) -> None:
if self._task is None:
self._task = asyncio.create_task(self._loop())
async def stop(self) -> None:
self._stop.set()
if self._task is not None:
await self._task
self._task = None
+80
View File
@@ -0,0 +1,80 @@
"""Environment-derived configuration.
Loaded once at process start. Every module that needs a value pulls it from
here rather than re-reading os.environ, so there is one obvious place to
look when a setting is missing.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
def _required(name: str) -> str:
value = os.environ.get(name, "").strip()
if not value:
raise RuntimeError(f"Required environment variable {name} is not set")
return value
def _optional(name: str, default: str = "") -> str:
return os.environ.get(name, default).strip()
@dataclass
class Config:
gitea_url: str
gitea_bot_user: str
gitea_bot_token: str
gitea_org: str
meta_repo: str
oauth_client_id: str
oauth_client_secret: str
app_url: str
secret_key: str
database_path: Path
owner_gitea_login: str
webhook_secret: str
enabled_models: list[str] = field(default_factory=list)
anthropic_api_key: str = ""
google_api_key: str = ""
openai_api_key: str = ""
@property
def redirect_uri(self) -> str:
return f"{self.app_url}/auth/callback"
@property
def meta_repo_full(self) -> str:
return f"{self.gitea_org}/{self.meta_repo}"
def load_config() -> Config:
database_path = Path(_optional("DATABASE_PATH", "data/rfc-app.db")).resolve()
database_path.parent.mkdir(parents=True, exist_ok=True)
enabled = [m.strip() for m in _optional("ENABLED_MODELS", "claude").split(",") if m.strip()]
return Config(
gitea_url=_required("GITEA_URL").rstrip("/"),
gitea_bot_user=_required("GITEA_BOT_USER"),
gitea_bot_token=_required("GITEA_BOT_TOKEN"),
gitea_org=_required("GITEA_ORG"),
meta_repo=_optional("META_REPO", "meta"),
oauth_client_id=_required("OAUTH_CLIENT_ID"),
oauth_client_secret=_required("OAUTH_CLIENT_SECRET"),
app_url=_optional("APP_URL", "http://localhost:8000").rstrip("/"),
secret_key=_required("SECRET_KEY"),
database_path=database_path,
owner_gitea_login=_optional("OWNER_GITEA_LOGIN"),
webhook_secret=_optional("GITEA_WEBHOOK_SECRET"),
enabled_models=enabled,
anthropic_api_key=_optional("ANTHROPIC_API_KEY"),
google_api_key=_optional("GOOGLE_API_KEY"),
openai_api_key=_optional("OPENAI_API_KEY"),
)
+84
View File
@@ -0,0 +1,84 @@
"""SQLite connection and migration runner.
The schema lives in backend/migrations/ as numbered .sql files; this module
runs them in order against the configured database file and exposes a
connection factory that the rest of the app uses. WAL is enabled because
the SSE handlers and the reconciler can read while a webhook writes; FK
enforcement is on because §5's cascade rules depend on it.
Per §4.2, single SQLite file colocated with the FastAPI process. If we
outgrow this, the spec calls for a planned migration to Postgres on a
separate host not for a clever sharding scheme bolted on here.
"""
from __future__ import annotations
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator
from .config import Config
MIGRATIONS_DIR = Path(__file__).resolve().parent.parent / "migrations"
def connect(path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(path, isolation_level=None, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA synchronous = NORMAL")
return conn
def run_migrations(config: Config) -> None:
conn = connect(config.database_path)
try:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)
"""
)
applied = {row["version"] for row in conn.execute("SELECT version FROM schema_migrations")}
for path in sorted(MIGRATIONS_DIR.glob("*.sql")):
version = path.stem
if version in applied:
continue
sql = path.read_text()
conn.executescript("BEGIN; " + sql + "; COMMIT;")
conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (version,))
finally:
conn.close()
_CONN: sqlite3.Connection | None = None
def init(config: Config) -> None:
"""Open the long-lived connection. Call once at startup, after migrations."""
global _CONN
if _CONN is not None:
return
_CONN = connect(config.database_path)
def conn() -> sqlite3.Connection:
if _CONN is None:
raise RuntimeError("db.init() has not been called")
return _CONN
@contextmanager
def tx() -> Iterator[sqlite3.Connection]:
"""Wrap a block in a transaction. Rolls back on exception."""
c = conn()
c.execute("BEGIN")
try:
yield c
c.execute("COMMIT")
except Exception:
c.execute("ROLLBACK")
raise
+102
View File
@@ -0,0 +1,102 @@
"""Meta-repo entry file shape per §2.1.
One markdown file per RFC under rfcs/<slug>.md, frontmatter on top, body
below. The frontmatter carries the canonical RFC state id, repo,
owners, arbiters, graduation timestamps and the body holds the pitch
(for super-drafts) or is empty (for graduated entries per §13.3 step 3).
This module contains the parser, the serializer, and a small validator
for the frontmatter shape. The parser is intentionally lenient about
unknown keys future fields land in frontmatter without breaking older
readers.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import date
from typing import Any
import yaml
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.DOTALL)
SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
@dataclass
class Entry:
slug: str
title: str
state: str = "super-draft" # super-draft | active | withdrawn
id: str | None = None # 'RFC-NNNN' or None
repo: str | None = None
proposed_by: str = ""
proposed_at: str = "" # ISO date
graduated_at: str | None = None
graduated_by: str | None = None
owners: list[str] = field(default_factory=list)
arbiters: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
body: str = ""
def parse(text: str) -> Entry:
match = FRONTMATTER_RE.match(text)
if not match:
raise ValueError("Entry file missing frontmatter")
fm = yaml.safe_load(match.group(1)) or {}
body = match.group(2).lstrip("\n")
return Entry(
slug=str(fm.get("slug") or ""),
title=str(fm.get("title") or ""),
state=str(fm.get("state") or "super-draft"),
id=fm.get("id") or None,
repo=fm.get("repo") or None,
proposed_by=str(fm.get("proposed_by") or ""),
proposed_at=str(fm.get("proposed_at") or ""),
graduated_at=fm.get("graduated_at"),
graduated_by=fm.get("graduated_by"),
owners=list(fm.get("owners") or []),
arbiters=list(fm.get("arbiters") or []),
tags=list(fm.get("tags") or []),
body=body,
)
def serialize(entry: Entry) -> str:
"""Emit canonical entry file text — frontmatter then body."""
fm: dict[str, Any] = {
"slug": entry.slug,
"title": entry.title,
"state": entry.state,
"id": entry.id,
"repo": entry.repo,
"proposed_by": entry.proposed_by,
"proposed_at": entry.proposed_at,
"graduated_at": entry.graduated_at,
"graduated_by": entry.graduated_by,
"owners": entry.owners,
"arbiters": entry.arbiters,
"tags": entry.tags,
}
yaml_text = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False).rstrip()
body = entry.body.lstrip("\n")
if body:
return f"---\n{yaml_text}\n---\n\n{body}\n"
return f"---\n{yaml_text}\n---\n"
def slugify(title: str) -> str:
"""Deterministic kebab-case per §9.1."""
s = title.lower().strip()
s = re.sub(r"[^a-z0-9]+", "-", s)
return s.strip("-")
def today() -> str:
return date.today().isoformat()
def is_valid_slug(slug: str) -> bool:
return bool(SLUG_RE.match(slug)) and len(slug) <= 80
+286
View File
@@ -0,0 +1,286 @@
"""Thin HTTP client for the Gitea REST API.
Read operations live here and are called from any module that needs them
(reconciler, super-draft body fetch, webhook handler). Write operations
also live here but are not called directly from outside this module
they are wrapped by `bot.py` per §1, so that every commit, branch, and
PR carries the §6.5 On-behalf-of trailer and a row in the actions log.
This split keeps the chokepoint legible: anything that wants to read
imports from here; anything that wants to write imports from `bot.py`
and never reaches around it.
"""
from __future__ import annotations
import base64
from typing import Any
import httpx
from .config import Config
class GiteaError(Exception):
def __init__(self, status: int, detail: str):
super().__init__(f"Gitea {status}: {detail}")
self.status = status
self.detail = detail
class Gitea:
def __init__(self, config: Config):
self._config = config
self._client = httpx.AsyncClient(
base_url=f"{config.gitea_url}/api/v1",
headers={
"Authorization": f"token {config.gitea_bot_token}",
"Accept": "application/json",
},
timeout=30.0,
)
async def close(self) -> None:
await self._client.aclose()
async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
resp = await self._client.request(method, path, **kwargs)
if resp.status_code >= 400:
try:
detail = resp.json().get("message", resp.text)
except Exception:
detail = resp.text
raise GiteaError(resp.status_code, detail)
return resp
# ----- Repo lifecycle -----
async def get_repo(self, owner: str, repo: str) -> dict | None:
try:
resp = await self._request("GET", f"/repos/{owner}/{repo}")
return resp.json()
except GiteaError as e:
if e.status == 404:
return None
raise
async def create_org_repo(self, org: str, name: str, *, description: str = "", private: bool = False) -> dict:
resp = await self._request(
"POST",
f"/orgs/{org}/repos",
json={
"name": name,
"description": description,
"private": private,
"auto_init": False,
},
)
return resp.json()
async def delete_repo(self, owner: str, repo: str) -> None:
await self._request("DELETE", f"/repos/{owner}/{repo}")
# ----- Branches -----
async def get_branch(self, owner: str, repo: str, branch: str) -> dict | None:
try:
resp = await self._request("GET", f"/repos/{owner}/{repo}/branches/{branch}")
return resp.json()
except GiteaError as e:
if e.status == 404:
return None
raise
async def list_branches(self, owner: str, repo: str) -> list[dict]:
resp = await self._request("GET", f"/repos/{owner}/{repo}/branches", params={"limit": 50})
return resp.json()
async def create_branch(self, owner: str, repo: str, new_branch: str, from_branch: str = "main") -> dict:
resp = await self._request(
"POST",
f"/repos/{owner}/{repo}/branches",
json={"new_branch_name": new_branch, "old_branch_name": from_branch},
)
return resp.json()
async def delete_branch(self, owner: str, repo: str, branch: str) -> None:
await self._request("DELETE", f"/repos/{owner}/{repo}/branches/{branch}")
# ----- File contents -----
async def get_contents(self, owner: str, repo: str, path: str, ref: str = "main") -> dict | None:
try:
resp = await self._request(
"GET",
f"/repos/{owner}/{repo}/contents/{path}",
params={"ref": ref},
)
return resp.json()
except GiteaError as e:
if e.status == 404:
return None
raise
async def list_dir(self, owner: str, repo: str, path: str = "", ref: str = "main") -> list[dict]:
try:
resp = await self._request(
"GET",
f"/repos/{owner}/{repo}/contents/{path}",
params={"ref": ref},
)
except GiteaError as e:
if e.status == 404:
return []
raise
data = resp.json()
return data if isinstance(data, list) else [data]
async def read_file(self, owner: str, repo: str, path: str, ref: str = "main") -> tuple[str, str] | None:
"""Return (content, sha) or None if the file is missing."""
item = await self.get_contents(owner, repo, path, ref)
if not item or item.get("type") != "file":
return None
return base64.b64decode(item["content"]).decode("utf-8"), item["sha"]
async def create_file(
self,
owner: str,
repo: str,
path: str,
*,
content: str,
message: str,
branch: str,
author_name: str | None = None,
author_email: str | None = None,
) -> dict:
body: dict[str, Any] = {
"message": message,
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
"branch": branch,
}
if author_name and author_email:
body["author"] = {"name": author_name, "email": author_email}
body["committer"] = {"name": author_name, "email": author_email}
resp = await self._request("POST", f"/repos/{owner}/{repo}/contents/{path}", json=body)
return resp.json()
async def update_file(
self,
owner: str,
repo: str,
path: str,
*,
content: str,
sha: str,
message: str,
branch: str,
author_name: str | None = None,
author_email: str | None = None,
) -> dict:
body: dict[str, Any] = {
"message": message,
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
"sha": sha,
"branch": branch,
}
if author_name and author_email:
body["author"] = {"name": author_name, "email": author_email}
body["committer"] = {"name": author_name, "email": author_email}
resp = await self._request("PUT", f"/repos/{owner}/{repo}/contents/{path}", json=body)
return resp.json()
# ----- Pull requests -----
async def list_pulls(self, owner: str, repo: str, state: str = "open") -> list[dict]:
resp = await self._request(
"GET",
f"/repos/{owner}/{repo}/pulls",
params={"state": state, "limit": 50},
)
return resp.json()
async def get_pull(self, owner: str, repo: str, number: int) -> dict | None:
try:
resp = await self._request("GET", f"/repos/{owner}/{repo}/pulls/{number}")
return resp.json()
except GiteaError as e:
if e.status == 404:
return None
raise
async def create_pull(
self,
owner: str,
repo: str,
*,
title: str,
body: str,
head: str,
base: str = "main",
) -> dict:
resp = await self._request(
"POST",
f"/repos/{owner}/{repo}/pulls",
json={"title": title, "body": body, "head": head, "base": base},
)
return resp.json()
async def merge_pull(
self,
owner: str,
repo: str,
number: int,
*,
merge_message_title: str,
merge_message_body: str,
style: str = "merge",
) -> None:
# Per §10.5: no-fast-forward merge commit. We pass Do='merge' so
# Gitea produces a merge commit rather than a fast-forward.
await self._request(
"POST",
f"/repos/{owner}/{repo}/pulls/{number}/merge",
json={
"Do": style,
"MergeTitleField": merge_message_title,
"MergeMessageField": merge_message_body,
},
)
async def close_pull(self, owner: str, repo: str, number: int) -> None:
await self._request(
"PATCH",
f"/repos/{owner}/{repo}/issues/{number}",
json={"state": "closed"},
)
async def create_issue_comment(self, owner: str, repo: str, number: int, body: str) -> dict:
resp = await self._request(
"POST",
f"/repos/{owner}/{repo}/issues/{number}/comments",
json={"body": body},
)
return resp.json()
# ----- Webhooks -----
async def ensure_webhook(self, owner: str, repo: str, *, url: str, secret: str, events: list[str]) -> dict:
existing = (await self._request("GET", f"/repos/{owner}/{repo}/hooks")).json()
for hook in existing:
if hook.get("config", {}).get("url") == url:
return hook
resp = await self._request(
"POST",
f"/repos/{owner}/{repo}/hooks",
json={
"type": "gitea",
"active": True,
"events": events,
"config": {
"url": url,
"content_type": "json",
"secret": secret,
},
},
)
return resp.json()
+102
View File
@@ -0,0 +1,102 @@
"""FastAPI entrypoint.
Wires the §17 routers, the OAuth callbacks, the webhook receiver, and
the background reconciler. Per §4.2, single process, colocated SQLite
no need for a separate worker.
"""
from __future__ import annotations
import logging
import secrets
from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI, HTTPException, Request
from fastapi.responses import RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
from . import api as api_routes, auth, cache, db, webhooks
from .bot import Bot
from .config import load_config
from .gitea import Gitea
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
log = logging.getLogger("rfc_app")
@asynccontextmanager
async def lifespan(app: FastAPI):
config = load_config()
db.run_migrations(config)
db.init(config)
gitea = Gitea(config)
bot = Bot(gitea)
reconciler = cache.Reconciler(config, gitea)
app.state.config = config
app.state.gitea = gitea
app.state.bot = bot
app.state.reconciler = reconciler
app.include_router(_oauth_router(config))
app.include_router(api_routes.make_router(config, gitea, bot))
app.include_router(webhooks.make_router(config, gitea))
reconciler.start()
log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo)
try:
yield
finally:
await reconciler.stop()
await gitea.close()
def create_app() -> FastAPI:
# The secret key is required at app construction (SessionMiddleware
# is added before lifespan runs), so we read just that one value
# eagerly via load_config(). Everything else waits for lifespan.
config = load_config()
app = FastAPI(lifespan=lifespan)
app.add_middleware(
SessionMiddleware,
secret_key=config.secret_key,
session_cookie="rfc_session",
max_age=60 * 60 * 24 * 30,
https_only=False,
)
return app
app = create_app()
def _oauth_router(config) -> APIRouter:
router = APIRouter()
@router.get("/auth/login")
async def login(request: Request):
state = auth.new_state()
request.session[auth.SESSION_STATE_KEY] = state
return RedirectResponse(auth.authorization_url(config, state))
@router.get("/auth/callback")
async def callback(request: Request, code: str = "", state: str = ""):
if not code:
raise HTTPException(400, "Missing code")
stored_state = request.session.get(auth.SESSION_STATE_KEY)
if not stored_state or not secrets.compare_digest(stored_state, state):
raise HTTPException(400, "Invalid state")
token_data = await auth.exchange_code(config, code)
access_token = token_data.get("access_token")
if not access_token:
raise HTTPException(400, "Token exchange failed")
profile = await auth.fetch_user_profile(config, access_token)
user = auth.provision_user(config, profile)
auth.store_session(request, user)
return RedirectResponse("/")
@router.get("/auth/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/")
return router
+71
View File
@@ -0,0 +1,71 @@
"""Gitea webhook receiver per §4.1.
Both the webhook receiver and the reconciler are §4.1 cache writers.
On a meaningful event meta-repo push or PR change we re-read just
what changed from Gitea and update the cache. The signature is verified
against the configured shared secret so spurious POSTs cannot poison
the cache.
"""
from __future__ import annotations
import hashlib
import hmac
import logging
from fastapi import APIRouter, Header, HTTPException, Request
from . import cache
from .config import Config
from .gitea import Gitea
log = logging.getLogger(__name__)
EVENTS_OF_INTEREST = {
"push", # meta-repo or RFC-repo commits
"pull_request", # opened / closed / merged
"create", # branch or repo created
"delete", # branch deleted
"repository", # repo created or deleted
}
def make_router(config: Config, gitea: Gitea) -> APIRouter:
router = APIRouter()
@router.post("/api/webhooks/gitea")
async def receive(
request: Request,
x_gitea_event: str = Header(default=""),
x_gitea_signature: str = Header(default=""),
):
body = await request.body()
if config.webhook_secret:
if not _verify_signature(body, x_gitea_signature, config.webhook_secret):
raise HTTPException(status_code=401, detail="Invalid signature")
event = x_gitea_event.lower()
if event not in EVENTS_OF_INTEREST:
return {"ok": True, "ignored": event}
# Slice 1 only acts on meta-repo events; per-RFC-repo events
# land in their respective slices. The handler is generous in
# what it accepts — any meta-repo change is a cue to refresh
# the whole meta-repo cache, since the cache is small and the
# refresh is idempotent.
try:
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
except Exception:
log.exception("webhook refresh failed")
raise HTTPException(status_code=500, detail="Refresh failed")
return {"ok": True}
return router
def _verify_signature(body: bytes, header: str, secret: str) -> bool:
if not header:
return False
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
@@ -0,0 +1,65 @@
-- §5 / §6: users, permission events, and audit log.
--
-- The users table is the app-owned canonical account record. Per §6.1,
-- role is one of owner / admin / contributor; anonymous is the absence
-- of a row (or the absence of a session). The §6.2 app-wide write-mute
-- lives here as `muted`, structurally distinct from the §15.6 per-RFC
-- mute (on watches) and the §15.8 per-user mute (notification_user_mutes).
--
-- Per §15, the per-user notification preferences are inlined for
-- proximity. The watched-RFC-churn category has no column per §15.4 —
-- it is permanently off and surfaces in settings as a disabled toggle.
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
gitea_id INTEGER UNIQUE NOT NULL,
gitea_login TEXT UNIQUE NOT NULL,
email TEXT,
display_name TEXT NOT NULL,
avatar_url TEXT,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'contributor')),
muted INTEGER NOT NULL DEFAULT 0, -- §6.2 app-wide write-mute
email_personal_direct INTEGER NOT NULL DEFAULT 1, -- §15.4 default on
email_watched_structural INTEGER NOT NULL DEFAULT 0, -- §15.4 default off
email_admin_actionable INTEGER NOT NULL DEFAULT 1, -- §15.4 default on for admins/owners; ignored for contributors
digest_cadence TEXT NOT NULL DEFAULT 'weekly' CHECK (digest_cadence IN ('off', 'weekly', 'daily')), -- §15.5
notification_quiet_hours_start TEXT, -- §15.8 ISO-8601 local time HH:MM
notification_quiet_hours_end TEXT,
notification_quiet_hours_timezone TEXT, -- IANA tz name
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_users_role ON users (role);
-- §6.5: permission-change audit. Append-only. Every mute, role grant,
-- or capability override produces a row here.
CREATE TABLE permission_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
subject_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
event_kind TEXT NOT NULL, -- e.g. role_changed, muted, restored
details TEXT, -- JSON blob with before/after, reason, etc.
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_permission_events_subject ON permission_events (subject_user_id, created_at);
-- §5: append-only action log. Every state transition, every graduation,
-- every grant change. Includes the on-behalf-of trailer per §6.5 so the
-- audit log and the Git log carry the same accountability.
CREATE TABLE actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
on_behalf_of TEXT NOT NULL, -- the gitea_login the bot acted on behalf of
action_kind TEXT NOT NULL, -- propose_rfc, merge_proposal, graduate, etc.
rfc_slug TEXT,
branch_name TEXT,
pr_number INTEGER,
bot_commit_sha TEXT,
details TEXT, -- JSON blob with kind-specific extras
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_actions_rfc ON actions (rfc_slug, created_at);
CREATE INDEX idx_actions_actor ON actions (actor_user_id, created_at);
+82
View File
@@ -0,0 +1,82 @@
-- §4: the metadata cache. Reconstructible from Gitea at any time by the
-- §4.1 reconciler; never written from user actions, only from webhook
-- handlers and reconciler sweeps. Body content is cached for main-branch
-- reads (§4 #3); branch bodies are not.
--
-- These tables are not in §5's "canonical app tables" list because they
-- are cache, not truth — but they are required for the left-pane render
-- path and for serving super-draft and main-branch bodies without a
-- Gitea round-trip on every navigation.
-- One row per meta-repo rfcs/<slug>.md entry. Mirrors §2.1 frontmatter
-- plus the cached body for super-draft preview (graduated entries have
-- frontmatter-only bodies per §13.3 step 3, but the field is reused).
CREATE TABLE cached_rfcs (
slug TEXT PRIMARY KEY,
title TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn')),
rfc_id TEXT, -- 'RFC-NNNN' or NULL until graduated
repo TEXT, -- 'org/repo' or NULL until graduated
proposed_by TEXT, -- gitea login or email
proposed_at TEXT,
graduated_at TEXT,
graduated_by TEXT,
owners_json TEXT NOT NULL DEFAULT '[]',
arbiters_json TEXT NOT NULL DEFAULT '[]',
tags_json TEXT NOT NULL DEFAULT '[]',
body TEXT, -- super-draft body or main RFC.md body
body_sha TEXT, -- the commit sha the body was fetched at
last_main_commit_at TEXT, -- §7.1's "Recently active" sort
last_entry_commit_at TEXT, -- last meta-repo commit touching this entry
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state);
CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs (
COALESCE(last_main_commit_at, last_entry_commit_at) DESC
);
-- One row per branch the bot knows about on either a per-RFC repo
-- (rfc_slug, state='active'') or on the meta repo as a super-draft edit
-- branch (rfc_slug, state='super-draft', branch_name like 'edit/<slug>/...').
-- §11.5: closed branches stay; deleted branches keep their metadata row
-- per §12 ("branch removed from Gitea, row remains").
CREATE TABLE cached_branches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
head_sha TEXT,
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed', 'deleted')),
pinned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_commit_at TEXT,
closed_at TEXT,
UNIQUE (rfc_slug, branch_name)
);
CREATE INDEX idx_cached_branches_rfc ON cached_branches (rfc_slug, state);
-- One row per PR the bot knows about. Includes meta-repo idea PRs (rfc_slug
-- carries the proposed slug, see §5 super-draft scoping note) and meta-repo
-- body-edit PRs and per-RFC-repo PRs. The pr_kind disambiguates.
CREATE TABLE cached_prs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
pr_kind TEXT NOT NULL CHECK (pr_kind IN ('idea', 'meta_body_edit', 'meta_metadata', 'meta_claim', 'rfc_branch')),
repo TEXT NOT NULL, -- 'org/repo' the PR lives on
pr_number INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
state TEXT NOT NULL CHECK (state IN ('open', 'merged', 'closed', 'withdrawn')),
opened_by TEXT, -- gitea login (resolved from On-behalf-of trailer where present)
opened_at TEXT,
merged_at TEXT,
closed_at TEXT,
head_branch TEXT,
base_branch TEXT NOT NULL DEFAULT 'main',
head_sha TEXT,
UNIQUE (repo, pr_number)
);
CREATE INDEX idx_cached_prs_rfc ON cached_prs (rfc_slug, state);
CREATE INDEX idx_cached_prs_kind ON cached_prs (pr_kind, state);
@@ -0,0 +1,38 @@
-- §5 / §6.4 / §11.1: per-branch visibility and contribute settings.
-- These rows are app data, not cache. They describe what the app permits
-- for a given branch; the bot enforces them before acting. Absence of a
-- row means defaults: read_public=1, contribute_mode='just-me'.
CREATE TABLE branch_visibility (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
read_public INTEGER NOT NULL DEFAULT 1,
contribute_mode TEXT NOT NULL DEFAULT 'just-me' CHECK (contribute_mode IN ('just-me', 'specific', 'any-contributor')),
UNIQUE (rfc_slug, branch_name)
);
CREATE TABLE branch_contribute_grants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
grantee_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
granted_by INTEGER NOT NULL REFERENCES users(id) ON DELETE SET NULL,
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (rfc_slug, branch_name, grantee_user_id)
);
CREATE INDEX idx_grants_lookup ON branch_contribute_grants (rfc_slug, branch_name);
CREATE INDEX idx_grants_grantee ON branch_contribute_grants (grantee_user_id);
-- §5 / §7.2: starred RFCs pin to the top of the current sort order.
CREATE TABLE stars (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
starred_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, rfc_slug)
);
CREATE INDEX idx_stars_user ON stars (user_id);
CREATE INDEX idx_stars_rfc ON stars (rfc_slug);
@@ -0,0 +1,73 @@
-- §5: threads, thread_messages, changes — the conversation and revision
-- substrate.
--
-- Per the §5 super-draft scoping note, rows with rfc_slug pointing at a
-- super-draft entry use branch_name to name a meta-repo branch rather
-- than a per-RFC-repo branch. The schema is identical either way; the
-- interpretation flows from the entry's state in cached_rfcs.
--
-- Threads on a pending-idea PR (§9.3) carry the proposed slug as rfc_slug
-- pre-merge — slugs are reserved during the idea PR per §9.1's uniqueness
-- check — and surface under the super-draft on merge with no data movement.
CREATE TABLE threads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT, -- NULL = scoped to the RFC's main view
anchor_kind TEXT NOT NULL CHECK (anchor_kind IN ('whole-doc', 'range', 'paragraph')),
anchor_payload TEXT, -- JSON: ProseMirror range or paragraph id
thread_kind TEXT NOT NULL CHECK (thread_kind IN ('chat', 'flag', 'review')),
label TEXT, -- short summary, or full flag content
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'resolved', 'stale')),
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
resolved_at TEXT,
resolved_by INTEGER REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX idx_threads_scope ON threads (rfc_slug, branch_name, state);
CREATE INDEX idx_threads_kind ON threads (thread_kind, state);
-- §5: chat content. Only chat-kind threads have rows here unless a flag
-- has been converted to a chat (§8.13). System-author messages (role='system',
-- author_user_id=NULL) carry the §10.6 manual-edit-flush markers, the §9.3
-- decline-comment record, and similar system-narration entries.
CREATE TABLE thread_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
author_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
model_id TEXT, -- set when role='assistant'
text TEXT NOT NULL,
quote TEXT, -- optional selection the user attached
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_thread_messages_thread ON thread_messages (thread_id, created_at);
CREATE INDEX idx_thread_messages_author ON thread_messages (author_user_id, created_at);
-- §5 / §8.6 / §8.9 / §8.11: structured proposed edits. AI-proposed (parsed
-- from <change> blocks per the §18 carryover) or manually authored.
-- stale_since is orthogonal to state: a stale AI proposal stays 'pending'
-- until the contributor acts on the staleness warning per §8.11.
CREATE TABLE changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
thread_id INTEGER REFERENCES threads(id) ON DELETE SET NULL,
source_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
kind TEXT NOT NULL CHECK (kind IN ('ai', 'manual')),
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'accepted', 'declined')),
original TEXT NOT NULL,
proposed TEXT NOT NULL,
reason TEXT,
was_edited_before_accept INTEGER NOT NULL DEFAULT 0,
stale_since TEXT,
acted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
acted_at TEXT,
commit_sha TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_changes_scope ON changes (rfc_slug, branch_name, state);
CREATE INDEX idx_changes_thread ON changes (thread_id);
@@ -0,0 +1,45 @@
-- §5 / §10.3 / §15.6 / §15.7: the freshness cursors and the watch model.
--
-- Two cursor families per §15.7: per-event read state lives on notifications
-- (added in 006); per-scope freshness lives on pr_seen and branch_chat_seen.
-- They serve different jobs and are reconciled by the visit-advances-cursor
-- reconciler in §15.7.
CREATE TABLE pr_seen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
pr_number INTEGER NOT NULL,
last_seen_commit_sha TEXT,
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, rfc_slug, pr_number)
);
CREATE TABLE branch_chat_seen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, rfc_slug, branch_name)
);
-- §15.6: the watch model. Three states; auto-rules upgrade but never
-- downgrade; explicit settings exempt from the 90-day decay. Per-RFC
-- mute lives here as state='muted'.
CREATE TABLE watches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('watching', 'following', 'muted')),
set_by TEXT NOT NULL CHECK (set_by IN ('auto', 'explicit')),
set_at TEXT NOT NULL DEFAULT (datetime('now')),
last_participation_at TEXT, -- 90-day decay key per §15.6
UNIQUE (user_id, rfc_slug)
);
CREATE INDEX idx_watches_user ON watches (user_id);
CREATE INDEX idx_watches_rfc ON watches (rfc_slug);
CREATE INDEX idx_watches_decay ON watches (state, last_participation_at);
+57
View File
@@ -0,0 +1,57 @@
-- §5 / §15: the notification substrate. Per §15.7, per-row read_at is what
-- the inbox needs because triage is per-event. Per §15.9, system-generated
-- events carry actor_user_id = NULL; the bot account does not appear here.
--
-- Fan-out is at signal-generation time per §15.7: each recipient gets their
-- own row. This trades storage for query simplicity at the inbox surface,
-- and the §15.5 digest's exclusion rules need per-recipient timestamps
-- (email_sent_at, digest_included_at) anyway.
CREATE TABLE notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipient_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
event_kind TEXT NOT NULL, -- §15.1 enum, extensible
rfc_slug TEXT,
branch_name TEXT,
pr_number INTEGER,
thread_id INTEGER REFERENCES threads(id) ON DELETE SET NULL,
change_id INTEGER REFERENCES changes(id) ON DELETE SET NULL,
actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- NULL = system per §15.9
payload TEXT NOT NULL DEFAULT '{}', -- JSON: rendered row text + extras
created_at TEXT NOT NULL DEFAULT (datetime('now')),
read_at TEXT, -- §15.7 per-event triage
email_sent_at TEXT, -- §15.5 exclusion rule 1
digest_included_at TEXT -- §15.5 exclusion rule 3 audit
);
CREATE INDEX idx_notifications_inbox ON notifications (recipient_user_id, read_at, created_at);
CREATE INDEX idx_notifications_scope ON notifications (rfc_slug, branch_name, pr_number);
CREATE INDEX idx_notifications_digest ON notifications (recipient_user_id, digest_included_at);
-- §15.5: per-recipient digest emissions. The period_start / period_end
-- pair makes the event-window dedup queryable at audit time.
CREATE TABLE notification_digests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipient_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
sent_at TEXT NOT NULL DEFAULT (datetime('now')),
period_start TEXT NOT NULL,
period_end TEXT NOT NULL,
signal_ids_included TEXT NOT NULL DEFAULT '[]' -- JSON array of notification ids
);
CREATE INDEX idx_digests_recipient ON notification_digests (recipient_user_id, sent_at);
-- §15.8: per-user notification mute. Notification-volume only; never
-- gates content visibility. The §6.2 clarification reads: an admin or
-- arbiter exercising authority on an RFC cannot mute participants on
-- that RFC. Enforcement of the role-exemption is in the API layer; the
-- schema just stores the mute.
CREATE TABLE notification_user_mutes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
muter_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
muted_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
muted_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (muter_user_id, muted_user_id)
);
CREATE INDEX idx_user_mutes_muter ON notification_user_mutes (muter_user_id);
+10
View File
@@ -0,0 +1,10 @@
fastapi>=0.115
uvicorn[standard]>=0.32
httpx>=0.27
python-dotenv>=1.0
itsdangerous>=2.2
pydantic>=2.9
anthropic>=0.39
google-generativeai>=0.8
openai>=1.50
PyYAML>=6.0
+440
View File
@@ -0,0 +1,440 @@
"""End-to-end integration test for the Slice 1 vertical.
Stands up the FastAPI app against a mocked Gitea transport that
simulates the meta repo and the propose-to-merge lifecycle. The test
walks the same path a user would: sign in (a forged session cookie
substitutes for the OAuth round-trip, since OAuth itself is not in
scope to mock end-to-end), open a propose modal
(POST /api/rfcs/propose), exercise the bot wrapper through to the
Gitea HTTP layer, merge the PR as an owner, refresh the cache, and
verify the super-draft surfaces in GET /api/rfcs and
GET /api/rfcs/<slug>.
The mocked Gitea is intentionally narrow it only honors the
endpoints the slice actually exercises. Adding routes to it as later
slices land is the right shape: the test surface tracks the production
surface.
"""
from __future__ import annotations
import base64
import json
import os
import re
import tempfile
from pathlib import Path
import httpx
import pytest
# ---------------------------------------------------------------------------
# Fake Gitea
# ---------------------------------------------------------------------------
class FakeGitea:
"""A narrow in-memory simulation of the Gitea API the slice uses."""
def __init__(self):
# files: (owner, repo, branch, path) -> {"content": str, "sha": str}
self.files: dict[tuple[str, str, str, str], dict] = {}
# branches: (owner, repo) -> {branch_name -> {"sha": str}}
self.branches: dict[tuple[str, str], dict[str, dict]] = {}
# pulls: (owner, repo) -> list[pull-dict]
self.pulls: dict[tuple[str, str], list[dict]] = {}
self._pr_counter = 0
self._commit_counter = 0
self._seed_repo("wiggleverse", "meta")
def _seed_repo(self, owner, repo):
self.branches[(owner, repo)] = {"main": {"sha": "initial"}}
self.pulls[(owner, repo)] = []
def _next_sha(self):
self._commit_counter += 1
return f"sha{self._commit_counter:04d}"
def handle(self, request: httpx.Request) -> httpx.Response:
path = request.url.path.replace("/api/v1", "", 1)
method = request.method
body = request.read().decode() if request.content else ""
payload = json.loads(body) if body else {}
# GET /repos/{owner}/{repo}
if method == "GET" and re.fullmatch(r"/repos/[^/]+/[^/]+", path):
return httpx.Response(200, json={"name": path.split("/")[-1]})
# GET /repos/{owner}/{repo}/branches/{branch}
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches/([^/]+)", path)
if method == "GET" and m:
owner, repo, branch = m.groups()
b = self.branches.get((owner, repo), {}).get(branch)
if not b:
return httpx.Response(404, json={"message": "not found"})
return httpx.Response(200, json={"name": branch, "commit": {"id": b["sha"]}})
# POST /repos/{owner}/{repo}/branches
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches", path)
if method == "POST" and m:
owner, repo = m.groups()
new = payload["new_branch_name"]
old = payload["old_branch_name"]
old_sha = self.branches[(owner, repo)][old]["sha"]
self.branches[(owner, repo)][new] = {"sha": old_sha}
# Copy main's files into the new branch
for (o, r, br, p), data in list(self.files.items()):
if (o, r, br) == (owner, repo, old):
self.files[(owner, repo, new, p)] = dict(data)
return httpx.Response(201, json={"name": new})
# GET /repos/{owner}/{repo}/contents/{path}?ref=...
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path)
if method == "GET" and m:
owner, repo, fpath = m.groups()
ref = request.url.params.get("ref", "main")
key = (owner, repo, ref, fpath)
if key in self.files:
f = self.files[key]
return httpx.Response(200, json={
"name": fpath.rsplit("/", 1)[-1],
"path": fpath,
"type": "file",
"sha": f["sha"],
"content": base64.b64encode(f["content"].encode()).decode(),
})
# Directory listing
prefix = fpath.rstrip("/") + "/"
children = []
for (o, r, br, p), data in self.files.items():
if (o, r, br) == (owner, repo, ref) and p.startswith(prefix) and "/" not in p[len(prefix):]:
children.append({
"name": p.rsplit("/", 1)[-1],
"path": p,
"type": "file",
"sha": data["sha"],
})
if children:
return httpx.Response(200, json=children)
return httpx.Response(404, json={"message": "not found"})
# POST /repos/{owner}/{repo}/contents/{path}
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path)
if method == "POST" and m:
owner, repo, fpath = m.groups()
branch = payload["branch"]
content = base64.b64decode(payload["content"]).decode()
sha = self._next_sha()
self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": sha}
self.branches[(owner, repo)][branch]["sha"] = sha
return httpx.Response(201, json={"commit": {"sha": sha}})
# GET /repos/{owner}/{repo}/pulls?state=...
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls", path)
if method == "GET" and m:
owner, repo = m.groups()
state = request.url.params.get("state", "open")
items = self.pulls.get((owner, repo), [])
filtered = [p for p in items if (state == "all") or (p["state"] == state)]
return httpx.Response(200, json=filtered)
# POST /repos/{owner}/{repo}/pulls
if method == "POST" and m:
owner, repo = m.groups()
self._pr_counter += 1
head_branch = payload["head"]
pr = {
"number": self._pr_counter,
"title": payload["title"],
"body": payload["body"],
"head": {"ref": head_branch, "sha": self.branches[(owner, repo)][head_branch]["sha"]},
"base": {"ref": payload["base"]},
"state": "open",
"merged": False,
"merged_at": None,
"closed_at": None,
"created_at": "2026-05-23T00:00:00Z",
"user": {"login": "rfc-bot"},
}
self.pulls[(owner, repo)].append(pr)
return httpx.Response(201, json=pr)
# POST /repos/{owner}/{repo}/pulls/{number}/merge
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls/(\d+)/merge", path)
if method == "POST" and m:
owner, repo, num = m.groups()
for pr in self.pulls[(owner, repo)]:
if pr["number"] == int(num):
head_branch = pr["head"]["ref"]
for (o, r, br, p), data in list(self.files.items()):
if (o, r, br) == (owner, repo, head_branch):
self.files[(owner, repo, "main", p)] = dict(data)
# Real Gitea: state becomes "closed" with merged=true.
pr["state"] = "closed"
pr["merged"] = True
pr["merged_at"] = "2026-05-23T01:00:00Z"
pr["closed_at"] = "2026-05-23T01:00:00Z"
new_sha = self._next_sha()
self.branches[(owner, repo)]["main"]["sha"] = new_sha
return httpx.Response(200, json={"merged": True})
return httpx.Response(404, json={"message": "not found"})
# GET /repos/{owner}/{repo}/hooks
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/hooks", path)
if method == "GET" and m:
return httpx.Response(200, json=[])
# PATCH /repos/{owner}/{repo}/issues/{number} — Gitea close path.
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/issues/(\d+)", path)
if method == "PATCH" and m:
owner, repo, num = m.groups()
for pr in self.pulls.get((owner, repo), []):
if pr["number"] == int(num) and payload.get("state") == "closed":
pr["state"] = "closed"
pr["closed_at"] = "2026-05-23T02:00:00Z"
return httpx.Response(200, json={"state": "closed"})
return httpx.Response(200, json={})
# POST /repos/{owner}/{repo}/issues/{number}/comments
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/issues/(\d+)/comments", path)
if method == "POST" and m:
return httpx.Response(201, json={"id": 1, "body": payload.get("body", "")})
return httpx.Response(404, json={"message": f"unmocked {method} {path}"})
# ---------------------------------------------------------------------------
# Session helpers — forge a SessionMiddleware cookie directly to skip OAuth.
# ---------------------------------------------------------------------------
def _sign_session(session_data: dict, secret: str) -> str:
from itsdangerous import TimestampSigner
data = base64.b64encode(json.dumps(session_data).encode("utf-8"))
signer = TimestampSigner(secret)
return signer.sign(data).decode("utf-8")
def sign_in_as(client, *, user_id, gitea_login, display_name, role, email=""):
payload = {
"user": {
"user_id": user_id,
"gitea_id": user_id,
"gitea_login": gitea_login,
"display_name": display_name,
"email": email,
"avatar_url": "",
"role": role,
}
}
cookie = _sign_session(payload, os.environ["SECRET_KEY"])
client.cookies.set("rfc_session", cookie)
def provision_user_row(*, user_id: int, login: str, role: str) -> None:
from app import db
db.conn().execute(
"""
INSERT OR REPLACE INTO users (id, gitea_id, gitea_login, email, display_name, avatar_url, role)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(user_id, user_id, login, f"{login}@test", login.capitalize(), "", role),
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_env(monkeypatch):
tmpdir = tempfile.mkdtemp(prefix="rfc-app-test-")
db_path = Path(tmpdir) / "test.db"
env = {
"GITEA_URL": "http://gitea.test",
"GITEA_BOT_USER": "rfc-bot",
"GITEA_BOT_TOKEN": "bot-token",
"GITEA_ORG": "wiggleverse",
"META_REPO": "meta",
"OAUTH_CLIENT_ID": "cid",
"OAUTH_CLIENT_SECRET": "csec",
"APP_URL": "http://localhost:8000",
"SECRET_KEY": "test-secret-key-for-cookies",
"DATABASE_PATH": str(db_path),
"OWNER_GITEA_LOGIN": "ben",
"GITEA_WEBHOOK_SECRET": "",
"ENABLED_MODELS": "claude",
}
for k, v in env.items():
monkeypatch.setenv(k, v)
yield env
@pytest.fixture
def app_with_fake_gitea(tmp_env, monkeypatch):
fake = FakeGitea()
real_client_cls = httpx.AsyncClient
def patched_client(*args, **kwargs):
kwargs["transport"] = httpx.MockTransport(fake.handle)
return real_client_cls(*args, **kwargs)
monkeypatch.setattr("app.gitea.httpx.AsyncClient", patched_client)
# The db module memoizes its connection — reset across tests so each
# test gets the tmpdir db its env points at, not a previous test's.
from app import db
if db._CONN is not None:
db._CONN.close()
db._CONN = None
from app.main import create_app
app = create_app()
return app, fake
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_propose_to_super_draft_vertical(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# The catalog is empty before anything happens.
r = client.get("/api/rfcs")
assert r.status_code == 200
assert r.json()["items"] == []
# A contributor proposes a new RFC.
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", "schema"],
})
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
assert r.json()["slug"] == "open-human-model"
# The proposal surfaces on the pending-ideas list.
r = client.get("/api/proposals")
items = r.json()["items"]
assert len(items) == 1
assert items[0]["slug"] == "open-human-model"
assert items[0]["pr_number"] == pr_number
# A contributor cannot merge.
r = client.post(f"/api/proposals/{pr_number}/merge")
assert r.status_code == 403
# Switch to the owner. The pending-idea view exposes the merge affordance.
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test")
r = client.get(f"/api/proposals/{pr_number}")
assert r.status_code == 200, r.text
proposal = r.json()
assert proposal["entry"]["title"] == "Open Human Model"
assert proposal["entry"]["state"] == "super-draft"
assert proposal["affordances"]["merge"] is True
# Owner merges. The catalog picks up the new super-draft.
r = client.post(f"/api/proposals/{pr_number}/merge")
assert r.status_code == 200, r.text
assert r.json()["slug"] == "open-human-model"
r = client.get("/api/rfcs")
items = r.json()["items"]
assert len(items) == 1
assert items[0]["slug"] == "open-human-model"
assert items[0]["state"] == "super-draft"
assert "identity" in items[0]["tags"]
# The super-draft view renders the body.
r = client.get("/api/rfcs/open-human-model")
assert r.status_code == 200
view = r.json()
assert view["state"] == "super-draft"
assert "shared definition" in view["body"]
# The pending-ideas list no longer carries the merged proposal.
r = client.get("/api/proposals")
assert r.json()["items"] == []
# The bot's actions are recorded in the audit log per §6.5.
actions = db.conn().execute(
"SELECT action_kind, on_behalf_of FROM actions ORDER BY id"
).fetchall()
kinds = [(a["action_kind"], a["on_behalf_of"]) for a in actions]
assert ("propose_rfc", "alice") in kinds
assert ("merge_proposal", "ben") in kinds
def test_slug_uniqueness_enforced(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=5, login="alice", role="contributor")
sign_in_as(client, user_id=5, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/propose", json={
"title": "First", "slug": "first", "pitch": "p", "tags": [],
})
assert r.status_code == 200, r.text
r = client.post("/api/rfcs/propose", json={
"title": "First Again", "slug": "first", "pitch": "p", "tags": [],
})
assert r.status_code == 409
def test_invalid_slug_rejected(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=7, login="alice", role="contributor")
sign_in_as(client, user_id=7, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/propose", json={
"title": "Bad slug", "slug": "Bad Slug!", "pitch": "p", "tags": [],
})
assert r.status_code == 422
def test_anonymous_cannot_propose(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/propose", json={
"title": "A", "slug": "a", "pitch": "p", "tags": [],
})
assert r.status_code == 401
def test_withdraw_by_proposer_works(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=9, login="alice", role="contributor")
provision_user_row(user_id=10, login="bob", role="contributor")
sign_in_as(client, user_id=9, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/propose", json={
"title": "X", "slug": "x", "pitch": "p", "tags": [],
})
pr_number = r.json()["pr_number"]
# A different contributor cannot withdraw someone else's proposal.
sign_in_as(client, user_id=10, gitea_login="bob", display_name="Bob", role="contributor")
r = client.post(f"/api/proposals/{pr_number}/withdraw")
assert r.status_code == 403
# The proposer can.
sign_in_as(client, user_id=9, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post(f"/api/proposals/{pr_number}/withdraw")
assert r.status_code == 200, r.text
r = client.get("/api/proposals")
assert r.json()["items"] == []
+153
View File
@@ -0,0 +1,153 @@
# Build notes
The slicing plan for the v1 build, the current state of the codebase,
and the next slice's brief.
## The slicing plan
Eight slices carry §§115 of [`SPEC.md`](../SPEC.md) end-to-end. The
build does not extend the spec; spec corrections during the build are
rare and surgical and live in the appropriate numbered section per
§19.3's working agreement.
1. **Repository scaffolding + propose-to-super-draft vertical.** The
chokepoint that every Git operation flows through (§1 bot wrapper),
the §4 cache machinery (webhook + reconciler), the §5 schema, Gitea
OAuth + user provisioning, the minimal §7 catalog, and one
end-to-end vertical: propose → idea PR → merge → super-draft view.
2. **The active-RFC view per §8 in full.** Editor, branch creation,
per-branch chat with AI participation (the §18 `<change>` protocol),
the change-card panel, accept/decline/edit, manual-edit flushes,
sub-threads, flags, DiffView.
3. **The PR flow per §10.** Open, review surface (diff + compressed
chat), the §10.3 seen-cursor, §10.4 review threads, merge,
post-merge, §10.9 conflict resolution.
4. **Super-draft body editing per §9.5 + §9.6.** Meta-repo edit
branches as the unit of work; everything from §8 inherits.
5. **Graduation per §13.** The dialog, the five-step transactional
sequence, rollback, the pre-graduation history affordance.
6. **Notifications per §15.** Last, because every other surface
produces signals the inbox receives — notification correctness
depends on the producers being in place first.
7. **The §14 chrome.** Landing page polish, the `/philosophy` route,
the persistent About link.
8. **Hardening.** End-to-end tests, dev/prod deployment shape,
the §12 30/90 branch-hygiene timers.
## State of the codebase
### Slice 1 — shipped
The repository scaffolding (`backend/`, `frontend/`, `scripts/`,
`docs/`), the §5 schema as numbered migrations under
`backend/migrations/`, the §1 bot wrapper (`app/bot.py`) that is the
single chokepoint every Git write flows through, Gitea OAuth and the
§6.1 user-provisioning row in `users`, the §4.1 webhook receiver and
the §4.1 periodic reconciler (both writing to the cache; user actions
never do), the §7 left pane (catalog list, search, sort, state-filter
chips, pending-ideas disclosure), and one end-to-end vertical: propose
→ idea PR opens → owner merges → super-draft appears in the catalog →
super-draft view renders the body.
The §17 endpoints exercised so far:
| Method | Path | § |
| ------ | -------------------------------------- | ------- |
| GET | `/api/auth/me` | §6 |
| GET | `/api/rfcs` | §7, §17 |
| GET | `/api/rfcs/{slug}` | §17 |
| GET | `/api/proposals` | §17 |
| GET | `/api/proposals/{pr_number}` | §17 |
| POST | `/api/rfcs/propose` | §9.1 |
| POST | `/api/proposals/{pr_number}/merge` | §9.3 |
| POST | `/api/proposals/{pr_number}/decline` | §9.3 |
| POST | `/api/proposals/{pr_number}/withdraw` | §9.3 |
| POST | `/api/webhooks/gitea` | §4.1 |
| GET | `/auth/login` / `/auth/callback` / `/auth/logout` | §18 |
### What's deferred from slice 1
These were on the §9.1 spec but pushed to Slice 2 because they belong
with surfaces that haven't been built yet:
- The propose modal's **AI-suggested tags** (§9.1) — the AI surface
lands with Slice 2's chat wiring. The tag chip input works manually
in the meantime.
- The propose modal's **AI-drafted PR description** (§9.2) — same
reason. The PR description is the pitch text for now.
- The decline ceremony's **two-step composer-then-preview dialog**
(§9.3) — the single-step required-comment input is in place; the
preview-and-confirm beat is the kind of UX polish that the §19.2
topic "pending-idea view's interaction design (remainder)" should
pick up alongside the merge-confirmation ceremony.
- The §9.3 **pre-merge chat thread on a pending-idea view** and the
migration of those threads to the super-draft on merge — depends
on Slice 2's chat infrastructure.
These are deferred in the build's working sense — surfaces exist in
the spec, but they share infrastructure that's wired in a later slice
and would otherwise have to be wired twice.
## Environment notes
- **Python 3.13.** Earlier 3.11+ should also work; 3.13 is what the
build session ran on.
- **Node 20+** for the frontend.
- **Local Gitea on port 3000.** Anything that exposes the Gitea v1
REST API works. If you tunnel Gitea elsewhere (e.g. a container,
a Codespace), re-run `scripts/seed_meta_repo.py` so the webhook
re-registers against the right `APP_URL`.
## Conventions
- **Bot writes only via `app/bot.py`.** If a module wants to call
`app/gitea.py`'s write methods directly, the spec is right and
the module is wrong — the wrapper is the chokepoint that makes
the §6.5 `On-behalf-of:` trailer and the §6 authorization both
consistent.
- **Cache writes only from `app/cache.py`.** User actions trigger
Git operations via the bot; the cache learns about them when the
webhook arrives (or the next reconciler sweep), and never before.
This invariant is what makes §4's "Git is truth" claim hold
operationally.
- **Spec corrections during the build are rare and surgical.** When
running code reveals the spec was wrong at a structural level (per
§19.3's working agreement), the correction lands in the appropriate
numbered section with a brief note explaining what running code
revealed. Spec extensions during the build are not in scope —
they accumulate in §19.2.
- **§16 stays deferred.** Body full-text search, per-RFC model
picker, funder role, persistent accepted-change markup, slug
renames — these are not shipped in any slice. They earn their own
topic sessions when use surfaces evidence they matter.
## Next slice
**Slice 2: the active-RFC view per §8.**
The active-RFC view inherits the three-column shape (§8.1), opens
on `main` in discuss mode by default (§8.2), supports the §8.3
discuss-vs-contribute mode flip on non-main branches, hosts §8.4's
per-branch chat with AI participation (§18's `<change>` protocol
parsing into `changes` rows per §8.6), the §8.8 change-card panel
with §8.9's accept / decline / edit-before-accept resolution, the
§8.10 tracked-change markup and DiffView toggle, the §8.11 manual-
edit flushes, the §8.12 range and paragraph sub-threads, the §8.13
flag affordance, and the §8.14 discuss-mode buffer.
The carryover assets that belong to Slice 2 are in the prototype
under `/Users/benstull/projects/wiggleverse/rfc-app-prototype/`:
- `frontend/src/components/Editor.jsx`, `ChatPanel.jsx`,
`ChangePanel.jsx`, `PromptBar.jsx`, `SelectionTooltip.jsx`,
`DiffView.jsx`, `ModelPicker.jsx` — Tiptap config, the
`<change>` parser, the selection-quote machinery, the
model-picker UX.
- `backend/providers.py`, `backend/chat.py` — the multi-provider
abstraction and the SSE-streaming chat layer.
These are §18 carryovers; reuse the working code rather than
rewriting. The prototype's *data model* and *permission shape* do
not carry; this codebase's `threads`, `thread_messages`, `changes`,
`changes.thread_id`, the §6 four-role model, and the per-branch
chat thread are the canonical shape for Slice 2 to wire against.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wiggleverse RFCs</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1707
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@tiptap/extension-placeholder": "^3.5.0",
"@tiptap/pm": "^3.5.0",
"@tiptap/react": "^3.5.0",
"@tiptap/starter-kit": "^3.5.0",
"marked": "^18.0.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.2.0"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"vite": "^8.0.12"
}
}
+319
View File
@@ -0,0 +1,319 @@
/* Adapted from the prototype's App.css per §18, narrowed to slice-1 surfaces. */
.boot {
display: flex; align-items: center; justify-content: center;
height: 100vh; color: #888; font-size: 14px;
}
.app { height: 100vh; display: flex; flex-direction: column; }
.app-header {
height: 48px; flex-shrink: 0;
background: #1a1a1a; color: #fff;
display: flex; align-items: center; justify-content: space-between;
padding: 0 20px;
}
.app-brand a { color: #fff; text-decoration: none; font-weight: 600; font-size: 14px; }
.header-right { display: flex; align-items: center; gap: 12px; font-size: 13px; }
.user-name { color: #ddd; }
.user-role-badge {
font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em;
padding: 2px 6px; border-radius: 4px;
background: rgba(255,255,255,0.15);
}
.role-owner { background: #b45309; }
.role-admin { background: #4338ca; }
.btn-link {
color: #fff; text-decoration: none;
background: rgba(255,255,255,0.15);
border-radius: 6px; padding: 4px 10px;
font-size: 13px;
}
.btn-link:hover { background: rgba(255,255,255,0.25); }
.app-body { flex: 1; display: flex; overflow: hidden; }
/* --- Catalog (left pane, §7) --- */
.catalog {
width: 320px; flex-shrink: 0;
background: #fff; border-right: 1px solid #e5e5e5;
display: flex; flex-direction: column;
overflow: hidden;
}
.catalog-search {
padding: 12px 14px;
border-bottom: 1px solid #f0f0ee;
}
.catalog-search input {
width: 100%; border: 1px solid #e5e5e5; border-radius: 6px;
padding: 6px 10px; font-size: 13px; outline: none;
}
.catalog-search input:focus { border-color: #1a1a1a; }
.catalog-controls {
display: flex; align-items: center; gap: 8px;
padding: 8px 14px;
border-bottom: 1px solid #f0f0ee;
font-size: 12px; color: #555;
}
.catalog-controls select {
border: 1px solid #e5e5e5; border-radius: 4px;
font-size: 12px; padding: 2px 4px;
}
.catalog-chips {
display: flex; flex-wrap: wrap; gap: 4px;
padding: 6px 14px 10px;
border-bottom: 1px solid #f0f0ee;
}
.chip {
font-size: 11px;
background: #f0f0ee; color: #444;
border: 1px solid transparent; border-radius: 999px;
padding: 2px 9px; cursor: pointer;
}
.chip.active { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
.catalog-list { flex: 1; overflow-y: auto; padding: 4px 0; }
.catalog-row {
display: flex; flex-direction: column;
padding: 8px 14px;
border: none; background: none; text-align: left; cursor: pointer;
border-left: 3px solid transparent; width: 100%;
}
.catalog-row:hover { background: #f7f7f5; }
.catalog-row.active { background: #f0f0ee; border-left-color: #1a1a1a; }
.catalog-row .row-top { display: flex; align-items: center; gap: 6px; }
.row-id {
font-size: 10px; font-weight: 700;
color: #888; text-transform: uppercase;
letter-spacing: 0.04em;
}
.row-id.super { color: #b45309; }
.row-title { font-size: 13px; margin-top: 2px; }
.catalog-row.is-super .row-title { color: #555; }
.row-tags { font-size: 11px; color: #999; margin-top: 2px; }
.catalog-pending {
border-top: 1px solid #f0f0ee;
flex-shrink: 0;
background: #fafaf8;
}
.pending-header {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 14px;
font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em;
color: #888;
cursor: pointer;
background: none; border: none; width: 100%; text-align: left;
}
.pending-count {
background: #e5e5e5; color: #333;
border-radius: 999px;
padding: 1px 8px; font-size: 11px;
}
.pending-list { padding: 2px 0 8px; }
.pending-row {
display: block;
padding: 6px 14px;
font-size: 13px; color: #444; text-decoration: none;
cursor: pointer; background: none; border: none; text-align: left; width: 100%;
}
.pending-row:hover { background: #fff; color: #1a1a1a; }
.pending-row.active { background: #fff; color: #1a1a1a; }
.pending-row .pending-by {
font-size: 11px; color: #999; margin-top: 1px;
}
.catalog-footer {
border-top: 1px solid #e5e5e5;
padding: 10px 14px;
flex-shrink: 0;
background: #fff;
}
.btn-propose {
width: 100%;
background: #1a1a1a; color: #fff;
border: none; border-radius: 6px;
padding: 8px 12px;
font-size: 13px; font-weight: 600;
cursor: pointer;
}
.btn-propose:hover { background: #333; }
.btn-propose:disabled { opacity: 0.5; cursor: default; }
/* --- Main pane --- */
.main-pane {
flex: 1; overflow-y: auto;
padding: 32px 48px;
}
.welcome { max-width: 640px; }
.welcome h1 { font-size: 22px; font-weight: 600; margin: 0 0 16px; }
.welcome p { line-height: 1.7; color: #444; }
/* --- RFC / Proposal view (read-only for slice 1) --- */
.entry-view { max-width: 720px; margin: 0 auto; }
.entry-state-banner {
font-size: 12px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.06em;
color: #b45309;
padding: 6px 10px;
background: #fffbeb; border: 1px solid #fde68a;
border-radius: 6px;
display: inline-block; margin-bottom: 16px;
}
.entry-state-banner.active { color: #166534; background: #f0fdf4; border-color: #bbf7d0; }
.entry-state-banner.declined { color: #991b1b; background: #fef2f2; border-color: #fecaca; }
.entry-state-banner.merged { color: #1e40af; background: #eff6ff; border-color: #bfdbfe; }
.entry-title { font-size: 26px; font-weight: 700; margin: 0 0 8px; }
.entry-meta { font-size: 12px; color: #999; margin-bottom: 24px; }
.entry-meta .entry-tag {
display: inline-block;
background: #f0f0ee; color: #555;
padding: 1px 8px; border-radius: 999px;
margin-right: 4px;
}
.entry-body {
font-size: 15px; line-height: 1.75; color: #1a1a1a;
}
.entry-body h1, .entry-body h2, .entry-body h3 { font-weight: 600; }
.entry-body h1 { font-size: 22px; margin-top: 24px; }
.entry-body h2 { font-size: 17px; margin-top: 20px; }
.entry-body h3 { font-size: 15px; margin-top: 16px; }
.entry-body p { margin: 0 0 12px; }
.entry-body ul, .entry-body ol { padding-left: 24px; }
.entry-body code { background: #f0f0ee; padding: 1px 5px; border-radius: 3px; font-size: 13px; }
.entry-actions {
display: flex; gap: 8px; margin: 16px 0 24px;
padding-bottom: 24px;
border-bottom: 1px solid #e5e5e5;
}
.btn-primary {
background: #166534; color: #fff;
border: none; border-radius: 6px;
padding: 7px 14px;
font-size: 13px; font-weight: 600;
cursor: pointer;
}
.btn-primary:hover { background: #14532d; }
.btn-primary:disabled { opacity: 0.5; cursor: default; }
.btn-secondary {
background: #fff; color: #1a1a1a;
border: 1px solid #d4d4d4; border-radius: 6px;
padding: 7px 14px;
font-size: 13px; font-weight: 600;
cursor: pointer;
}
.btn-secondary:hover { background: #f7f7f5; }
.btn-danger {
background: #fff; color: #991b1b;
border: 1px solid #fecaca; border-radius: 6px;
padding: 7px 14px;
font-size: 13px; font-weight: 600;
cursor: pointer;
}
.btn-danger:hover { background: #fef2f2; }
/* --- Modals --- */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(20, 20, 20, 0.4);
display: flex; align-items: center; justify-content: center;
z-index: 100;
}
.modal {
background: #fff;
border-radius: 10px;
box-shadow: 0 12px 40px rgba(0,0,0,0.15);
width: 560px; max-width: calc(100vw - 40px);
max-height: calc(100vh - 80px);
display: flex; flex-direction: column;
}
.modal-header {
padding: 18px 20px;
border-bottom: 1px solid #f0f0ee;
display: flex; align-items: center; justify-content: space-between;
}
.modal-header h2 { margin: 0; font-size: 17px; font-weight: 600; }
.modal-close {
background: none; border: none; font-size: 22px; cursor: pointer;
color: #999; line-height: 1; padding: 0;
}
.modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.modal-body label {
display: block;
font-size: 12px; font-weight: 600;
color: #555;
text-transform: uppercase; letter-spacing: 0.05em;
margin-bottom: 4px;
}
.modal-body input, .modal-body textarea {
width: 100%;
border: 1px solid #d4d4d4; border-radius: 6px;
padding: 8px 10px;
font-size: 14px; font-family: inherit;
outline: none; margin-bottom: 14px;
}
.modal-body input:focus, .modal-body textarea:focus { border-color: #1a1a1a; }
.modal-body textarea { resize: vertical; min-height: 100px; }
.field-help {
font-size: 12px; color: #999;
margin-top: -10px; margin-bottom: 14px;
}
.field-error {
font-size: 12px; color: #991b1b;
margin-top: -10px; margin-bottom: 14px;
}
.modal-actions {
border-top: 1px solid #f0f0ee;
padding: 14px 20px;
display: flex; justify-content: flex-end; gap: 10px;
}
/* --- Landing page (pre-login, §14.1) --- */
.landing {
height: 100vh;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 40px;
text-align: center;
}
.landing h1 { font-size: 28px; font-weight: 700; margin: 0 0 8px; }
.landing .subtitle { font-size: 16px; color: #666; margin: 0 0 28px; }
.landing .pitch {
max-width: 540px;
line-height: 1.7; color: #333;
font-size: 15px;
margin: 0 0 28px;
}
.landing .btn-signin {
background: #1a1a1a; color: #fff;
border: none; border-radius: 8px;
padding: 10px 20px;
font-size: 14px; font-weight: 600;
text-decoration: none;
}
.landing .btn-signin:hover { background: #333; }
.landing .secondary-link {
margin-top: 14px;
font-size: 13px;
color: #666; text-decoration: none;
}
.landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; }
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react'
import { Routes, Route, Link, useNavigate } from 'react-router-dom'
import { getMe } from './api'
import Catalog from './components/Catalog.jsx'
import RFCView from './components/RFCView.jsx'
import ProposalView from './components/ProposalView.jsx'
import ProposeModal from './components/ProposeModal.jsx'
import Landing from './components/Landing.jsx'
import './App.css'
export default function App() {
const [me, setMe] = useState(null)
const [loading, setLoading] = useState(true)
const [proposeOpen, setProposeOpen] = useState(false)
const [catalogVersion, setCatalogVersion] = useState(0)
const navigate = useNavigate()
useEffect(() => {
getMe()
.then(setMe)
.catch(() => setMe({ authenticated: false }))
.finally(() => setLoading(false))
}, [])
if (loading) {
return <div className="boot">Loading</div>
}
if (!me?.authenticated) {
return <Landing />
}
return (
<div className="app">
<header className="app-header">
<div className="app-brand">
<Link to="/">Wiggleverse RFCs</Link>
</div>
<div className="header-right">
<span className="user-name">{me.user.display_name}</span>
<span className={`user-role-badge role-${me.user.role}`}>{me.user.role}</span>
<a className="btn-link" href="/auth/logout">Sign out</a>
</div>
</header>
<div className="app-body">
<Catalog
onProposeRFC={() => setProposeOpen(true)}
version={catalogVersion}
/>
<main className="main-pane">
<Routes>
<Route path="/" element={<Welcome viewer={me.user} />} />
<Route path="/rfc/:slug" element={<RFCView />} />
<Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} />
</Routes>
</main>
</div>
{proposeOpen && (
<ProposeModal
onClose={() => setProposeOpen(false)}
onSubmitted={({ pr_number }) => {
setProposeOpen(false)
setCatalogVersion(v => v + 1)
navigate(`/proposals/${pr_number}`)
}}
/>
)}
</div>
)
}
function Welcome({ viewer }) {
return (
<div className="welcome">
<h1>Welcome, {viewer.display_name}.</h1>
<p>
The catalog on the left lists every super-draft and active RFC in the
framework. Open one to read the canonical body, or use{' '}
<strong>Propose New RFC</strong> at the bottom of the catalog to open an
idea PR against the meta repository.
</p>
<p>
Slice 1 of the build is in place: propose idea PR owner merges
super-draft appears in the catalog super-draft view renders. The
revision flow, per-branch chat, AI participation, and the PR surface
land in subsequent slices.
</p>
</div>
)
}
+70
View File
@@ -0,0 +1,70 @@
// api.js — every backend call lives here.
//
// All write requests pass {credentials: 'include'} implicitly because
// the dev proxy and the production deploy serve the API from the same
// origin as the frontend. If you split origins later, change here.
async function jsonOrThrow(res) {
if (!res.ok) {
let detail = ''
try {
const body = await res.json()
detail = body.detail || JSON.stringify(body)
} catch {
detail = await res.text()
}
const error = new Error(detail || `HTTP ${res.status}`)
error.status = res.status
throw error
}
return res.json()
}
export async function getMe() {
const res = await fetch('/api/auth/me')
return jsonOrThrow(res)
}
export async function listRFCs() {
return jsonOrThrow(await fetch('/api/rfcs'))
}
export async function getRFC(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}`))
}
export async function listProposals() {
return jsonOrThrow(await fetch('/api/proposals'))
}
export async function getProposal(prNumber) {
return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`))
}
export async function proposeRFC({ title, slug, pitch, tags }) {
const res = await fetch('/api/rfcs/propose', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, slug, pitch, tags: tags || [] }),
})
return jsonOrThrow(res)
}
export async function mergeProposal(prNumber) {
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
return jsonOrThrow(res)
}
export async function declineProposal(prNumber, comment) {
const res = await fetch(`/api/proposals/${prNumber}/decline`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ comment }),
})
return jsonOrThrow(res)
}
export async function withdrawProposal(prNumber) {
const res = await fetch(`/api/proposals/${prNumber}/withdraw`, { method: 'POST' })
return jsonOrThrow(res)
}
+155
View File
@@ -0,0 +1,155 @@
// Catalog.jsx the §7 left pane.
//
// One scrollable flat list of every super-draft and active entry,
// state-styled rather than grouped. Filter chips are AND-combined per
// §7.1; search is fuzzy over title + slug + id. The "Pending ideas"
// disclosure per §7.3 lives at the bottom, above the "+ Propose New RFC"
// button.
import { useEffect, useMemo, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { listRFCs, listProposals } from '../api'
const STATE_CHIPS = [
{ id: 'super-draft', label: 'Super-draft' },
{ id: 'active', label: 'Active' },
]
const SORT_OPTIONS = [
{ id: 'recent', label: 'Recently active' },
{ id: 'title', label: 'Title' },
{ id: 'id', label: 'ID' },
{ id: 'state', label: 'State' },
]
export default function Catalog({ onProposeRFC, version }) {
const [rfcs, setRfcs] = useState([])
const [proposals, setProposals] = useState([])
const [search, setSearch] = useState('')
const [sort, setSort] = useState('recent')
const [activeChips, setActiveChips] = useState(new Set())
const [pendingOpen, setPendingOpen] = useState(true)
const { slug, prNumber } = useParams()
useEffect(() => {
listRFCs().then(d => setRfcs(d.items)).catch(() => setRfcs([]))
listProposals().then(d => setProposals(d.items)).catch(() => setProposals([]))
}, [version])
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase()
let items = rfcs.filter(r => {
if (activeChips.size > 0 && !activeChips.has(r.state)) return false
if (!needle) return true
const hay = [r.title, r.slug, r.id || ''].join(' ').toLowerCase()
return hay.includes(needle)
})
items = [...items].sort((a, b) => {
// Starred items pin to the top of the current sort per §7.2.
if (a.starred_by_me !== b.starred_by_me) return a.starred_by_me ? -1 : 1
if (sort === 'title') return a.title.localeCompare(b.title)
if (sort === 'id') return (a.id || 'zzz').localeCompare(b.id || 'zzz')
if (sort === 'state') return a.state.localeCompare(b.state)
// recent
return (b.last_active_at || '').localeCompare(a.last_active_at || '')
})
return items
}, [rfcs, search, sort, activeChips])
function toggleChip(id) {
const next = new Set(activeChips)
next.has(id) ? next.delete(id) : next.add(id)
setActiveChips(next)
}
return (
<aside className="catalog">
<div className="catalog-search">
<input
placeholder="Search title, slug, ID…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="catalog-controls">
<label htmlFor="catalog-sort">Sort:</label>
<select id="catalog-sort" value={sort} onChange={e => setSort(e.target.value)}>
{SORT_OPTIONS.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
</div>
<div className="catalog-chips">
{STATE_CHIPS.map(chip => (
<button
key={chip.id}
className={`chip ${activeChips.has(chip.id) ? 'active' : ''}`}
onClick={() => toggleChip(chip.id)}
>
{chip.label}
</button>
))}
</div>
<div className="catalog-list">
{filtered.length === 0 ? (
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
{rfcs.length === 0
? 'No RFCs in the catalog yet. Propose one below.'
: 'No matches.'}
</div>
) : (
filtered.map(r => {
const isActive = slug === r.slug
const isSuper = r.state === 'super-draft'
return (
<Link
key={r.slug}
to={`/rfc/${r.slug}`}
className={`catalog-row ${isActive ? 'active' : ''} ${isSuper ? 'is-super' : ''}`}
>
<div className="row-top">
<span className={`row-id ${isSuper ? 'super' : ''}`}>
{isSuper ? 'super-draft' : (r.id || '—')}
</span>
</div>
<span className="row-title">{r.title}</span>
{r.tags.length > 0 && (
<span className="row-tags">{r.tags.join(' · ')}</span>
)}
</Link>
)
})
)}
</div>
<div className="catalog-pending">
<button className="pending-header" onClick={() => setPendingOpen(o => !o)}>
<span>{pendingOpen ? '▾' : '▸'} Pending ideas</span>
<span className="pending-count">{proposals.length}</span>
</button>
{pendingOpen && proposals.length > 0 && (
<div className="pending-list">
{proposals.map(p => (
<Link
key={p.pr_number}
to={`/proposals/${p.pr_number}`}
className={`pending-row ${String(prNumber) === String(p.pr_number) ? 'active' : ''}`}
>
<div>{p.title.replace(/^Propose:\s*/, '')}</div>
<div className="pending-by">by @{p.opened_by || '—'} · PR #{p.pr_number}</div>
</Link>
))}
</div>
)}
{pendingOpen && proposals.length === 0 && (
<div style={{ padding: '0 14px 12px', color: '#aaa', fontSize: 12 }}>
No pending proposals.
</div>
)}
</div>
<div className="catalog-footer">
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New RFC</button>
</div>
</aside>
)
}
+24
View File
@@ -0,0 +1,24 @@
// Landing.jsx §14.1's pre-login surface.
//
// Title, subtitle, the short-form pitch from PHILOSOPHY.md, then the
// single primary action: "Sign in with Gitea." The visual design is
// deferred per §14.4; the structural commitments are here.
export default function Landing() {
return (
<div className="landing">
<h1>Wiggleverse RFCs</h1>
<p className="subtitle">A standards process for shared meaning between humans and machines.</p>
<p className="pitch">
Large language models work brilliantly with programming languages because every
word in Python or C has a definitive meaning enforced by tooling. They struggle
with natural language because no such dictionary exists for words like
<em> consent</em>, <em> trait</em>, or <em> agency</em> words that do enormous
work in any system that interacts with humans. The Wiggleverse RFC framework is
a standardization process for that vocabulary. Build the dictionary first.
</p>
<a className="btn-signin" href="/auth/login">Sign in with Gitea</a>
<a className="secondary-link" href="/philosophy">Read the full philosophy </a>
</div>
)
}
+168
View File
@@ -0,0 +1,168 @@
// ProposalView.jsx §9.3 pending-idea view.
//
// Renders the proposed entry's body and frontmatter (read-only) with a
// status banner ("Pending idea awaiting review"). The header strip
// carries Merge / Decline / Withdraw per the viewer's affordances. The
// decline two-step composer-then-preview from §9.3 ships in Slice 1
// as a single-step required-comment input; the preview-confirm ceremony
// can land with the rest of §9.3's UX polish in the §19.2 "pending-idea
// view's interaction design (remainder)" topic.
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { marked } from 'marked'
import { getProposal, mergeProposal, declineProposal, withdrawProposal } from '../api'
export default function ProposalView({ viewer, onChange }) {
const { prNumber } = useParams()
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [acting, setActing] = useState(false)
const [declineOpen, setDeclineOpen] = useState(false)
const [declineComment, setDeclineComment] = useState('')
const navigate = useNavigate()
function refresh() {
setData(null); setError(null)
getProposal(prNumber).then(setData).catch(err => setError(err.message))
}
useEffect(refresh, [prNumber])
if (error) return <div className="entry-view"><p>Error: {error}</p></div>
if (!data) return <div className="entry-view">Loading</div>
const isOpen = data.state === 'open'
async function doMerge() {
setActing(true)
try {
const { slug } = await mergeProposal(prNumber)
onChange?.()
navigate(`/rfc/${slug}`)
} catch (e) {
setError(e.message)
setActing(false)
}
}
async function doDecline() {
if (!declineComment.trim()) return
setActing(true)
try {
await declineProposal(prNumber, declineComment.trim())
onChange?.()
refresh()
setDeclineOpen(false)
setDeclineComment('')
} catch (e) {
setError(e.message)
} finally {
setActing(false)
}
}
async function doWithdraw() {
if (!confirm('Withdraw this proposal? The PR will be closed; the conversation stays attached as historical record.')) return
setActing(true)
try {
await withdrawProposal(prNumber)
onChange?.()
refresh()
} catch (e) {
setError(e.message)
} finally {
setActing(false)
}
}
return (
<article className="entry-view">
<div className={`entry-state-banner ${data.state === 'merged' ? 'merged' : data.state === 'open' ? '' : 'declined'}`}>
{isOpen ? 'Pending idea — awaiting review'
: data.state === 'merged' ? 'Merged — now a super-draft'
: 'Closed'}
</div>
<h1 className="entry-title">
{data.entry?.title || data.title.replace(/^Propose:\s*/, '')}
</h1>
<div className="entry-meta">
<span>PR #{data.pr_number}</span>
{data.opened_by && <> · proposed by <strong>@{data.opened_by}</strong></>}
{data.opened_at && <> · {new Date(data.opened_at).toLocaleDateString()}</>}
{data.entry?.tags?.length > 0 && (
<div style={{ marginTop: 6 }}>
{data.entry.tags.map(t => <span key={t} className="entry-tag">{t}</span>)}
</div>
)}
</div>
{isOpen && (
<div className="entry-actions">
{data.affordances.merge && (
<button className="btn-primary" onClick={doMerge} disabled={acting}>
{acting ? 'Merging…' : 'Merge proposal'}
</button>
)}
{data.affordances.decline && (
<button className="btn-danger" onClick={() => setDeclineOpen(true)} disabled={acting}>
Decline
</button>
)}
{data.affordances.withdraw && (
<button className="btn-secondary" onClick={doWithdraw} disabled={acting}>
Withdraw proposal
</button>
)}
</div>
)}
{declineOpen && (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) setDeclineOpen(false) }}>
<div className="modal">
<div className="modal-header">
<h2>Decline proposal</h2>
<button className="modal-close" onClick={() => setDeclineOpen(false)}>×</button>
</div>
<div className="modal-body">
<label>Comment to the proposer (required)</label>
<textarea
value={declineComment}
onChange={e => setDeclineComment(e.target.value)}
rows={5}
placeholder="The proposer will read this verbatim."
autoFocus
/>
<p className="field-help">
Per §9.3, the decline ceremony's two-step preview-and-confirm
surface lands with the rest of §9.3 UX in Slice 2. For now the
comment goes directly to the PR and to the proposer's inbox.
</p>
</div>
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={() => setDeclineOpen(false)}>Cancel</button>
<button
type="button"
className="btn-danger"
onClick={doDecline}
disabled={!declineComment.trim() || acting}
>
{acting ? 'Declining…' : 'Send decline'}
</button>
</div>
</div>
</div>
)}
<h3 style={{ fontSize: 13, fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: 24 }}>
Proposed entry
</h3>
<div
className="entry-body"
dangerouslySetInnerHTML={{ __html: marked.parse(data.entry?.body || '') }}
/>
</article>
)
}
+150
View File
@@ -0,0 +1,150 @@
// ProposeModal.jsx §9.1.
//
// 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 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'
function slugify(title) {
return title
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
export default function ProposeModal({ onClose, onSubmitted }) {
const [title, setTitle] = useState('')
const [slug, setSlug] = useState('')
const [slugEdited, setSlugEdited] = useState(false)
const [pitch, setPitch] = useState('')
const [tagInput, setTagInput] = useState('')
const [tags, setTags] = useState([])
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
if (!slugEdited) setSlug(slugify(title))
}, [title, slugEdited])
function addTag() {
const t = tagInput.trim()
if (t && !tags.includes(t)) setTags([...tags, t])
setTagInput('')
}
async function handleSubmit(e) {
e.preventDefault()
if (!title.trim() || !slug || !pitch.trim()) return
setSubmitting(true)
setError(null)
try {
const result = await proposeRFC({
title: title.trim(),
slug,
pitch: pitch.trim(),
tags,
})
onSubmitted?.(result)
} catch (err) {
setError(err.message || 'Submission failed.')
} finally {
setSubmitting(false)
}
}
return (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal">
<div className="modal-header">
<h2>Propose a New RFC</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
<label htmlFor="propose-title">Title</label>
<input
id="propose-title"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="e.g. Open Human Model"
autoFocus
required
/>
<p className="field-help">The word or topic this RFC will define.</p>
<label htmlFor="propose-slug">Slug</label>
<input
id="propose-slug"
value={slug}
onChange={e => { setSlug(slugify(e.target.value)); setSlugEdited(true) }}
placeholder="open-human-model"
required
/>
<p className="field-help">Auto-derived from the title; edit if needed.</p>
<label htmlFor="propose-pitch">Why is this RFC needed?</label>
<textarea
id="propose-pitch"
value={pitch}
onChange={e => setPitch(e.target.value)}
placeholder="One or two paragraphs answering 'why this RFC is needed.'"
rows={5}
required
/>
<label htmlFor="propose-tag">Tags (optional)</label>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
<input
id="propose-tag"
value={tagInput}
onChange={e => setTagInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTag() }
}}
placeholder="identity, schema"
style={{ marginBottom: 0 }}
/>
<button type="button" className="btn-secondary" onClick={addTag} style={{ padding: '6px 12px' }}>
Add
</button>
</div>
{tags.length > 0 && (
<div style={{ marginTop: 4, marginBottom: 14 }}>
{tags.map(t => (
<span key={t} className="entry-tag" style={{ display: 'inline-block', marginRight: 4 }}>
{t}{' '}
<button
type="button"
onClick={() => setTags(tags.filter(x => x !== t))}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#999' }}
>×</button>
</span>
))}
</div>
)}
{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={!title.trim() || !slug || !pitch.trim() || submitting}
>
{submitting ? 'Opening PR…' : 'Open proposal PR'}
</button>
</div>
</form>
</div>
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
// RFCView.jsx §9.4 super-draft view (and a stub for active RFCs).
//
// Slice 1 ships read-only body rendering: the breadcrumb names the
// entry, the body renders via marked. The discuss-vs-contribute toggle,
// per-branch chat, change-card panel, and breadcrumb dropdown all land
// in Slice 2 per §8.
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { marked } from 'marked'
import { getRFC } from '../api'
export default function RFCView() {
const { slug } = useParams()
const [entry, setEntry] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
setEntry(null); setError(null)
getRFC(slug).then(setEntry).catch(err => setError(err.message))
}, [slug])
if (error) return <div className="entry-view"><p>Error: {error}</p></div>
if (!entry) return <div className="entry-view">Loading</div>
const stateClass = entry.state === 'active' ? 'active' : ''
return (
<article className="entry-view">
<div className={`entry-state-banner ${stateClass}`}>
{entry.state === 'super-draft' ? 'Super-draft' : (entry.id || 'Active')}
</div>
<h1 className="entry-title">{entry.title}</h1>
<div className="entry-meta">
<span>{entry.slug}</span>
{entry.proposed_by && <> · proposed by <strong>{entry.proposed_by}</strong></>}
{entry.proposed_at && <> · {entry.proposed_at}</>}
{entry.tags.length > 0 && (
<div style={{ marginTop: 6 }}>
{entry.tags.map(t => <span key={t} className="entry-tag">{t}</span>)}
</div>
)}
</div>
{entry.state === 'active' && (
<div className="entry-state-banner" style={{ background: '#fffbeb', borderColor: '#fde68a', color: '#92400e' }}>
The active-RFC view (editor, branches, chat) lands in Slice 2.
The body below is the canonical main-branch text.
</div>
)}
<div
className="entry-body"
dangerouslySetInnerHTML={{ __html: marked.parse(entry.body || '') }}
/>
</article>
)
}
+14
View File
@@ -0,0 +1,14 @@
:root {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
color: #1a1a1a;
background: #fafaf8;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body { overflow: hidden; }
a { color: inherit; }
button { font-family: inherit; }
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// In dev, the frontend runs on Vite's port and proxies the API
// (and /auth/*, /api/webhooks/*) to the FastAPI process.
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:8000',
'/auth': 'http://localhost:8000',
},
},
})
File diff suppressed because it is too large Load Diff
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Seed a fresh meta repository on a local Gitea instance.
Creates `<org>/<meta_repo>` if it does not exist, seeds it with the
hand-authored files §2 describes (README.md, PHILOSOPHY.md,
CONTRIBUTING.md, the workflow file, and an empty rfcs/ directory),
and registers the webhook the app needs per §4.1.
Run this once after standing up Gitea + the bot account + the .env.
Re-running is safe; everything is upsert-shaped.
Usage:
cd backend && .venv/bin/python ../scripts/seed_meta_repo.py
"""
from __future__ import annotations
import asyncio
import base64
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "backend"))
from app.config import load_config # noqa: E402
from app.gitea import Gitea, GiteaError # noqa: E402
PHILOSOPHY_PATH = Path(__file__).resolve().parent.parent / "PHILOSOPHY.md"
README_HEADER = """# Wiggleverse RFCs
*A standards process for shared meaning between humans and machines.*
This repository is the meta repo for the Wiggleverse RFC framework the
authoritative directory of every RFC in the system, super-drafts and
graduated entries alike. Every active or in-progress RFC has exactly one
markdown file in `rfcs/` describing it; once graduated, the RFC itself
lives in its own dedicated repository, linked from its entry here.
See [PHILOSOPHY.md](./PHILOSOPHY.md) for the full statement of why this
framework exists.
<!-- INDEX:START -->
*The index below is regenerated by CI on every merge to main.*
<!-- INDEX:END -->
"""
CONTRIBUTING = """# Contributing to the Wiggleverse RFC framework
All contribution flows through the RFC app, which talks to this Gitea
instance on your behalf via a single bot service account. Raw `git
clone` plus `git push` is not a supported contribution path.
To propose a new RFC, sign in at the app and use the **"+ Propose New
RFC"** affordance at the bottom of the catalog. Your proposal opens a
PR against this repository adding one file under `rfcs/`. An owner or
admin reviews and either merges (creating the super-draft) or declines
(with a comment to you).
See the app for the full revision, conversation, and graduation flows.
"""
ACTIONS_WORKFLOW = """# Regenerates README.md's index section on every merge to main.
# See §2 of SPEC.md. Implementation is a follow-up — this workflow
# file is present as a marker so the meta repo's shape matches the
# spec from day one.
name: regenerate-readme-index
on:
push:
branches: [main]
jobs:
noop:
runs-on: ubuntu-latest
steps:
- run: echo "Index regeneration is a follow-up (see SPEC §2)"
"""
async def main() -> None:
config = load_config()
gitea = Gitea(config)
try:
await ensure_repo(config, gitea)
await seed_files(config, gitea)
await ensure_webhook(config, gitea)
finally:
await gitea.close()
async def ensure_repo(config, gitea: Gitea) -> None:
existing = await gitea.get_repo(config.gitea_org, config.meta_repo)
if existing:
print(f"meta repo {config.meta_repo_full} already exists")
return
print(f"creating meta repo {config.meta_repo_full}")
# Create unauto-initialized so we control the initial commit.
await gitea._request( # noqa: SLF001 — bootstrap-only direct call
"POST",
f"/orgs/{config.gitea_org}/repos",
json={
"name": config.meta_repo,
"description": "Wiggleverse RFC framework — meta repository",
"private": False,
"auto_init": True,
"default_branch": "main",
},
)
async def seed_files(config, gitea: Gitea) -> None:
"""Write README.md, PHILOSOPHY.md, CONTRIBUTING.md, and the workflow.
Each file is created if missing, left alone if present re-running
the seed will not overwrite manual edits made post-bootstrap.
"""
files = {
"PHILOSOPHY.md": PHILOSOPHY_PATH.read_text() if PHILOSOPHY_PATH.exists() else "(placeholder)\n",
"README.md": README_HEADER,
"CONTRIBUTING.md": CONTRIBUTING,
".gitea/workflows/regenerate-readme-index.yml": ACTIONS_WORKFLOW,
"rfcs/.gitkeep": "",
}
for path, content in files.items():
existing = await gitea.get_contents(config.gitea_org, config.meta_repo, path, ref="main")
if existing:
print(f" {path} already present — skipping")
continue
print(f" seeding {path}")
await gitea.create_file(
config.gitea_org,
config.meta_repo,
path,
content=content,
message=f"Bootstrap: add {path}",
branch="main",
)
async def ensure_webhook(config, gitea: Gitea) -> None:
if not config.webhook_secret:
print("skipping webhook setup (GITEA_WEBHOOK_SECRET not set)")
return
url = f"{config.app_url}/api/webhooks/gitea"
print(f"ensuring webhook on {config.meta_repo_full} -> {url}")
await gitea.ensure_webhook(
config.gitea_org,
config.meta_repo,
url=url,
secret=config.webhook_secret,
events=["push", "pull_request", "create", "delete", "repository"],
)
if __name__ == "__main__":
asyncio.run(main())