Files
rfc-app/backend/app/api.py
T
Ben Stull ee6e3491e7 Drop "prototype/carryover" framing now that v1 is shipped
SPEC, DEV docs, and code comments still talked about the codebase as
a rewrite-in-progress against an external prototype. With v1 shipped
the framing reads oddly — it implies code is provisional when it's
the production thing. Recast §18 as "the technical stack," strip
"carryover from the prototype" comments across backend (api.py,
chat.py, providers.py) and frontend (DiffView, PromptBar,
SelectionTooltip, modelStyles), and rework SPEC §1 / §18 to introduce
OHM up front rather than as a follow-on to a prototype reference.

Also:
- RUNBOOK: bump Python prereq to 3.11+ to match the production VM
  (was 3.13).
- Remove IMPLEMENTATION-PROMPT.md — the original implementation brief
  is no longer load-bearing.
- Add deploy/DEPLOY-NEW-SESSION-PROMPT.md as the durable
  deploy-handoff prompt for new sessions.
2026-05-25 10:32:46 -07:00

513 lines
20 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,
funder,
philosophy,
providers as providers_mod,
)
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)
class FunderCredentialBody(BaseModel):
provider: str = Field(min_length=1, max_length=40)
api_key: str = Field(min_length=1, max_length=2048)
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 — 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}
# ---------------------------------------------------------------
# §6.7 / §17: per-RFC credential delegation — the funder surface.
# /api/users/me/funder is the self-serve read; the two write
# endpoints register credentials and the two slug-scoped endpoints
# toggle consent. Per §6.7, the funder cannot expand the operator
# universe: registering for a provider family the operator has not
# enabled is refused. A consent without registered credentials is
# also refused since it would be operationally inert.
# ---------------------------------------------------------------
@router.get("/api/users/me/funder")
async def get_funder_self(request: Request) -> dict[str, Any]:
user = auth.require_user(request)
return {
"credentials": funder.list_credentials(user.user_id),
"consents": funder.list_consents(user.user_id),
}
@router.post("/api/users/me/funder/credentials")
async def register_funder_credential(payload: FunderCredentialBody, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
provider = payload.provider.strip().lower()
if provider not in providers_mod.FUNDER_PROVIDER_FAMILIES:
raise HTTPException(422, f"Unknown provider `{provider}`")
# §6.7: the funder cannot expand the operator universe. The
# provider family must back at least one operator-enabled key.
operator_family_keys = providers_mod.picker_keys_for_family(provider, list(providers.keys()))
if not operator_family_keys:
raise HTTPException(409, f"Operator has not enabled any `{provider}` models")
funder.upsert_credential(user.user_id, provider, payload.api_key.strip())
return {"ok": True, "provider": provider}
@router.delete("/api/users/me/funder/credentials/{provider}")
async def delete_funder_credential(provider: str, request: Request) -> dict[str, Any]:
user = auth.require_user(request)
provider = provider.strip().lower()
funder.delete_credential(user.user_id, provider)
return {"ok": True, "provider": provider}
@router.post("/api/rfcs/{slug}/funder/consent")
async def add_funder_consent(slug: str, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
rfc = db.conn().execute(
"SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if rfc is None:
raise HTTPException(404, "RFC not found")
# §6.7: refuse consent from a user with no registered credentials
# — a consent without a universe would be inert and the surface
# should fail loudly rather than silently.
if not funder.has_any_credentials(user.user_id):
raise HTTPException(409, "Register credentials before consenting to fund")
funder.add_consent(user.user_id, slug)
return {"ok": True, "slug": slug}
@router.delete("/api/rfcs/{slug}/funder/consent")
async def remove_funder_consent(slug: str, request: Request) -> dict[str, Any]:
user = auth.require_user(request)
funder.remove_consent(user.user_id, slug)
return {"ok": True, "slug": slug}
# ---------------------------------------------------------------
# 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,
}