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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 04:31:11 -07:00
commit 779ba6db59
42 changed files with 10385 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
# --- Gitea ---
# Base URL of the Gitea instance the app speaks to.
GITEA_URL=http://localhost:3000
# The bot service account that performs every Git operation per §1.
# Provision a real Gitea user, generate a personal access token with
# repo and admin (or at minimum: repo, write:repository) scopes, and
# put the token here. The bot is the only Git writer.
GITEA_BOT_USER=rfc-bot
GITEA_BOT_TOKEN=
# The Gitea org or user that owns the meta repo and every RFC repo
# the bot will create on graduation.
GITEA_ORG=wiggleverse
META_REPO=meta
# --- OAuth (Gitea) ---
# In Gitea: Site Administration → Applications → Add OAuth2 Application.
# Redirect URI: {APP_URL}/auth/callback
OAUTH_CLIENT_ID=
OAUTH_CLIENT_SECRET=
# --- App ---
APP_URL=http://localhost:8000
SECRET_KEY=change-me-to-a-long-random-string
DATABASE_PATH=data/rfc-app.db
# Per §1: owner zero. The Gitea login that gets the owner role on
# first sign-in.
OWNER_GITEA_LOGIN=ben
# Webhook signature secret. Gitea sends X-Gitea-Signature as the
# HMAC-SHA256 of the body using this secret. Per §4.1 the webhook is
# one of two cache writers; signing keeps spurious writes out.
GITEA_WEBHOOK_SECRET=change-me-to-a-shared-secret
# --- LLM providers (carryover §18) ---
# Comma-separated list of provider keys to enable. Per the §19.2
# per-RFC-model topic, this is app-wide until that topic lands.
ENABLED_MODELS=claude
ANTHROPIC_API_KEY=
GOOGLE_API_KEY=
OPENAI_API_KEY=
View File
+396
View File
@@ -0,0 +1,396 @@
"""API surface for Slice 1.
Carries the §17 endpoints exercised by the propose-to-super-draft
vertical, plus the catalog read endpoints (§7) and the super-draft view
read endpoints (§9.4). The rest of §17 lands in the relevant later
slices; the dispatch shape here leaves room for them.
Routing follows the §17 layout literally — `/api/rfcs`,
`/api/proposals/<n>`, etc. — so the next slice can extend the same
modules rather than untangling a layout that drifted.
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import auth, db, entry as entry_mod, cache
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
class ProposeBody(BaseModel):
title: str = Field(min_length=1, max_length=200)
slug: str = Field(min_length=1, max_length=80)
pitch: str = Field(min_length=1)
tags: list[str] = Field(default_factory=list)
class DeclineBody(BaseModel):
comment: str = Field(min_length=1, max_length=4000)
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
router = APIRouter()
# ---------------------------------------------------------------
# Auth surface — extends the prototype's pattern but reads role
# from our users table per §6.
# ---------------------------------------------------------------
@router.get("/api/auth/me")
async def auth_me(request: Request) -> dict[str, Any]:
user = auth.current_user(request)
if user is None:
return {"authenticated": False, "user": None}
return {
"authenticated": True,
"user": {
"id": user.user_id,
"gitea_login": user.gitea_login,
"display_name": user.display_name,
"email": user.email,
"avatar_url": user.avatar_url,
"role": user.role,
},
}
# ---------------------------------------------------------------
# §7: the catalog
# ---------------------------------------------------------------
@router.get("/api/rfcs")
async def list_rfcs(request: Request) -> dict[str, Any]:
"""§7's left pane data.
The chip-filter / sort / search combinatorics live on the
client — the server returns the full set and lets the chips
narrow it. The set is small (hundreds, not thousands) for the
foreseeable future, so paginating here would buy nothing.
"""
viewer = auth.current_user(request)
viewer_id = viewer.user_id if viewer else None
rows = db.conn().execute(
"""
SELECT slug, title, state, rfc_id, repo,
owners_json, arbiters_json, tags_json,
last_main_commit_at, last_entry_commit_at, updated_at
FROM cached_rfcs
WHERE state IN ('super-draft', 'active')
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
"""
).fetchall()
starred = set()
if viewer_id is not None:
starred = {
r["rfc_slug"]
for r in db.conn().execute(
"SELECT rfc_slug FROM stars WHERE user_id = ?", (viewer_id,)
)
}
items = []
for r in rows:
items.append(
{
"slug": r["slug"],
"title": r["title"],
"state": r["state"],
"id": r["rfc_id"],
"repo": r["repo"],
"owners": json.loads(r["owners_json"] or "[]"),
"arbiters": json.loads(r["arbiters_json"] or "[]"),
"tags": json.loads(r["tags_json"] or "[]"),
"last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"],
"starred_by_me": r["slug"] in starred,
"has_open_prs": False, # wired in Slice 2 when per-RFC repos exist
}
)
return {"items": items}
@router.get("/api/rfcs/{slug}")
async def get_rfc(slug: str) -> dict[str, Any]:
row = db.conn().execute(
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if row is None:
raise HTTPException(404, "Not found")
return _serialize_rfc(row)
# ---------------------------------------------------------------
# §7.3 / §9.3: pending ideas
# ---------------------------------------------------------------
@router.get("/api/proposals")
async def list_proposals() -> dict[str, Any]:
rows = db.conn().execute(
"""
SELECT rfc_slug, pr_number, title, description, opened_by, opened_at, state
FROM cached_prs
WHERE pr_kind = 'idea' AND state = 'open'
ORDER BY opened_at DESC
"""
).fetchall()
return {
"items": [
{
"slug": r["rfc_slug"],
"pr_number": r["pr_number"],
"title": r["title"],
"description": r["description"],
"opened_by": r["opened_by"],
"opened_at": r["opened_at"],
}
for r in rows
]
}
@router.get("/api/proposals/{pr_number}")
async def get_proposal(pr_number: int, request: Request) -> dict[str, Any]:
"""§9.3 pending-idea view data.
Reads the proposed file from the proposer's branch on the meta
repo, so the viewer sees the entry as it will land. The chat
thread is not yet implemented; thread_id surfaces as null until
Slice 2's chat wiring lands.
"""
row = db.conn().execute(
"""
SELECT * FROM cached_prs
WHERE pr_kind = 'idea' AND pr_number = ?
""",
(pr_number,),
).fetchone()
if row is None:
raise HTTPException(404, "Not a proposal PR")
# Read the proposed entry file from the head branch.
slug = row["rfc_slug"]
head = row["head_branch"]
result = await gitea.read_file(config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref=head)
entry_payload: dict[str, Any] | None = None
if result:
text, _sha = result
try:
entry = entry_mod.parse(text)
entry_payload = _entry_payload(entry)
except Exception:
entry_payload = None
viewer = auth.current_user(request)
affordances = _proposal_affordances(viewer, row)
return {
"slug": slug,
"pr_number": pr_number,
"title": row["title"],
"description": row["description"],
"state": row["state"],
"opened_by": row["opened_by"],
"opened_at": row["opened_at"],
"entry": entry_payload,
"affordances": affordances,
}
# ---------------------------------------------------------------
# §9.1: propose a new RFC
# ---------------------------------------------------------------
@router.post("/api/rfcs/propose")
async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
slug = payload.slug.strip().lower()
if not entry_mod.is_valid_slug(slug):
raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes")
# §9.1 uniqueness — against rfcs/ on main *and* against open idea PRs.
# We re-check atomically here even though the client also checks
# on every keystroke, since a concurrent submission could land
# between dialog-open and submit.
clash = db.conn().execute(
"SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if clash:
raise HTTPException(409, f"Slug `{slug}` is already taken")
idea_clash = db.conn().execute(
"SELECT 1 FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' AND rfc_slug = ?",
(slug,),
).fetchone()
if idea_clash:
raise HTTPException(409, f"Slug `{slug}` is already reserved by an open proposal")
entry = entry_mod.Entry(
slug=slug,
title=payload.title.strip(),
state="super-draft",
id=None,
repo=None,
proposed_by=user.email or user.gitea_login,
proposed_at=entry_mod.today(),
graduated_at=None,
graduated_by=None,
owners=[],
arbiters=[],
tags=[t.strip() for t in payload.tags if t.strip()],
body=payload.pitch.strip() + "\n",
)
contents = entry_mod.serialize(entry)
pr_title = f"Propose: {entry.title}"
# Slice 1's AI-drafted PR description is deferred — the v1 spec
# calls for it (§9.2) but the wiring belongs with the rest of
# the AI-on-the-propose-modal work; for now we send the pitch.
pr_description = (
f"**Topic:** {entry.title}\n\n"
f"{payload.pitch.strip()}"
)
try:
pr = await bot.open_idea_pr(
user.as_actor(),
org=config.gitea_org,
meta_repo=config.meta_repo,
slug=slug,
file_contents=contents,
pr_title=pr_title,
pr_description=pr_description,
)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
# Refresh the meta-PRs cache so the proposer sees the new entry
# on the pending-ideas disclosure immediately, without waiting
# for the webhook to arrive. (The webhook will arrive too; the
# cache write is idempotent.)
await cache.refresh_meta_pulls(config, gitea)
return {"pr_number": pr["number"], "slug": slug}
# ---------------------------------------------------------------
# §9.3: merge / decline / withdraw an idea PR
# ---------------------------------------------------------------
@router.post("/api/proposals/{pr_number}/merge")
async def merge_proposal(pr_number: int, request: Request) -> dict[str, Any]:
user = auth.require_admin(request)
row = _require_open_idea_pr(pr_number)
try:
await bot.merge_idea_pr(
user.as_actor(),
org=config.gitea_org,
meta_repo=config.meta_repo,
pr_number=pr_number,
slug=row["rfc_slug"],
)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
# Refresh both surfaces — the entry is now on main, and the PR
# is now closed.
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
return {"ok": True, "slug": row["rfc_slug"]}
@router.post("/api/proposals/{pr_number}/decline")
async def decline_proposal(pr_number: int, body: DeclineBody, request: Request) -> dict[str, Any]:
user = auth.require_admin(request)
row = _require_open_idea_pr(pr_number)
try:
await bot.decline_idea_pr(
user.as_actor(),
org=config.gitea_org,
meta_repo=config.meta_repo,
pr_number=pr_number,
slug=row["rfc_slug"],
comment=body.comment,
)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
await cache.refresh_meta_pulls(config, gitea)
return {"ok": True}
@router.post("/api/proposals/{pr_number}/withdraw")
async def withdraw_proposal(pr_number: int, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
row = _require_open_idea_pr(pr_number)
# Only the proposer can withdraw their own proposal, except that
# owner/admin can also act (they have all contributor powers per
# §6.1, and the withdraw path here doesn't expose decline-only
# affordances).
if row["opened_by"] != user.gitea_login and user.role not in ("owner", "admin"):
raise HTTPException(403, "Only the proposer can withdraw")
try:
await bot.withdraw_idea_pr(
user.as_actor(),
org=config.gitea_org,
meta_repo=config.meta_repo,
pr_number=pr_number,
slug=row["rfc_slug"],
)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
await cache.refresh_meta_pulls(config, gitea)
return {"ok": True}
# ---------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------
def _require_open_idea_pr(pr_number: int):
row = db.conn().execute(
"""
SELECT * FROM cached_prs
WHERE pr_kind = 'idea' AND pr_number = ? AND state = 'open'
""",
(pr_number,),
).fetchone()
if row is None:
raise HTTPException(404, "Not an open proposal PR")
return row
return router
def _serialize_rfc(row) -> dict[str, Any]:
return {
"slug": row["slug"],
"title": row["title"],
"state": row["state"],
"id": row["rfc_id"],
"repo": row["repo"],
"proposed_by": row["proposed_by"],
"proposed_at": row["proposed_at"],
"graduated_at": row["graduated_at"],
"graduated_by": row["graduated_by"],
"owners": json.loads(row["owners_json"] or "[]"),
"arbiters": json.loads(row["arbiters_json"] or "[]"),
"tags": json.loads(row["tags_json"] or "[]"),
"body": row["body"] or "",
}
def _entry_payload(entry: entry_mod.Entry) -> dict[str, Any]:
return {
"slug": entry.slug,
"title": entry.title,
"state": entry.state,
"id": entry.id,
"repo": entry.repo,
"proposed_by": entry.proposed_by,
"proposed_at": entry.proposed_at,
"owners": entry.owners,
"arbiters": entry.arbiters,
"tags": entry.tags,
"body": entry.body,
}
def _proposal_affordances(viewer, row) -> dict[str, bool]:
"""§9.3 header strip affordances by role."""
is_owner_admin = viewer is not None and viewer.role in ("owner", "admin")
is_proposer = viewer is not None and row["opened_by"] == viewer.gitea_login
return {
"merge": is_owner_admin,
"decline": is_owner_admin,
"withdraw": is_proposer or is_owner_admin,
}
+195
View File
@@ -0,0 +1,195 @@
"""Gitea OAuth and user provisioning.
OAuth identity is the basis for the app's user account per §18; the §6
authorization layer is built on top by reading from the users table. On
first sign-in we insert a row with role='contributor' (or 'owner' if the
gitea_login matches the configured OWNER_GITEA_LOGIN — bootstrapping for
owner zero per §6.1). On subsequent sign-ins we refresh the display name
and avatar from Gitea so a rename in Gitea propagates here.
"""
from __future__ import annotations
import secrets
from dataclasses import dataclass
from typing import Any
import httpx
from fastapi import HTTPException, Request
from . import db
from .bot import Actor
from .config import Config
@dataclass
class SessionUser:
user_id: int
gitea_id: int
gitea_login: str
display_name: str
email: str
avatar_url: str
role: str
def as_actor(self) -> Actor:
return Actor(
user_id=self.user_id,
gitea_login=self.gitea_login,
display_name=self.display_name,
email=self.email,
)
def authorization_url(config: Config, state: str) -> str:
return (
f"{config.gitea_url}/login/oauth/authorize"
f"?client_id={config.oauth_client_id}"
f"&redirect_uri={config.redirect_uri}"
f"&response_type=code"
f"&state={state}"
)
async def exchange_code(config: Config, code: str) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
f"{config.gitea_url}/login/oauth/access_token",
json={
"client_id": config.oauth_client_id,
"client_secret": config.oauth_client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": config.redirect_uri,
},
headers={"Accept": "application/json"},
)
resp.raise_for_status()
return resp.json()
async def fetch_user_profile(config: Config, access_token: str) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.get(
f"{config.gitea_url}/api/v1/user",
headers={"Authorization": f"token {access_token}"},
)
resp.raise_for_status()
return resp.json()
def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
"""Insert or update the users row for this Gitea profile.
Owner zero (§6.1) is whoever's gitea_login matches OWNER_GITEA_LOGIN.
The owner role is granted on first sign-in and never revoked from a
later config change — once owner, always owner until an explicit
role transition (which lives in §6.1 and isn't part of slice 1).
"""
gitea_id = profile["id"]
login = profile["login"]
display = profile.get("full_name") or login
email = profile.get("email") or ""
avatar = profile.get("avatar_url") or ""
c = db.conn()
existing = c.execute("SELECT * FROM users WHERE gitea_id = ?", (gitea_id,)).fetchone()
if existing is None:
role = "owner" if config.owner_gitea_login and login == config.owner_gitea_login else "contributor"
cur = c.execute(
"""
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role)
VALUES (?, ?, ?, ?, ?, ?)
""",
(gitea_id, login, email, display, avatar, role),
)
user_id = cur.lastrowid
else:
user_id = existing["id"]
role = existing["role"]
c.execute(
"""
UPDATE users
SET gitea_login = ?, email = ?, display_name = ?, avatar_url = ?, last_seen_at = datetime('now')
WHERE id = ?
""",
(login, email, display, avatar, user_id),
)
return SessionUser(
user_id=user_id,
gitea_id=gitea_id,
gitea_login=login,
display_name=display,
email=email,
avatar_url=avatar,
role=role,
)
# ----- Session helpers -----
SESSION_USER_KEY = "user"
SESSION_STATE_KEY = "oauth_state"
def store_session(request: Request, user: SessionUser) -> None:
request.session[SESSION_USER_KEY] = {
"user_id": user.user_id,
"gitea_id": user.gitea_id,
"gitea_login": user.gitea_login,
"display_name": user.display_name,
"email": user.email,
"avatar_url": user.avatar_url,
"role": user.role,
}
def current_user(request: Request) -> SessionUser | None:
raw = request.session.get(SESSION_USER_KEY)
if not raw:
return None
# Re-read the role from the database every request so role changes
# take effect on the next API call without forcing a logout.
row = db.conn().execute(
"SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role FROM users WHERE id = ?",
(raw["user_id"],),
).fetchone()
if row is None:
return None
return SessionUser(
user_id=row["id"],
gitea_id=row["gitea_id"],
gitea_login=row["gitea_login"],
display_name=row["display_name"],
email=row["email"] or "",
avatar_url=row["avatar_url"] or "",
role=row["role"],
)
def require_user(request: Request) -> SessionUser:
user = current_user(request)
if user is None:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
def require_contributor(request: Request) -> SessionUser:
"""§6.1: authenticated, not write-muted."""
user = require_user(request)
row = db.conn().execute("SELECT muted FROM users WHERE id = ?", (user.user_id,)).fetchone()
if row and row["muted"]:
raise HTTPException(status_code=403, detail="Your account is muted")
return user
def require_admin(request: Request) -> SessionUser:
"""§6.1: owner or admin."""
user = require_user(request)
if user.role not in ("owner", "admin"):
raise HTTPException(status_code=403, detail="Admin or owner role required")
return user
def new_state() -> str:
return secrets.token_urlsafe(16)
+222
View File
@@ -0,0 +1,222 @@
"""The bot wrapper.
Per §1: the bot service account is the only Git writer in the system.
Per §6.5: every commit, branch creation, and PR merge carries an
On-behalf-of: trailer naming the acting user.
This module is the single chokepoint. Every write to Gitea — file
creation, branch creation, PR open, PR merge, PR close — flows through
a Bot method that takes an `actor` (the authenticated user whose gesture
produced the action) and an `action_kind` (one of the values recorded
in the `actions` table). The wrapper:
- calls the Gitea HTTP client with the bot's credentials,
- appends the trailer to commit/PR/comment bodies,
- records a row in `actions` so the app's accountability surface and
the Git log carry the same record.
If you find yourself wanting to import gitea.py directly to perform a
write, the spec is right and you are wrong: the wrapper is the
invariant. Read operations live in `gitea.py` and can be called from
anywhere.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from . import db
from .gitea import Gitea
@dataclass(frozen=True)
class Actor:
"""The user whose gesture is producing a write."""
user_id: int
gitea_login: str
display_name: str
email: str
def _trailer(actor: Actor) -> str:
return f"On-behalf-of: {actor.display_name} <{actor.gitea_login}>"
def _stamp(message_subject: str, message_body: str, actor: Actor) -> tuple[str, str]:
"""Compose subject + body with the On-behalf-of trailer appended.
Subject and body are returned separately because Gitea's merge API
takes them on distinct fields; for file commits we hand back a
single string in the caller.
"""
body = message_body.rstrip()
trailer = _trailer(actor)
if body:
return message_subject, f"{body}\n\n{trailer}"
return message_subject, trailer
def _stamp_single(message: str, actor: Actor) -> str:
subject, _, rest = message.partition("\n")
subject, body = _stamp(subject, rest.lstrip(), actor)
return f"{subject}\n\n{body}".rstrip()
def _log(
actor: Actor,
action_kind: str,
*,
rfc_slug: str | None = None,
branch_name: str | None = None,
pr_number: int | None = None,
bot_commit_sha: str | None = None,
details: dict | None = None,
) -> None:
db.conn().execute(
"""
INSERT INTO actions
(actor_user_id, on_behalf_of, action_kind, rfc_slug, branch_name, pr_number, bot_commit_sha, details)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
actor.user_id,
actor.gitea_login,
action_kind,
rfc_slug,
branch_name,
pr_number,
bot_commit_sha,
json.dumps(details) if details else None,
),
)
class Bot:
def __init__(self, gitea: Gitea):
self._gitea = gitea
# ----- Meta repo: idea PRs (§9.1 / §9.2) -----
async def open_idea_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
slug: str,
file_contents: str,
pr_title: str,
pr_description: str,
) -> dict:
"""Per §9.1: open a meta-repo PR adding one file under rfcs/.
One file per PR keeps idea submissions atomic and conflict-free.
The PR title and the file-add commit subject share §9.2's fixed
pattern; callers compose `pr_title` as `Propose: <Title>`.
"""
branch = f"propose/{slug}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
commit_subject = pr_title # §9.2: shared pattern
commit_message = _stamp_single(commit_subject, actor)
created = await self._gitea.create_file(
org,
meta_repo,
f"rfcs/{slug}.md",
content=file_contents,
message=commit_message,
branch=branch,
author_name=actor.display_name,
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
)
commit_sha = created.get("commit", {}).get("sha")
pr_body_subject, pr_body = _stamp("", pr_description, actor)
del pr_body_subject # only the body matters here
pr = await self._gitea.create_pull(
org,
meta_repo,
title=pr_title,
body=pr_body,
head=branch,
base="main",
)
_log(
actor,
"propose_rfc",
rfc_slug=slug,
branch_name=branch,
pr_number=pr["number"],
bot_commit_sha=commit_sha,
details={"pr_title": pr_title},
)
return pr
async def merge_idea_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
pr_number: int,
slug: str,
) -> None:
"""Per §9.3: owner/admin merges an idea PR, creating the super-draft."""
subject = f"Merge proposal: {slug}"
body = _trailer(actor)
await self._gitea.merge_pull(
org,
meta_repo,
pr_number,
merge_message_title=subject,
merge_message_body=body,
)
_log(
actor,
"merge_proposal",
rfc_slug=slug,
pr_number=pr_number,
)
async def decline_idea_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
pr_number: int,
slug: str,
comment: str,
) -> None:
"""Per §9.3: owner/admin declines an idea PR with a required comment.
The comment is posted to the PR (the durable Git artifact) and a
mirroring system-author thread_messages row is written by the
caller so the chat record carries the act inline.
"""
commented = comment.strip() or "(no comment provided)"
body = f"{commented}\n\n{_trailer(actor)}"
await self._gitea.create_issue_comment(org, meta_repo, pr_number, body)
await self._gitea.close_pull(org, meta_repo, pr_number)
_log(
actor,
"decline_proposal",
rfc_slug=slug,
pr_number=pr_number,
details={"comment": commented},
)
async def withdraw_idea_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
pr_number: int,
slug: str,
) -> None:
await self._gitea.close_pull(org, meta_repo, pr_number)
_log(
actor,
"withdraw_proposal",
rfc_slug=slug,
pr_number=pr_number,
)
+312
View File
@@ -0,0 +1,312 @@
"""The §4 metadata cache and its two writers.
Per §4: Gitea is truth. The cache mirrors only what the left pane and
the read surfaces need, and it is rebuildable from Gitea at any time.
Per §4.1: two writers — the webhook handler and the periodic reconciler —
both read from Gitea and write to the cache. User actions never write
to the cache directly; they trigger Git operations through the bot
(`bot.py`), and the resulting webhook (or the next reconciler sweep)
is what updates the cache.
This module provides:
- `refresh_meta_repo()` — reads rfcs/ on the meta repo and reconciles
cached_rfcs against what's there. Used by both the webhook handler
(on meta-repo merge events) and the reconciler.
- `refresh_meta_pulls()` — reads open meta-repo PRs and reconciles
cached_prs for pr_kind='idea' and friends. Backs the §7.3
pending-ideas disclosure.
Per §4.2's "single SQLite file colocated with the FastAPI process," the
cache writes happen on the same process that serves reads; lock
contention is bounded by the small mutation surface (a few hundred
rows at most for v1) and SQLite's WAL mode.
"""
from __future__ import annotations
import asyncio
import json
import logging
from . import db, entry as entry_mod
from .config import Config
from .gitea import Gitea, GiteaError
log = logging.getLogger(__name__)
async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
"""Re-read rfcs/ on the meta repo and reconcile cached_rfcs.
Idempotent. Safe to call on every meta-repo webhook and on every
reconciler sweep.
"""
org, repo = config.gitea_org, config.meta_repo
try:
files = await gitea.list_dir(org, repo, "rfcs", ref="main")
except GiteaError as e:
log.warning("refresh_meta_repo: cannot list rfcs/: %s", e)
return
seen_slugs: set[str] = set()
for f in files:
if f.get("type") != "file" or not f.get("name", "").endswith(".md"):
continue
result = await gitea.read_file(org, repo, f["path"], ref="main")
if not result:
continue
text, sha = result
try:
entry = entry_mod.parse(text)
except Exception as parse_err:
log.warning("refresh_meta_repo: skipping %s: %s", f["path"], parse_err)
continue
if not entry.slug:
log.warning("refresh_meta_repo: skipping %s: missing slug", f["path"])
continue
seen_slugs.add(entry.slug)
_upsert_cached_rfc(entry, body_sha=sha)
# Mark entries removed from the meta repo as withdrawn-without-trace.
# In practice the spec keeps withdrawn entries in rfcs/ as historical
# record (§3), so this branch fires only for entries deleted out of
# band. We leave the row but flag it for reconciler attention.
existing = {row["slug"] for row in db.conn().execute("SELECT slug FROM cached_rfcs")}
for missing in existing - seen_slugs:
log.info("refresh_meta_repo: %s no longer in rfcs/ — leaving cache row in place", missing)
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
db.conn().execute(
"""
INSERT INTO cached_rfcs
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
body, body_sha, last_entry_commit_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
ON CONFLICT(slug) DO UPDATE SET
title = excluded.title,
state = excluded.state,
rfc_id = excluded.rfc_id,
repo = excluded.repo,
proposed_by = excluded.proposed_by,
proposed_at = excluded.proposed_at,
graduated_at = excluded.graduated_at,
graduated_by = excluded.graduated_by,
owners_json = excluded.owners_json,
arbiters_json = excluded.arbiters_json,
tags_json = excluded.tags_json,
body = excluded.body,
body_sha = excluded.body_sha,
last_entry_commit_at = datetime('now'),
updated_at = datetime('now')
""",
(
entry.slug,
entry.title,
entry.state,
entry.id,
entry.repo,
entry.proposed_by,
entry.proposed_at,
entry.graduated_at,
entry.graduated_by,
json.dumps(entry.owners),
json.dumps(entry.arbiters),
json.dumps(entry.tags),
entry.body,
body_sha,
),
)
async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
"""Reconcile open meta-repo PRs into cached_prs.
For Slice 1 we care about pr_kind='idea' (proposing a new entry).
Other meta-repo PR kinds (body edits, metadata edits, claims) will
be wired in their respective slices.
`opened_by` is the **underlying actor**, not the bot login Gitea
reports — per §15.9's framing for notifications and per §6.5's
On-behalf-of accountability shape. We recover the actor by joining
against the `actions` audit log; if no row matches (cache rebuilt
from scratch on a deployment that pre-dates the actions log, or a
pull we did not author), we fall back to parsing the
`On-behalf-of:` trailer from the PR body, then to the raw Gitea
login as last resort.
"""
org, repo = config.gitea_org, config.meta_repo
repo_full = f"{org}/{repo}"
try:
open_pulls = await gitea.list_pulls(org, repo, state="open")
closed_pulls = await gitea.list_pulls(org, repo, state="closed")
except GiteaError as e:
log.warning("refresh_meta_pulls: %s", e)
return
bot_login = config.gitea_bot_user
for pull in open_pulls + closed_pulls:
head_branch = pull.get("head", {}).get("ref", "")
slug = _slug_from_head_branch(head_branch)
if slug is None:
continue
pr_kind = _kind_from_branch(head_branch)
state = _state_from_pull(pull)
gitea_opener = (pull.get("user") or {}).get("login") or ""
opened_by = _resolve_actor(
gitea_opener,
bot_login,
slug,
pull["number"],
pull.get("body") or "",
)
db.conn().execute(
"""
INSERT INTO cached_prs
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
opened_by, opened_at, merged_at, closed_at,
head_branch, base_branch, head_sha)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(repo, pr_number) DO UPDATE SET
title = excluded.title,
description = excluded.description,
state = excluded.state,
opened_by = excluded.opened_by,
merged_at = excluded.merged_at,
closed_at = excluded.closed_at,
head_sha = excluded.head_sha
""",
(
slug,
pr_kind,
repo_full,
pull["number"],
pull.get("title") or "",
pull.get("body") or "",
state,
opened_by,
pull.get("created_at"),
pull.get("merged_at"),
pull.get("closed_at"),
head_branch,
(pull.get("base") or {}).get("ref") or "main",
(pull.get("head") or {}).get("sha"),
),
)
_TRAILER_RE = None
def _resolve_actor(gitea_opener: str, bot_login: str, slug: str, pr_number: int, body: str) -> str:
"""Best effort: collapse the bot's authorship to the underlying actor."""
if gitea_opener and gitea_opener != bot_login:
return gitea_opener
# Prefer the audit log.
row = db.conn().execute(
"""
SELECT on_behalf_of FROM actions
WHERE action_kind IN ('propose_rfc', 'open_body_edit_pr', 'open_claim_pr', 'open_metadata_pr')
AND rfc_slug = ? AND pr_number = ?
ORDER BY id LIMIT 1
""",
(slug, pr_number),
).fetchone()
if row and row["on_behalf_of"]:
return row["on_behalf_of"]
# Fall back to parsing the On-behalf-of trailer.
import re as _re
global _TRAILER_RE
if _TRAILER_RE is None:
_TRAILER_RE = _re.compile(r"On-behalf-of:\s+.*?<([^>]+)>", _re.MULTILINE)
m = _TRAILER_RE.search(body)
if m:
return m.group(1)
return gitea_opener or bot_login
def _slug_from_head_branch(head_branch: str) -> str | None:
if head_branch.startswith("propose/"):
return head_branch[len("propose/") :]
if head_branch.startswith("edit/"):
parts = head_branch.split("/", 2)
if len(parts) >= 2:
return parts[1]
if head_branch.startswith("claim/"):
return head_branch[len("claim/") :]
if head_branch.startswith("metadata/"):
return head_branch[len("metadata/") :]
return None
def _kind_from_branch(head_branch: str) -> str:
if head_branch.startswith("propose/"):
return "idea"
if head_branch.startswith("edit/"):
return "meta_body_edit"
if head_branch.startswith("claim/"):
return "meta_claim"
if head_branch.startswith("metadata/"):
return "meta_metadata"
return "idea" # fallback
def _state_from_pull(pull: dict) -> str:
if pull.get("merged"):
return "merged"
if pull.get("state") == "closed":
return "closed"
return "open"
# ----- Reconciler -----
class Reconciler:
"""Per §4.1: periodic safety-net sweep.
Runs in the background, every five minutes by default. Catches up
on any webhook the bot missed (downtime, network failure, Gitea
flake). If the cache is corrupted, the reconciler rebuilds from
scratch — that's the contract.
"""
def __init__(self, config: Config, gitea: Gitea, interval_seconds: int = 300):
self._config = config
self._gitea = gitea
self._interval = interval_seconds
self._task: asyncio.Task | None = None
self._stop = asyncio.Event()
async def _loop(self) -> None:
# One sweep at startup, then on the interval. The startup sweep
# is what brings a fresh cache to life on first boot.
await self.sweep()
while not self._stop.is_set():
try:
await asyncio.wait_for(self._stop.wait(), timeout=self._interval)
except asyncio.TimeoutError:
pass
if self._stop.is_set():
break
await self.sweep()
async def sweep(self) -> None:
log.info("reconciler: starting sweep")
try:
await refresh_meta_repo(self._config, self._gitea)
await refresh_meta_pulls(self._config, self._gitea)
except Exception:
log.exception("reconciler: sweep failed")
else:
log.info("reconciler: sweep complete")
def start(self) -> None:
if self._task is None:
self._task = asyncio.create_task(self._loop())
async def stop(self) -> None:
self._stop.set()
if self._task is not None:
await self._task
self._task = None
+80
View File
@@ -0,0 +1,80 @@
"""Environment-derived configuration.
Loaded once at process start. Every module that needs a value pulls it from
here rather than re-reading os.environ, so there is one obvious place to
look when a setting is missing.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
def _required(name: str) -> str:
value = os.environ.get(name, "").strip()
if not value:
raise RuntimeError(f"Required environment variable {name} is not set")
return value
def _optional(name: str, default: str = "") -> str:
return os.environ.get(name, default).strip()
@dataclass
class Config:
gitea_url: str
gitea_bot_user: str
gitea_bot_token: str
gitea_org: str
meta_repo: str
oauth_client_id: str
oauth_client_secret: str
app_url: str
secret_key: str
database_path: Path
owner_gitea_login: str
webhook_secret: str
enabled_models: list[str] = field(default_factory=list)
anthropic_api_key: str = ""
google_api_key: str = ""
openai_api_key: str = ""
@property
def redirect_uri(self) -> str:
return f"{self.app_url}/auth/callback"
@property
def meta_repo_full(self) -> str:
return f"{self.gitea_org}/{self.meta_repo}"
def load_config() -> Config:
database_path = Path(_optional("DATABASE_PATH", "data/rfc-app.db")).resolve()
database_path.parent.mkdir(parents=True, exist_ok=True)
enabled = [m.strip() for m in _optional("ENABLED_MODELS", "claude").split(",") if m.strip()]
return Config(
gitea_url=_required("GITEA_URL").rstrip("/"),
gitea_bot_user=_required("GITEA_BOT_USER"),
gitea_bot_token=_required("GITEA_BOT_TOKEN"),
gitea_org=_required("GITEA_ORG"),
meta_repo=_optional("META_REPO", "meta"),
oauth_client_id=_required("OAUTH_CLIENT_ID"),
oauth_client_secret=_required("OAUTH_CLIENT_SECRET"),
app_url=_optional("APP_URL", "http://localhost:8000").rstrip("/"),
secret_key=_required("SECRET_KEY"),
database_path=database_path,
owner_gitea_login=_optional("OWNER_GITEA_LOGIN"),
webhook_secret=_optional("GITEA_WEBHOOK_SECRET"),
enabled_models=enabled,
anthropic_api_key=_optional("ANTHROPIC_API_KEY"),
google_api_key=_optional("GOOGLE_API_KEY"),
openai_api_key=_optional("OPENAI_API_KEY"),
)
+84
View File
@@ -0,0 +1,84 @@
"""SQLite connection and migration runner.
The schema lives in backend/migrations/ as numbered .sql files; this module
runs them in order against the configured database file and exposes a
connection factory that the rest of the app uses. WAL is enabled because
the SSE handlers and the reconciler can read while a webhook writes; FK
enforcement is on because §5's cascade rules depend on it.
Per §4.2, single SQLite file colocated with the FastAPI process. If we
outgrow this, the spec calls for a planned migration to Postgres on a
separate host — not for a clever sharding scheme bolted on here.
"""
from __future__ import annotations
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator
from .config import Config
MIGRATIONS_DIR = Path(__file__).resolve().parent.parent / "migrations"
def connect(path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(path, isolation_level=None, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA synchronous = NORMAL")
return conn
def run_migrations(config: Config) -> None:
conn = connect(config.database_path)
try:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)
"""
)
applied = {row["version"] for row in conn.execute("SELECT version FROM schema_migrations")}
for path in sorted(MIGRATIONS_DIR.glob("*.sql")):
version = path.stem
if version in applied:
continue
sql = path.read_text()
conn.executescript("BEGIN; " + sql + "; COMMIT;")
conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (version,))
finally:
conn.close()
_CONN: sqlite3.Connection | None = None
def init(config: Config) -> None:
"""Open the long-lived connection. Call once at startup, after migrations."""
global _CONN
if _CONN is not None:
return
_CONN = connect(config.database_path)
def conn() -> sqlite3.Connection:
if _CONN is None:
raise RuntimeError("db.init() has not been called")
return _CONN
@contextmanager
def tx() -> Iterator[sqlite3.Connection]:
"""Wrap a block in a transaction. Rolls back on exception."""
c = conn()
c.execute("BEGIN")
try:
yield c
c.execute("COMMIT")
except Exception:
c.execute("ROLLBACK")
raise
+102
View File
@@ -0,0 +1,102 @@
"""Meta-repo entry file shape per §2.1.
One markdown file per RFC under rfcs/<slug>.md, frontmatter on top, body
below. The frontmatter carries the canonical RFC state — id, repo,
owners, arbiters, graduation timestamps — and the body holds the pitch
(for super-drafts) or is empty (for graduated entries per §13.3 step 3).
This module contains the parser, the serializer, and a small validator
for the frontmatter shape. The parser is intentionally lenient about
unknown keys — future fields land in frontmatter without breaking older
readers.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import date
from typing import Any
import yaml
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.DOTALL)
SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
@dataclass
class Entry:
slug: str
title: str
state: str = "super-draft" # super-draft | active | withdrawn
id: str | None = None # 'RFC-NNNN' or None
repo: str | None = None
proposed_by: str = ""
proposed_at: str = "" # ISO date
graduated_at: str | None = None
graduated_by: str | None = None
owners: list[str] = field(default_factory=list)
arbiters: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
body: str = ""
def parse(text: str) -> Entry:
match = FRONTMATTER_RE.match(text)
if not match:
raise ValueError("Entry file missing frontmatter")
fm = yaml.safe_load(match.group(1)) or {}
body = match.group(2).lstrip("\n")
return Entry(
slug=str(fm.get("slug") or ""),
title=str(fm.get("title") or ""),
state=str(fm.get("state") or "super-draft"),
id=fm.get("id") or None,
repo=fm.get("repo") or None,
proposed_by=str(fm.get("proposed_by") or ""),
proposed_at=str(fm.get("proposed_at") or ""),
graduated_at=fm.get("graduated_at"),
graduated_by=fm.get("graduated_by"),
owners=list(fm.get("owners") or []),
arbiters=list(fm.get("arbiters") or []),
tags=list(fm.get("tags") or []),
body=body,
)
def serialize(entry: Entry) -> str:
"""Emit canonical entry file text — frontmatter then body."""
fm: dict[str, Any] = {
"slug": entry.slug,
"title": entry.title,
"state": entry.state,
"id": entry.id,
"repo": entry.repo,
"proposed_by": entry.proposed_by,
"proposed_at": entry.proposed_at,
"graduated_at": entry.graduated_at,
"graduated_by": entry.graduated_by,
"owners": entry.owners,
"arbiters": entry.arbiters,
"tags": entry.tags,
}
yaml_text = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False).rstrip()
body = entry.body.lstrip("\n")
if body:
return f"---\n{yaml_text}\n---\n\n{body}\n"
return f"---\n{yaml_text}\n---\n"
def slugify(title: str) -> str:
"""Deterministic kebab-case per §9.1."""
s = title.lower().strip()
s = re.sub(r"[^a-z0-9]+", "-", s)
return s.strip("-")
def today() -> str:
return date.today().isoformat()
def is_valid_slug(slug: str) -> bool:
return bool(SLUG_RE.match(slug)) and len(slug) <= 80
+286
View File
@@ -0,0 +1,286 @@
"""Thin HTTP client for the Gitea REST API.
Read operations live here and are called from any module that needs them
(reconciler, super-draft body fetch, webhook handler). Write operations
also live here but are not called directly from outside this module —
they are wrapped by `bot.py` per §1, so that every commit, branch, and
PR carries the §6.5 On-behalf-of trailer and a row in the actions log.
This split keeps the chokepoint legible: anything that wants to read
imports from here; anything that wants to write imports from `bot.py`
and never reaches around it.
"""
from __future__ import annotations
import base64
from typing import Any
import httpx
from .config import Config
class GiteaError(Exception):
def __init__(self, status: int, detail: str):
super().__init__(f"Gitea {status}: {detail}")
self.status = status
self.detail = detail
class Gitea:
def __init__(self, config: Config):
self._config = config
self._client = httpx.AsyncClient(
base_url=f"{config.gitea_url}/api/v1",
headers={
"Authorization": f"token {config.gitea_bot_token}",
"Accept": "application/json",
},
timeout=30.0,
)
async def close(self) -> None:
await self._client.aclose()
async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
resp = await self._client.request(method, path, **kwargs)
if resp.status_code >= 400:
try:
detail = resp.json().get("message", resp.text)
except Exception:
detail = resp.text
raise GiteaError(resp.status_code, detail)
return resp
# ----- Repo lifecycle -----
async def get_repo(self, owner: str, repo: str) -> dict | None:
try:
resp = await self._request("GET", f"/repos/{owner}/{repo}")
return resp.json()
except GiteaError as e:
if e.status == 404:
return None
raise
async def create_org_repo(self, org: str, name: str, *, description: str = "", private: bool = False) -> dict:
resp = await self._request(
"POST",
f"/orgs/{org}/repos",
json={
"name": name,
"description": description,
"private": private,
"auto_init": False,
},
)
return resp.json()
async def delete_repo(self, owner: str, repo: str) -> None:
await self._request("DELETE", f"/repos/{owner}/{repo}")
# ----- Branches -----
async def get_branch(self, owner: str, repo: str, branch: str) -> dict | None:
try:
resp = await self._request("GET", f"/repos/{owner}/{repo}/branches/{branch}")
return resp.json()
except GiteaError as e:
if e.status == 404:
return None
raise
async def list_branches(self, owner: str, repo: str) -> list[dict]:
resp = await self._request("GET", f"/repos/{owner}/{repo}/branches", params={"limit": 50})
return resp.json()
async def create_branch(self, owner: str, repo: str, new_branch: str, from_branch: str = "main") -> dict:
resp = await self._request(
"POST",
f"/repos/{owner}/{repo}/branches",
json={"new_branch_name": new_branch, "old_branch_name": from_branch},
)
return resp.json()
async def delete_branch(self, owner: str, repo: str, branch: str) -> None:
await self._request("DELETE", f"/repos/{owner}/{repo}/branches/{branch}")
# ----- File contents -----
async def get_contents(self, owner: str, repo: str, path: str, ref: str = "main") -> dict | None:
try:
resp = await self._request(
"GET",
f"/repos/{owner}/{repo}/contents/{path}",
params={"ref": ref},
)
return resp.json()
except GiteaError as e:
if e.status == 404:
return None
raise
async def list_dir(self, owner: str, repo: str, path: str = "", ref: str = "main") -> list[dict]:
try:
resp = await self._request(
"GET",
f"/repos/{owner}/{repo}/contents/{path}",
params={"ref": ref},
)
except GiteaError as e:
if e.status == 404:
return []
raise
data = resp.json()
return data if isinstance(data, list) else [data]
async def read_file(self, owner: str, repo: str, path: str, ref: str = "main") -> tuple[str, str] | None:
"""Return (content, sha) or None if the file is missing."""
item = await self.get_contents(owner, repo, path, ref)
if not item or item.get("type") != "file":
return None
return base64.b64decode(item["content"]).decode("utf-8"), item["sha"]
async def create_file(
self,
owner: str,
repo: str,
path: str,
*,
content: str,
message: str,
branch: str,
author_name: str | None = None,
author_email: str | None = None,
) -> dict:
body: dict[str, Any] = {
"message": message,
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
"branch": branch,
}
if author_name and author_email:
body["author"] = {"name": author_name, "email": author_email}
body["committer"] = {"name": author_name, "email": author_email}
resp = await self._request("POST", f"/repos/{owner}/{repo}/contents/{path}", json=body)
return resp.json()
async def update_file(
self,
owner: str,
repo: str,
path: str,
*,
content: str,
sha: str,
message: str,
branch: str,
author_name: str | None = None,
author_email: str | None = None,
) -> dict:
body: dict[str, Any] = {
"message": message,
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
"sha": sha,
"branch": branch,
}
if author_name and author_email:
body["author"] = {"name": author_name, "email": author_email}
body["committer"] = {"name": author_name, "email": author_email}
resp = await self._request("PUT", f"/repos/{owner}/{repo}/contents/{path}", json=body)
return resp.json()
# ----- Pull requests -----
async def list_pulls(self, owner: str, repo: str, state: str = "open") -> list[dict]:
resp = await self._request(
"GET",
f"/repos/{owner}/{repo}/pulls",
params={"state": state, "limit": 50},
)
return resp.json()
async def get_pull(self, owner: str, repo: str, number: int) -> dict | None:
try:
resp = await self._request("GET", f"/repos/{owner}/{repo}/pulls/{number}")
return resp.json()
except GiteaError as e:
if e.status == 404:
return None
raise
async def create_pull(
self,
owner: str,
repo: str,
*,
title: str,
body: str,
head: str,
base: str = "main",
) -> dict:
resp = await self._request(
"POST",
f"/repos/{owner}/{repo}/pulls",
json={"title": title, "body": body, "head": head, "base": base},
)
return resp.json()
async def merge_pull(
self,
owner: str,
repo: str,
number: int,
*,
merge_message_title: str,
merge_message_body: str,
style: str = "merge",
) -> None:
# Per §10.5: no-fast-forward merge commit. We pass Do='merge' so
# Gitea produces a merge commit rather than a fast-forward.
await self._request(
"POST",
f"/repos/{owner}/{repo}/pulls/{number}/merge",
json={
"Do": style,
"MergeTitleField": merge_message_title,
"MergeMessageField": merge_message_body,
},
)
async def close_pull(self, owner: str, repo: str, number: int) -> None:
await self._request(
"PATCH",
f"/repos/{owner}/{repo}/issues/{number}",
json={"state": "closed"},
)
async def create_issue_comment(self, owner: str, repo: str, number: int, body: str) -> dict:
resp = await self._request(
"POST",
f"/repos/{owner}/{repo}/issues/{number}/comments",
json={"body": body},
)
return resp.json()
# ----- Webhooks -----
async def ensure_webhook(self, owner: str, repo: str, *, url: str, secret: str, events: list[str]) -> dict:
existing = (await self._request("GET", f"/repos/{owner}/{repo}/hooks")).json()
for hook in existing:
if hook.get("config", {}).get("url") == url:
return hook
resp = await self._request(
"POST",
f"/repos/{owner}/{repo}/hooks",
json={
"type": "gitea",
"active": True,
"events": events,
"config": {
"url": url,
"content_type": "json",
"secret": secret,
},
},
)
return resp.json()
+102
View File
@@ -0,0 +1,102 @@
"""FastAPI entrypoint.
Wires the §17 routers, the OAuth callbacks, the webhook receiver, and
the background reconciler. Per §4.2, single process, colocated SQLite —
no need for a separate worker.
"""
from __future__ import annotations
import logging
import secrets
from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI, HTTPException, Request
from fastapi.responses import RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
from . import api as api_routes, auth, cache, db, webhooks
from .bot import Bot
from .config import load_config
from .gitea import Gitea
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
log = logging.getLogger("rfc_app")
@asynccontextmanager
async def lifespan(app: FastAPI):
config = load_config()
db.run_migrations(config)
db.init(config)
gitea = Gitea(config)
bot = Bot(gitea)
reconciler = cache.Reconciler(config, gitea)
app.state.config = config
app.state.gitea = gitea
app.state.bot = bot
app.state.reconciler = reconciler
app.include_router(_oauth_router(config))
app.include_router(api_routes.make_router(config, gitea, bot))
app.include_router(webhooks.make_router(config, gitea))
reconciler.start()
log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo)
try:
yield
finally:
await reconciler.stop()
await gitea.close()
def create_app() -> FastAPI:
# The secret key is required at app construction (SessionMiddleware
# is added before lifespan runs), so we read just that one value
# eagerly via load_config(). Everything else waits for lifespan.
config = load_config()
app = FastAPI(lifespan=lifespan)
app.add_middleware(
SessionMiddleware,
secret_key=config.secret_key,
session_cookie="rfc_session",
max_age=60 * 60 * 24 * 30,
https_only=False,
)
return app
app = create_app()
def _oauth_router(config) -> APIRouter:
router = APIRouter()
@router.get("/auth/login")
async def login(request: Request):
state = auth.new_state()
request.session[auth.SESSION_STATE_KEY] = state
return RedirectResponse(auth.authorization_url(config, state))
@router.get("/auth/callback")
async def callback(request: Request, code: str = "", state: str = ""):
if not code:
raise HTTPException(400, "Missing code")
stored_state = request.session.get(auth.SESSION_STATE_KEY)
if not stored_state or not secrets.compare_digest(stored_state, state):
raise HTTPException(400, "Invalid state")
token_data = await auth.exchange_code(config, code)
access_token = token_data.get("access_token")
if not access_token:
raise HTTPException(400, "Token exchange failed")
profile = await auth.fetch_user_profile(config, access_token)
user = auth.provision_user(config, profile)
auth.store_session(request, user)
return RedirectResponse("/")
@router.get("/auth/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/")
return router
+71
View File
@@ -0,0 +1,71 @@
"""Gitea webhook receiver per §4.1.
Both the webhook receiver and the reconciler are §4.1 cache writers.
On a meaningful event — meta-repo push or PR change — we re-read just
what changed from Gitea and update the cache. The signature is verified
against the configured shared secret so spurious POSTs cannot poison
the cache.
"""
from __future__ import annotations
import hashlib
import hmac
import logging
from fastapi import APIRouter, Header, HTTPException, Request
from . import cache
from .config import Config
from .gitea import Gitea
log = logging.getLogger(__name__)
EVENTS_OF_INTEREST = {
"push", # meta-repo or RFC-repo commits
"pull_request", # opened / closed / merged
"create", # branch or repo created
"delete", # branch deleted
"repository", # repo created or deleted
}
def make_router(config: Config, gitea: Gitea) -> APIRouter:
router = APIRouter()
@router.post("/api/webhooks/gitea")
async def receive(
request: Request,
x_gitea_event: str = Header(default=""),
x_gitea_signature: str = Header(default=""),
):
body = await request.body()
if config.webhook_secret:
if not _verify_signature(body, x_gitea_signature, config.webhook_secret):
raise HTTPException(status_code=401, detail="Invalid signature")
event = x_gitea_event.lower()
if event not in EVENTS_OF_INTEREST:
return {"ok": True, "ignored": event}
# Slice 1 only acts on meta-repo events; per-RFC-repo events
# land in their respective slices. The handler is generous in
# what it accepts — any meta-repo change is a cue to refresh
# the whole meta-repo cache, since the cache is small and the
# refresh is idempotent.
try:
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
except Exception:
log.exception("webhook refresh failed")
raise HTTPException(status_code=500, detail="Refresh failed")
return {"ok": True}
return router
def _verify_signature(body: bytes, header: str, secret: str) -> bool:
if not header:
return False
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
@@ -0,0 +1,65 @@
-- §5 / §6: users, permission events, and audit log.
--
-- The users table is the app-owned canonical account record. Per §6.1,
-- role is one of owner / admin / contributor; anonymous is the absence
-- of a row (or the absence of a session). The §6.2 app-wide write-mute
-- lives here as `muted`, structurally distinct from the §15.6 per-RFC
-- mute (on watches) and the §15.8 per-user mute (notification_user_mutes).
--
-- Per §15, the per-user notification preferences are inlined for
-- proximity. The watched-RFC-churn category has no column per §15.4 —
-- it is permanently off and surfaces in settings as a disabled toggle.
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
gitea_id INTEGER UNIQUE NOT NULL,
gitea_login TEXT UNIQUE NOT NULL,
email TEXT,
display_name TEXT NOT NULL,
avatar_url TEXT,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'contributor')),
muted INTEGER NOT NULL DEFAULT 0, -- §6.2 app-wide write-mute
email_personal_direct INTEGER NOT NULL DEFAULT 1, -- §15.4 default on
email_watched_structural INTEGER NOT NULL DEFAULT 0, -- §15.4 default off
email_admin_actionable INTEGER NOT NULL DEFAULT 1, -- §15.4 default on for admins/owners; ignored for contributors
digest_cadence TEXT NOT NULL DEFAULT 'weekly' CHECK (digest_cadence IN ('off', 'weekly', 'daily')), -- §15.5
notification_quiet_hours_start TEXT, -- §15.8 ISO-8601 local time HH:MM
notification_quiet_hours_end TEXT,
notification_quiet_hours_timezone TEXT, -- IANA tz name
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_users_role ON users (role);
-- §6.5: permission-change audit. Append-only. Every mute, role grant,
-- or capability override produces a row here.
CREATE TABLE permission_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
subject_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
event_kind TEXT NOT NULL, -- e.g. role_changed, muted, restored
details TEXT, -- JSON blob with before/after, reason, etc.
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_permission_events_subject ON permission_events (subject_user_id, created_at);
-- §5: append-only action log. Every state transition, every graduation,
-- every grant change. Includes the on-behalf-of trailer per §6.5 so the
-- audit log and the Git log carry the same accountability.
CREATE TABLE actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
on_behalf_of TEXT NOT NULL, -- the gitea_login the bot acted on behalf of
action_kind TEXT NOT NULL, -- propose_rfc, merge_proposal, graduate, etc.
rfc_slug TEXT,
branch_name TEXT,
pr_number INTEGER,
bot_commit_sha TEXT,
details TEXT, -- JSON blob with kind-specific extras
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_actions_rfc ON actions (rfc_slug, created_at);
CREATE INDEX idx_actions_actor ON actions (actor_user_id, created_at);
+82
View File
@@ -0,0 +1,82 @@
-- §4: the metadata cache. Reconstructible from Gitea at any time by the
-- §4.1 reconciler; never written from user actions, only from webhook
-- handlers and reconciler sweeps. Body content is cached for main-branch
-- reads (§4 #3); branch bodies are not.
--
-- These tables are not in §5's "canonical app tables" list because they
-- are cache, not truth — but they are required for the left-pane render
-- path and for serving super-draft and main-branch bodies without a
-- Gitea round-trip on every navigation.
-- One row per meta-repo rfcs/<slug>.md entry. Mirrors §2.1 frontmatter
-- plus the cached body for super-draft preview (graduated entries have
-- frontmatter-only bodies per §13.3 step 3, but the field is reused).
CREATE TABLE cached_rfcs (
slug TEXT PRIMARY KEY,
title TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn')),
rfc_id TEXT, -- 'RFC-NNNN' or NULL until graduated
repo TEXT, -- 'org/repo' or NULL until graduated
proposed_by TEXT, -- gitea login or email
proposed_at TEXT,
graduated_at TEXT,
graduated_by TEXT,
owners_json TEXT NOT NULL DEFAULT '[]',
arbiters_json TEXT NOT NULL DEFAULT '[]',
tags_json TEXT NOT NULL DEFAULT '[]',
body TEXT, -- super-draft body or main RFC.md body
body_sha TEXT, -- the commit sha the body was fetched at
last_main_commit_at TEXT, -- §7.1's "Recently active" sort
last_entry_commit_at TEXT, -- last meta-repo commit touching this entry
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state);
CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs (
COALESCE(last_main_commit_at, last_entry_commit_at) DESC
);
-- One row per branch the bot knows about on either a per-RFC repo
-- (rfc_slug, state='active'') or on the meta repo as a super-draft edit
-- branch (rfc_slug, state='super-draft', branch_name like 'edit/<slug>/...').
-- §11.5: closed branches stay; deleted branches keep their metadata row
-- per §12 ("branch removed from Gitea, row remains").
CREATE TABLE cached_branches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
head_sha TEXT,
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed', 'deleted')),
pinned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_commit_at TEXT,
closed_at TEXT,
UNIQUE (rfc_slug, branch_name)
);
CREATE INDEX idx_cached_branches_rfc ON cached_branches (rfc_slug, state);
-- One row per PR the bot knows about. Includes meta-repo idea PRs (rfc_slug
-- carries the proposed slug, see §5 super-draft scoping note) and meta-repo
-- body-edit PRs and per-RFC-repo PRs. The pr_kind disambiguates.
CREATE TABLE cached_prs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
pr_kind TEXT NOT NULL CHECK (pr_kind IN ('idea', 'meta_body_edit', 'meta_metadata', 'meta_claim', 'rfc_branch')),
repo TEXT NOT NULL, -- 'org/repo' the PR lives on
pr_number INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
state TEXT NOT NULL CHECK (state IN ('open', 'merged', 'closed', 'withdrawn')),
opened_by TEXT, -- gitea login (resolved from On-behalf-of trailer where present)
opened_at TEXT,
merged_at TEXT,
closed_at TEXT,
head_branch TEXT,
base_branch TEXT NOT NULL DEFAULT 'main',
head_sha TEXT,
UNIQUE (repo, pr_number)
);
CREATE INDEX idx_cached_prs_rfc ON cached_prs (rfc_slug, state);
CREATE INDEX idx_cached_prs_kind ON cached_prs (pr_kind, state);
@@ -0,0 +1,38 @@
-- §5 / §6.4 / §11.1: per-branch visibility and contribute settings.
-- These rows are app data, not cache. They describe what the app permits
-- for a given branch; the bot enforces them before acting. Absence of a
-- row means defaults: read_public=1, contribute_mode='just-me'.
CREATE TABLE branch_visibility (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
read_public INTEGER NOT NULL DEFAULT 1,
contribute_mode TEXT NOT NULL DEFAULT 'just-me' CHECK (contribute_mode IN ('just-me', 'specific', 'any-contributor')),
UNIQUE (rfc_slug, branch_name)
);
CREATE TABLE branch_contribute_grants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
grantee_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
granted_by INTEGER NOT NULL REFERENCES users(id) ON DELETE SET NULL,
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (rfc_slug, branch_name, grantee_user_id)
);
CREATE INDEX idx_grants_lookup ON branch_contribute_grants (rfc_slug, branch_name);
CREATE INDEX idx_grants_grantee ON branch_contribute_grants (grantee_user_id);
-- §5 / §7.2: starred RFCs pin to the top of the current sort order.
CREATE TABLE stars (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
starred_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, rfc_slug)
);
CREATE INDEX idx_stars_user ON stars (user_id);
CREATE INDEX idx_stars_rfc ON stars (rfc_slug);
@@ -0,0 +1,73 @@
-- §5: threads, thread_messages, changes — the conversation and revision
-- substrate.
--
-- Per the §5 super-draft scoping note, rows with rfc_slug pointing at a
-- super-draft entry use branch_name to name a meta-repo branch rather
-- than a per-RFC-repo branch. The schema is identical either way; the
-- interpretation flows from the entry's state in cached_rfcs.
--
-- Threads on a pending-idea PR (§9.3) carry the proposed slug as rfc_slug
-- pre-merge — slugs are reserved during the idea PR per §9.1's uniqueness
-- check — and surface under the super-draft on merge with no data movement.
CREATE TABLE threads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT, -- NULL = scoped to the RFC's main view
anchor_kind TEXT NOT NULL CHECK (anchor_kind IN ('whole-doc', 'range', 'paragraph')),
anchor_payload TEXT, -- JSON: ProseMirror range or paragraph id
thread_kind TEXT NOT NULL CHECK (thread_kind IN ('chat', 'flag', 'review')),
label TEXT, -- short summary, or full flag content
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'resolved', 'stale')),
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
resolved_at TEXT,
resolved_by INTEGER REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX idx_threads_scope ON threads (rfc_slug, branch_name, state);
CREATE INDEX idx_threads_kind ON threads (thread_kind, state);
-- §5: chat content. Only chat-kind threads have rows here unless a flag
-- has been converted to a chat (§8.13). System-author messages (role='system',
-- author_user_id=NULL) carry the §10.6 manual-edit-flush markers, the §9.3
-- decline-comment record, and similar system-narration entries.
CREATE TABLE thread_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
author_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
model_id TEXT, -- set when role='assistant'
text TEXT NOT NULL,
quote TEXT, -- optional selection the user attached
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_thread_messages_thread ON thread_messages (thread_id, created_at);
CREATE INDEX idx_thread_messages_author ON thread_messages (author_user_id, created_at);
-- §5 / §8.6 / §8.9 / §8.11: structured proposed edits. AI-proposed (parsed
-- from <change> blocks per the §18 carryover) or manually authored.
-- stale_since is orthogonal to state: a stale AI proposal stays 'pending'
-- until the contributor acts on the staleness warning per §8.11.
CREATE TABLE changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
thread_id INTEGER REFERENCES threads(id) ON DELETE SET NULL,
source_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
kind TEXT NOT NULL CHECK (kind IN ('ai', 'manual')),
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'accepted', 'declined')),
original TEXT NOT NULL,
proposed TEXT NOT NULL,
reason TEXT,
was_edited_before_accept INTEGER NOT NULL DEFAULT 0,
stale_since TEXT,
acted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
acted_at TEXT,
commit_sha TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_changes_scope ON changes (rfc_slug, branch_name, state);
CREATE INDEX idx_changes_thread ON changes (thread_id);
@@ -0,0 +1,45 @@
-- §5 / §10.3 / §15.6 / §15.7: the freshness cursors and the watch model.
--
-- Two cursor families per §15.7: per-event read state lives on notifications
-- (added in 006); per-scope freshness lives on pr_seen and branch_chat_seen.
-- They serve different jobs and are reconciled by the visit-advances-cursor
-- reconciler in §15.7.
CREATE TABLE pr_seen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
pr_number INTEGER NOT NULL,
last_seen_commit_sha TEXT,
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, rfc_slug, pr_number)
);
CREATE TABLE branch_chat_seen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, rfc_slug, branch_name)
);
-- §15.6: the watch model. Three states; auto-rules upgrade but never
-- downgrade; explicit settings exempt from the 90-day decay. Per-RFC
-- mute lives here as state='muted'.
CREATE TABLE watches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('watching', 'following', 'muted')),
set_by TEXT NOT NULL CHECK (set_by IN ('auto', 'explicit')),
set_at TEXT NOT NULL DEFAULT (datetime('now')),
last_participation_at TEXT, -- 90-day decay key per §15.6
UNIQUE (user_id, rfc_slug)
);
CREATE INDEX idx_watches_user ON watches (user_id);
CREATE INDEX idx_watches_rfc ON watches (rfc_slug);
CREATE INDEX idx_watches_decay ON watches (state, last_participation_at);
+57
View File
@@ -0,0 +1,57 @@
-- §5 / §15: the notification substrate. Per §15.7, per-row read_at is what
-- the inbox needs because triage is per-event. Per §15.9, system-generated
-- events carry actor_user_id = NULL; the bot account does not appear here.
--
-- Fan-out is at signal-generation time per §15.7: each recipient gets their
-- own row. This trades storage for query simplicity at the inbox surface,
-- and the §15.5 digest's exclusion rules need per-recipient timestamps
-- (email_sent_at, digest_included_at) anyway.
CREATE TABLE notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipient_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
event_kind TEXT NOT NULL, -- §15.1 enum, extensible
rfc_slug TEXT,
branch_name TEXT,
pr_number INTEGER,
thread_id INTEGER REFERENCES threads(id) ON DELETE SET NULL,
change_id INTEGER REFERENCES changes(id) ON DELETE SET NULL,
actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- NULL = system per §15.9
payload TEXT NOT NULL DEFAULT '{}', -- JSON: rendered row text + extras
created_at TEXT NOT NULL DEFAULT (datetime('now')),
read_at TEXT, -- §15.7 per-event triage
email_sent_at TEXT, -- §15.5 exclusion rule 1
digest_included_at TEXT -- §15.5 exclusion rule 3 audit
);
CREATE INDEX idx_notifications_inbox ON notifications (recipient_user_id, read_at, created_at);
CREATE INDEX idx_notifications_scope ON notifications (rfc_slug, branch_name, pr_number);
CREATE INDEX idx_notifications_digest ON notifications (recipient_user_id, digest_included_at);
-- §15.5: per-recipient digest emissions. The period_start / period_end
-- pair makes the event-window dedup queryable at audit time.
CREATE TABLE notification_digests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipient_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
sent_at TEXT NOT NULL DEFAULT (datetime('now')),
period_start TEXT NOT NULL,
period_end TEXT NOT NULL,
signal_ids_included TEXT NOT NULL DEFAULT '[]' -- JSON array of notification ids
);
CREATE INDEX idx_digests_recipient ON notification_digests (recipient_user_id, sent_at);
-- §15.8: per-user notification mute. Notification-volume only; never
-- gates content visibility. The §6.2 clarification reads: an admin or
-- arbiter exercising authority on an RFC cannot mute participants on
-- that RFC. Enforcement of the role-exemption is in the API layer; the
-- schema just stores the mute.
CREATE TABLE notification_user_mutes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
muter_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
muted_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
muted_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (muter_user_id, muted_user_id)
);
CREATE INDEX idx_user_mutes_muter ON notification_user_mutes (muter_user_id);
+10
View File
@@ -0,0 +1,10 @@
fastapi>=0.115
uvicorn[standard]>=0.32
httpx>=0.27
python-dotenv>=1.0
itsdangerous>=2.2
pydantic>=2.9
anthropic>=0.39
google-generativeai>=0.8
openai>=1.50
PyYAML>=6.0
+440
View File
@@ -0,0 +1,440 @@
"""End-to-end integration test for the Slice 1 vertical.
Stands up the FastAPI app against a mocked Gitea transport that
simulates the meta repo and the propose-to-merge lifecycle. The test
walks the same path a user would: sign in (a forged session cookie
substitutes for the OAuth round-trip, since OAuth itself is not in
scope to mock end-to-end), open a propose modal
(POST /api/rfcs/propose), exercise the bot wrapper through to the
Gitea HTTP layer, merge the PR as an owner, refresh the cache, and
verify the super-draft surfaces in GET /api/rfcs and
GET /api/rfcs/<slug>.
The mocked Gitea is intentionally narrow — it only honors the
endpoints the slice actually exercises. Adding routes to it as later
slices land is the right shape: the test surface tracks the production
surface.
"""
from __future__ import annotations
import base64
import json
import os
import re
import tempfile
from pathlib import Path
import httpx
import pytest
# ---------------------------------------------------------------------------
# Fake Gitea
# ---------------------------------------------------------------------------
class FakeGitea:
"""A narrow in-memory simulation of the Gitea API the slice uses."""
def __init__(self):
# files: (owner, repo, branch, path) -> {"content": str, "sha": str}
self.files: dict[tuple[str, str, str, str], dict] = {}
# branches: (owner, repo) -> {branch_name -> {"sha": str}}
self.branches: dict[tuple[str, str], dict[str, dict]] = {}
# pulls: (owner, repo) -> list[pull-dict]
self.pulls: dict[tuple[str, str], list[dict]] = {}
self._pr_counter = 0
self._commit_counter = 0
self._seed_repo("wiggleverse", "meta")
def _seed_repo(self, owner, repo):
self.branches[(owner, repo)] = {"main": {"sha": "initial"}}
self.pulls[(owner, repo)] = []
def _next_sha(self):
self._commit_counter += 1
return f"sha{self._commit_counter:04d}"
def handle(self, request: httpx.Request) -> httpx.Response:
path = request.url.path.replace("/api/v1", "", 1)
method = request.method
body = request.read().decode() if request.content else ""
payload = json.loads(body) if body else {}
# GET /repos/{owner}/{repo}
if method == "GET" and re.fullmatch(r"/repos/[^/]+/[^/]+", path):
return httpx.Response(200, json={"name": path.split("/")[-1]})
# GET /repos/{owner}/{repo}/branches/{branch}
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches/([^/]+)", path)
if method == "GET" and m:
owner, repo, branch = m.groups()
b = self.branches.get((owner, repo), {}).get(branch)
if not b:
return httpx.Response(404, json={"message": "not found"})
return httpx.Response(200, json={"name": branch, "commit": {"id": b["sha"]}})
# POST /repos/{owner}/{repo}/branches
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches", path)
if method == "POST" and m:
owner, repo = m.groups()
new = payload["new_branch_name"]
old = payload["old_branch_name"]
old_sha = self.branches[(owner, repo)][old]["sha"]
self.branches[(owner, repo)][new] = {"sha": old_sha}
# Copy main's files into the new branch
for (o, r, br, p), data in list(self.files.items()):
if (o, r, br) == (owner, repo, old):
self.files[(owner, repo, new, p)] = dict(data)
return httpx.Response(201, json={"name": new})
# GET /repos/{owner}/{repo}/contents/{path}?ref=...
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path)
if method == "GET" and m:
owner, repo, fpath = m.groups()
ref = request.url.params.get("ref", "main")
key = (owner, repo, ref, fpath)
if key in self.files:
f = self.files[key]
return httpx.Response(200, json={
"name": fpath.rsplit("/", 1)[-1],
"path": fpath,
"type": "file",
"sha": f["sha"],
"content": base64.b64encode(f["content"].encode()).decode(),
})
# Directory listing
prefix = fpath.rstrip("/") + "/"
children = []
for (o, r, br, p), data in self.files.items():
if (o, r, br) == (owner, repo, ref) and p.startswith(prefix) and "/" not in p[len(prefix):]:
children.append({
"name": p.rsplit("/", 1)[-1],
"path": p,
"type": "file",
"sha": data["sha"],
})
if children:
return httpx.Response(200, json=children)
return httpx.Response(404, json={"message": "not found"})
# POST /repos/{owner}/{repo}/contents/{path}
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path)
if method == "POST" and m:
owner, repo, fpath = m.groups()
branch = payload["branch"]
content = base64.b64decode(payload["content"]).decode()
sha = self._next_sha()
self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": sha}
self.branches[(owner, repo)][branch]["sha"] = sha
return httpx.Response(201, json={"commit": {"sha": sha}})
# GET /repos/{owner}/{repo}/pulls?state=...
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls", path)
if method == "GET" and m:
owner, repo = m.groups()
state = request.url.params.get("state", "open")
items = self.pulls.get((owner, repo), [])
filtered = [p for p in items if (state == "all") or (p["state"] == state)]
return httpx.Response(200, json=filtered)
# POST /repos/{owner}/{repo}/pulls
if method == "POST" and m:
owner, repo = m.groups()
self._pr_counter += 1
head_branch = payload["head"]
pr = {
"number": self._pr_counter,
"title": payload["title"],
"body": payload["body"],
"head": {"ref": head_branch, "sha": self.branches[(owner, repo)][head_branch]["sha"]},
"base": {"ref": payload["base"]},
"state": "open",
"merged": False,
"merged_at": None,
"closed_at": None,
"created_at": "2026-05-23T00:00:00Z",
"user": {"login": "rfc-bot"},
}
self.pulls[(owner, repo)].append(pr)
return httpx.Response(201, json=pr)
# POST /repos/{owner}/{repo}/pulls/{number}/merge
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls/(\d+)/merge", path)
if method == "POST" and m:
owner, repo, num = m.groups()
for pr in self.pulls[(owner, repo)]:
if pr["number"] == int(num):
head_branch = pr["head"]["ref"]
for (o, r, br, p), data in list(self.files.items()):
if (o, r, br) == (owner, repo, head_branch):
self.files[(owner, repo, "main", p)] = dict(data)
# Real Gitea: state becomes "closed" with merged=true.
pr["state"] = "closed"
pr["merged"] = True
pr["merged_at"] = "2026-05-23T01:00:00Z"
pr["closed_at"] = "2026-05-23T01:00:00Z"
new_sha = self._next_sha()
self.branches[(owner, repo)]["main"]["sha"] = new_sha
return httpx.Response(200, json={"merged": True})
return httpx.Response(404, json={"message": "not found"})
# GET /repos/{owner}/{repo}/hooks
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/hooks", path)
if method == "GET" and m:
return httpx.Response(200, json=[])
# PATCH /repos/{owner}/{repo}/issues/{number} — Gitea close path.
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/issues/(\d+)", path)
if method == "PATCH" and m:
owner, repo, num = m.groups()
for pr in self.pulls.get((owner, repo), []):
if pr["number"] == int(num) and payload.get("state") == "closed":
pr["state"] = "closed"
pr["closed_at"] = "2026-05-23T02:00:00Z"
return httpx.Response(200, json={"state": "closed"})
return httpx.Response(200, json={})
# POST /repos/{owner}/{repo}/issues/{number}/comments
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/issues/(\d+)/comments", path)
if method == "POST" and m:
return httpx.Response(201, json={"id": 1, "body": payload.get("body", "")})
return httpx.Response(404, json={"message": f"unmocked {method} {path}"})
# ---------------------------------------------------------------------------
# Session helpers — forge a SessionMiddleware cookie directly to skip OAuth.
# ---------------------------------------------------------------------------
def _sign_session(session_data: dict, secret: str) -> str:
from itsdangerous import TimestampSigner
data = base64.b64encode(json.dumps(session_data).encode("utf-8"))
signer = TimestampSigner(secret)
return signer.sign(data).decode("utf-8")
def sign_in_as(client, *, user_id, gitea_login, display_name, role, email=""):
payload = {
"user": {
"user_id": user_id,
"gitea_id": user_id,
"gitea_login": gitea_login,
"display_name": display_name,
"email": email,
"avatar_url": "",
"role": role,
}
}
cookie = _sign_session(payload, os.environ["SECRET_KEY"])
client.cookies.set("rfc_session", cookie)
def provision_user_row(*, user_id: int, login: str, role: str) -> None:
from app import db
db.conn().execute(
"""
INSERT OR REPLACE INTO users (id, gitea_id, gitea_login, email, display_name, avatar_url, role)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(user_id, user_id, login, f"{login}@test", login.capitalize(), "", role),
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_env(monkeypatch):
tmpdir = tempfile.mkdtemp(prefix="rfc-app-test-")
db_path = Path(tmpdir) / "test.db"
env = {
"GITEA_URL": "http://gitea.test",
"GITEA_BOT_USER": "rfc-bot",
"GITEA_BOT_TOKEN": "bot-token",
"GITEA_ORG": "wiggleverse",
"META_REPO": "meta",
"OAUTH_CLIENT_ID": "cid",
"OAUTH_CLIENT_SECRET": "csec",
"APP_URL": "http://localhost:8000",
"SECRET_KEY": "test-secret-key-for-cookies",
"DATABASE_PATH": str(db_path),
"OWNER_GITEA_LOGIN": "ben",
"GITEA_WEBHOOK_SECRET": "",
"ENABLED_MODELS": "claude",
}
for k, v in env.items():
monkeypatch.setenv(k, v)
yield env
@pytest.fixture
def app_with_fake_gitea(tmp_env, monkeypatch):
fake = FakeGitea()
real_client_cls = httpx.AsyncClient
def patched_client(*args, **kwargs):
kwargs["transport"] = httpx.MockTransport(fake.handle)
return real_client_cls(*args, **kwargs)
monkeypatch.setattr("app.gitea.httpx.AsyncClient", patched_client)
# The db module memoizes its connection — reset across tests so each
# test gets the tmpdir db its env points at, not a previous test's.
from app import db
if db._CONN is not None:
db._CONN.close()
db._CONN = None
from app.main import create_app
app = create_app()
return app, fake
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_propose_to_super_draft_vertical(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# The catalog is empty before anything happens.
r = client.get("/api/rfcs")
assert r.status_code == 200
assert r.json()["items"] == []
# A contributor proposes a new RFC.
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
r = client.post("/api/rfcs/propose", json={
"title": "Open Human Model",
"slug": "open-human-model",
"pitch": "A shared definition of what we mean by *human*.",
"tags": ["identity", "schema"],
})
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
assert r.json()["slug"] == "open-human-model"
# The proposal surfaces on the pending-ideas list.
r = client.get("/api/proposals")
items = r.json()["items"]
assert len(items) == 1
assert items[0]["slug"] == "open-human-model"
assert items[0]["pr_number"] == pr_number
# A contributor cannot merge.
r = client.post(f"/api/proposals/{pr_number}/merge")
assert r.status_code == 403
# Switch to the owner. The pending-idea view exposes the merge affordance.
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test")
r = client.get(f"/api/proposals/{pr_number}")
assert r.status_code == 200, r.text
proposal = r.json()
assert proposal["entry"]["title"] == "Open Human Model"
assert proposal["entry"]["state"] == "super-draft"
assert proposal["affordances"]["merge"] is True
# Owner merges. The catalog picks up the new super-draft.
r = client.post(f"/api/proposals/{pr_number}/merge")
assert r.status_code == 200, r.text
assert r.json()["slug"] == "open-human-model"
r = client.get("/api/rfcs")
items = r.json()["items"]
assert len(items) == 1
assert items[0]["slug"] == "open-human-model"
assert items[0]["state"] == "super-draft"
assert "identity" in items[0]["tags"]
# The super-draft view renders the body.
r = client.get("/api/rfcs/open-human-model")
assert r.status_code == 200
view = r.json()
assert view["state"] == "super-draft"
assert "shared definition" in view["body"]
# The pending-ideas list no longer carries the merged proposal.
r = client.get("/api/proposals")
assert r.json()["items"] == []
# The bot's actions are recorded in the audit log per §6.5.
actions = db.conn().execute(
"SELECT action_kind, on_behalf_of FROM actions ORDER BY id"
).fetchall()
kinds = [(a["action_kind"], a["on_behalf_of"]) for a in actions]
assert ("propose_rfc", "alice") in kinds
assert ("merge_proposal", "ben") in kinds
def test_slug_uniqueness_enforced(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=5, login="alice", role="contributor")
sign_in_as(client, user_id=5, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/propose", json={
"title": "First", "slug": "first", "pitch": "p", "tags": [],
})
assert r.status_code == 200, r.text
r = client.post("/api/rfcs/propose", json={
"title": "First Again", "slug": "first", "pitch": "p", "tags": [],
})
assert r.status_code == 409
def test_invalid_slug_rejected(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=7, login="alice", role="contributor")
sign_in_as(client, user_id=7, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/propose", json={
"title": "Bad slug", "slug": "Bad Slug!", "pitch": "p", "tags": [],
})
assert r.status_code == 422
def test_anonymous_cannot_propose(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.post("/api/rfcs/propose", json={
"title": "A", "slug": "a", "pitch": "p", "tags": [],
})
assert r.status_code == 401
def test_withdraw_by_proposer_works(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=9, login="alice", role="contributor")
provision_user_row(user_id=10, login="bob", role="contributor")
sign_in_as(client, user_id=9, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/propose", json={
"title": "X", "slug": "x", "pitch": "p", "tags": [],
})
pr_number = r.json()["pr_number"]
# A different contributor cannot withdraw someone else's proposal.
sign_in_as(client, user_id=10, gitea_login="bob", display_name="Bob", role="contributor")
r = client.post(f"/api/proposals/{pr_number}/withdraw")
assert r.status_code == 403
# The proposer can.
sign_in_as(client, user_id=9, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post(f"/api/proposals/{pr_number}/withdraw")
assert r.status_code == 200, r.text
r = client.get("/api/proposals")
assert r.json()["items"] == []