Files
rfc-app/backend/app/api.py
T
Ben Stull 060fa408a2 Slice 7: §14 chrome + settings and admin neighborhoods
§14.1 richer landing, §14.2 /philosophy route (disk-backed), §14.3
persistent About link. /settings/notifications surfaces Slice 6's
preferences/quiet-hours/mute/watches endpoints. /admin home base
consolidates role management, the §6.2 write-mute, the audit-log
viewer, the permission-events log, and the §13.2 graduation queue.

Backend: backend/app/philosophy.py, backend/app/api_admin.py (seven
admin endpoints + user-search), GET /api/users/me/notification-mutes.
Frontend: Landing.jsx (deck), Philosophy.jsx, NotificationSettings.jsx,
Admin.jsx, App.jsx routing for the chrome surfaces.

Tests: backend/tests/test_chrome_vertical.py — 13 cases. Full suite
75/75 green.

Spec corrections: §14.2 (PHILOSOPHY.md source is a deployment-time
decision), §17 (admin block extended to name the seven new endpoints
+ user-search and notification-mutes read). §19.1 rewritten for
Slice 8 hardening; §19.2 grew four candidates (owner succession,
mute-from-actor, the "Following since <date>" disclosure, audit-log
row prose).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:40:49 -07:00

446 lines
17 KiB
Python

"""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 (
api_admin,
api_branches,
api_graduation,
api_notifications,
api_prs,
auth,
db,
entry as entry_mod,
cache,
philosophy,
)
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
from .providers import BaseProvider
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,
providers: dict[str, BaseProvider] | None = None,
) -> APIRouter:
# Use `is None` rather than `providers or {}` — an empty dict is
# falsy, and the test harness mutates the dict the closure holds to
# inject a fake provider; substituting a fresh `{}` here would
# silently drop those mutations.
if providers is None:
providers = {}
router = APIRouter()
# Slice 2: the §8 active-RFC view's endpoints live in api_branches.
# Mounting them on the same router keeps the §17 layout flat.
router.include_router(api_branches.make_router(config, gitea, bot, providers))
# Slice 3: the §10 PR-flow endpoints.
router.include_router(api_prs.make_router(config, gitea, bot, providers))
# Slice 5: §13 graduation + §13.1 claim.
router.include_router(api_graduation.make_router(config, gitea, bot))
# Slice 6: §15 notifications surface (inbox, watches, prefs,
# quiet hours, per-user mute, email unsubscribe, bounce webhook).
router.include_router(api_notifications.make_router(config))
# Slice 7: §14 chrome (/philosophy read endpoint, user search for
# the §15.8 mute typeahead) and the §6/§17 admin surfaces
# (role, write-mute, audit-log, graduation-readiness queue).
router.include_router(api_admin.make_router(config))
# ---------------------------------------------------------------
# §14.2: /api/philosophy — PHILOSOPHY.md served verbatim.
# No auth gate; anonymous visitors reach `/philosophy` per §14.1.
# The body is read once at process start and refreshed on demand
# via the reconciler; see backend/app/philosophy.py.
# ---------------------------------------------------------------
@router.get("/api/philosophy")
async def get_philosophy() -> dict[str, Any]:
payload = philosophy.load()
return {"body": payload["body"]}
# ---------------------------------------------------------------
# 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,
}