"""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/`, 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_branches, auth, db, entry as entry_mod, cache 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)) # --------------------------------------------------------------- # 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, }