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:
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user