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:
+25
@@ -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/
|
||||
@@ -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 §§1–15 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
@@ -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 human–machine 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.
|
||||
@@ -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.
|
||||
@@ -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=
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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
@@ -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 §§1–15 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.
|
||||
@@ -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>
|
||||
Generated
+1707
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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
Executable
+157
@@ -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())
|
||||
Reference in New Issue
Block a user