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