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
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)