705 lines
29 KiB
Python
705 lines
29 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_discussion,
|
|
api_graduation,
|
|
api_notifications,
|
|
api_prs,
|
|
auth,
|
|
db,
|
|
device_trust as device_trust_mod,
|
|
docs as docs_mod,
|
|
entry as entry_mod,
|
|
cache,
|
|
funder,
|
|
health,
|
|
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)
|
|
|
|
|
|
class BetaRequestBody(BaseModel):
|
|
# v0.8.0 — captured on the first OTC sign-in. All three fields are
|
|
# required so the admin queue has a coherent triage shape.
|
|
# The bounds match the v0.7.0 OTC body (320 chars for email-ish
|
|
# headers; 4000 for the free-text reason — the same upper bound
|
|
# DeclineBody uses elsewhere in this file).
|
|
first_name: str = Field(min_length=1, max_length=120)
|
|
last_name: str = Field(min_length=1, max_length=120)
|
|
beta_request_reason: str = Field(min_length=1, max_length=4000)
|
|
|
|
|
|
def make_router(
|
|
config: Config,
|
|
gitea: Gitea,
|
|
bot: Bot,
|
|
providers: dict[str, BaseProvider] | None = None,
|
|
) -> APIRouter:
|
|
# Use `is None` rather than `providers or {}` — an empty dict is
|
|
# falsy, and the test harness mutates the dict the closure holds to
|
|
# inject a fake provider; substituting a fresh `{}` here would
|
|
# silently drop those mutations.
|
|
if providers is None:
|
|
providers = {}
|
|
router = APIRouter()
|
|
# Slice 2: the §8 active-RFC view's endpoints live in api_branches.
|
|
# Mounting them on the same router keeps the §17 layout flat.
|
|
router.include_router(api_branches.make_router(config, gitea, bot, providers))
|
|
# Slice 3: the §10 PR-flow endpoints.
|
|
router.include_router(api_prs.make_router(config, gitea, bot, providers))
|
|
# Slice 5: §13 graduation + §13.1 claim.
|
|
router.include_router(api_graduation.make_router(config, gitea, bot))
|
|
# Slice 6: §15 notifications surface (inbox, watches, prefs,
|
|
# quiet hours, per-user mute, email unsubscribe, bounce webhook).
|
|
router.include_router(api_notifications.make_router(config))
|
|
# Slice 7: §14 chrome (/philosophy read endpoint, user search for
|
|
# the §15.8 mute typeahead) and the §6/§17 admin surfaces
|
|
# (role, write-mute, audit-log, graduation-readiness queue).
|
|
router.include_router(api_admin.make_router(config))
|
|
# v0.5.0: §5 / §7 / §10 — PR-less per-RFC discussion endpoints.
|
|
# The substrate is the existing threads/thread_messages tables;
|
|
# rows whose branch_name IS NULL scope to the RFC's main view.
|
|
# Contribution still requires a PR (api_prs above); this surface
|
|
# is for discussion that does not yet warrant a branch.
|
|
router.include_router(api_discussion.make_router())
|
|
|
|
# ---------------------------------------------------------------
|
|
# §17: /api/health — unauthenticated post-flight probe.
|
|
# Used by ops tooling (flotilla) to verify a deploy landed via
|
|
# version-match. v1 always reports ok; see backend/app/health.py.
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.get("/api/health")
|
|
async def get_health() -> dict[str, Any]:
|
|
return {"version": health.VERSION, "status": "ok"}
|
|
|
|
# ---------------------------------------------------------------
|
|
# §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"]}
|
|
|
|
# ---------------------------------------------------------------
|
|
# /api/docs — DOCS.md served verbatim. Sibling of /api/philosophy:
|
|
# no auth gate, same disk-first load + cache shape, same intent —
|
|
# public read surface for a markdown file checked into the repo.
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.get("/api/docs")
|
|
async def get_docs() -> dict[str, Any]:
|
|
payload = docs_mod.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}
|
|
# v0.8.0 + v0.10.0: single round-trip for everything the
|
|
# frontend gates UI off of — beta-access state + passcode state.
|
|
row = db.conn().execute(
|
|
"SELECT first_name, last_name, beta_request_reason, "
|
|
"passcode_hash, passcode_set_at "
|
|
"FROM users WHERE id = ?",
|
|
(user.user_id,),
|
|
).fetchone()
|
|
first_name = (row["first_name"] if row else None) or ""
|
|
last_name = (row["last_name"] if row else None) or ""
|
|
beta_request_reason = (row["beta_request_reason"] if row else None) or ""
|
|
# "Needs profile" iff the user is pending AND hasn't yet
|
|
# filed their beta-request capture. Granted users never see
|
|
# the capture prompt; pending users who already filed see
|
|
# the /beta-pending page without the capture form.
|
|
needs_profile = (
|
|
user.permission_state == "pending"
|
|
and not first_name
|
|
and not last_name
|
|
and not beta_request_reason
|
|
)
|
|
has_passcode = bool(row and row["passcode_hash"])
|
|
passcode_set_at = row["passcode_set_at"] if (row and has_passcode) else 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,
|
|
"permission_state": user.permission_state,
|
|
"first_name": first_name,
|
|
"last_name": last_name,
|
|
"beta_request_reason": beta_request_reason,
|
|
"needs_profile": needs_profile,
|
|
"has_passcode": has_passcode,
|
|
"passcode_set_at": passcode_set_at,
|
|
},
|
|
}
|
|
|
|
# ---------------------------------------------------------------
|
|
# v0.8.0: /api/auth/me/beta-request — first-OTC profile capture
|
|
# (roadmap item #6). Lands first name, last name, and the free-
|
|
# text "why I should be included in the beta" on the signed-in
|
|
# user's row. Idempotent for the same already-pending user;
|
|
# refuses to overwrite a row that's already granted (so a
|
|
# bored already-granted user can't accidentally re-submit the
|
|
# form and clobber the admin's audit trail). Uses
|
|
# `require_user` rather than `require_contributor` because
|
|
# `require_contributor` already enforces `permission_state =
|
|
# 'granted'` and would refuse a pending user; the whole point
|
|
# of this endpoint is to register the request _from_ a pending
|
|
# user.
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.post("/api/auth/me/beta-request")
|
|
async def submit_beta_request(body: BetaRequestBody, request: Request) -> dict[str, Any]:
|
|
user = auth.require_user(request)
|
|
row = db.conn().execute(
|
|
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE id = ?",
|
|
(user.user_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
# Defensive — the session pointed at a deleted row.
|
|
raise HTTPException(404, "User not found")
|
|
# Granted users have no business filing a beta request.
|
|
# 'revoked' likewise — the request flow is for fresh users
|
|
# only. Both shapes refuse with 409 (conflict) so the client
|
|
# can distinguish "you already have access" from
|
|
# "your access was revoked".
|
|
if row["permission_state"] == "granted":
|
|
raise HTTPException(409, "Your account is already granted access")
|
|
if row["permission_state"] == "revoked":
|
|
raise HTTPException(409, "Your account's access has been revoked")
|
|
# Re-submission from a pending user updates the row — the
|
|
# admin sees the latest text rather than a stale draft.
|
|
# The state stays 'pending'; only an admin can flip it.
|
|
db.conn().execute(
|
|
"""
|
|
UPDATE users
|
|
SET first_name = ?,
|
|
last_name = ?,
|
|
beta_request_reason = ?
|
|
WHERE id = ?
|
|
""",
|
|
(
|
|
body.first_name.strip(),
|
|
body.last_name.strip(),
|
|
body.beta_request_reason.strip(),
|
|
user.user_id,
|
|
),
|
|
)
|
|
return {"ok": True}
|
|
|
|
# ---------------------------------------------------------------
|
|
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
|
|
#
|
|
# The mint path lives on the OAuth router (issuing the cookie is
|
|
# coupled to OTC/passcode verify). This module owns the read/revoke
|
|
# surface the /settings/devices page calls.
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.get("/api/auth/me/devices")
|
|
async def list_my_devices(request: Request) -> dict[str, Any]:
|
|
"""Active device-trust rows for the signed-in user.
|
|
|
|
Active = not revoked, not expired. The current request's
|
|
device (if any) is *not* singled out here — the surface
|
|
shows the same row shape for every device so the user can
|
|
revoke any of them without the page leaking which row
|
|
carries the cookie they're using right now.
|
|
"""
|
|
user = auth.require_user(request)
|
|
rows = device_trust_mod.list_for_user(user.user_id)
|
|
return {
|
|
"items": [
|
|
{
|
|
"id": r.id,
|
|
"created_at": r.created_at,
|
|
"expires_at": r.expires_at,
|
|
"last_seen_at": r.last_seen_at,
|
|
"user_agent": r.user_agent,
|
|
}
|
|
for r in rows
|
|
]
|
|
}
|
|
|
|
@router.delete("/api/auth/me/devices/{device_id}")
|
|
async def revoke_my_device(device_id: int, request: Request) -> dict[str, Any]:
|
|
"""Revoke a single device-trust row for the signed-in user.
|
|
|
|
The user-id scope is enforced in SQL so a hostile client
|
|
cannot revoke another user's row by guessing ids. A row that
|
|
doesn't exist, doesn't belong to this user, or is already
|
|
revoked reads as 404 — the wrong-vs-already-revoked
|
|
distinction would only help a probing client enumerate ids.
|
|
"""
|
|
user = auth.require_user(request)
|
|
ok = device_trust_mod.revoke(user.user_id, device_id)
|
|
if not ok:
|
|
raise HTTPException(404, "Device not found")
|
|
return {"ok": True}
|
|
|
|
@router.delete("/api/auth/me/devices")
|
|
async def revoke_all_my_devices(request: Request) -> dict[str, Any]:
|
|
"""Revoke every active device-trust row for the signed-in user.
|
|
|
|
The user's current request stays authenticated via its
|
|
session cookie; the device-trust cookie carried on the
|
|
current device is also revoked, but `rfc_session` keeps the
|
|
request flow alive until sign-out / expiry.
|
|
"""
|
|
user = auth.require_user(request)
|
|
count = device_trust_mod.revoke_all(user.user_id)
|
|
return {"ok": True, "revoked": count}
|
|
|
|
# ---------------------------------------------------------------
|
|
# §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,
|
|
# §9.2: the proposer is the implicit first owner at propose time.
|
|
# The §13.1 claim flow exists for *other* contributors to become
|
|
# owners on an RFC they didn't propose; the proposer never needs
|
|
# to claim their own RFC.
|
|
owners=[user.gitea_login],
|
|
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,
|
|
}
|