"""§5 / §7 / §10 — PR-less per-RFC discussion endpoints (v0.5.0). This module surfaces the discussion-without-PR shape committed by the roadmap's item #3. The substrate is the existing `threads` / `thread_messages` pair from §5: rows whose `branch_name` is NULL are scoped to the RFC's main view (the schema comment on the column says exactly this; until now no write path produced such rows). This module is the read+write surface for those rows. Contribution still requires a PR: the §10 PR flow is unchanged, the branch-scoped chat in `api_branches.py` is unchanged, and accept / decline of AI `` blocks still lives on a branch. What this module adds is the "discuss freely about the RFC, no branch yet" surface — a place to drop a question, a flag-style observation, or a multi-turn conversation that does not yet warrant cutting a branch. Auth shape mirrors the v0.3.0 anonymous-read contract: reads are open, writes require `auth.require_contributor`. Item #4 ("anon discuss/ contribute off-limits") tightens the read gate in v0.6.0; v0.5.0's write gate already holds the line. Notification routing reuses the existing `fan_out_chat_message` path with `branch_name=None`; the `notifications.branch_name` column is nullable, and the inbox row prose ("@alice posted a chat message on ") renders identically whether the chat lives on a branch or on the RFC's discussion surface. The existing `chat_message_in_participated_thread` / `chat_reply_to_my_message` event kinds carry both shapes; introducing a parallel `open_rfc_discussion_thread` / `post_rfc_discussion_message` enum pair would split routing without adding signal. The §15 §19.2 candidate "distinct event_kinds for PR-less discussion" notes the option for a future session if evidence demands the split. """ from __future__ import annotations import json import logging from typing import Any from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field from . import auth, chat as chat_layer, db, rfc_links log = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Request bodies # --------------------------------------------------------------------------- class DiscussionThreadCreateBody(BaseModel): """A discussion thread is a `thread_kind='chat'`, `anchor_kind='whole-doc'`, `branch_name=NULL` row. Anchored-range / per-paragraph threads on the RFC discussion surface are a §19.2 candidate — the schema supports them; the UI work to surface a range-anchor on a non-branch view is the deferred part. v0.5.0 keeps the shape narrow.""" label: str | None = Field(default=None, max_length=400) message: str | None = Field(default=None, max_length=20_000) class DiscussionMessageBody(BaseModel): text: str = Field(min_length=1, max_length=20_000) quote: str | None = Field(default=None, max_length=2000) # --------------------------------------------------------------------------- # Router # --------------------------------------------------------------------------- def make_router() -> APIRouter: router = APIRouter() # ------------------------------------------------------------------- # GET /api/rfcs//discussion/threads # Lists every PR-less thread on the RFC. The default whole-doc thread # is materialized lazily on first list (mirroring the §8.12 branch- # chat default-thread treatment) so the UI always has a target for # the compose-message affordance. # ------------------------------------------------------------------- @router.get("/api/rfcs/{slug}/discussion/threads") async def list_discussion_threads(slug: str, request: Request) -> dict[str, Any]: viewer = auth.current_user(request) _require_rfc_readable(slug) # Ensure the default whole-doc discussion thread exists. We mint # it on first read regardless of viewer (anonymous viewers can # trigger the creation — the row's `created_by` is null in that # case, mirroring `_ensure_branch_chat_thread`). _ensure_discussion_thread(slug, viewer) rows = db.conn().execute( """ SELECT id, anchor_kind, anchor_payload, thread_kind, label, state, created_by, created_at, resolved_at, resolved_by FROM threads WHERE rfc_slug = ? AND branch_name IS NULL ORDER BY id """, (slug,), ).fetchall() return {"items": [_serialize_thread(r) for r in rows]} # ------------------------------------------------------------------- # POST /api/rfcs//discussion/threads # Open a fresh discussion thread. Writes require require_contributor # — anonymous viewers can read but cannot open a thread, per item # #4's hardening anticipated in v0.6.0 (we already enforce it here # to avoid the open window). # ------------------------------------------------------------------- @router.post("/api/rfcs/{slug}/discussion/threads") async def create_discussion_thread( slug: str, body: DiscussionThreadCreateBody, request: Request ) -> dict[str, Any]: viewer = auth.require_contributor(request) _require_rfc_readable(slug) # v0.16.0 (roadmap item #12): the per-RFC discussion is now a # gated surface. The platform-level `require_contributor` above # ensures the user is signed in + admin-granted; this layer # narrows further to "is this user named for this RFC?" The # 403 here is structurally the v0.6.0 anon-write refusal # extended to non-invited platform users. if not auth.can_discuss_rfc(viewer, slug): raise HTTPException( 403, "This RFC's owner has not invited you to its discussion", ) cur = db.conn().execute( """ INSERT INTO threads (rfc_slug, branch_name, anchor_kind, anchor_payload, thread_kind, label, created_by) VALUES (?, NULL, 'whole-doc', NULL, 'chat', ?, ?) """, (slug, body.label, viewer.user_id), ) thread_id = cur.lastrowid message_id = None if body.message: message_id = chat_layer.append_user_message( thread_id=thread_id, author_user_id=viewer.user_id, text=body.message, quote=None, ) return {"thread_id": thread_id, "message_id": message_id} # ------------------------------------------------------------------- # GET /api/rfcs//discussion/threads//messages # ------------------------------------------------------------------- @router.get("/api/rfcs/{slug}/discussion/threads/{thread_id}/messages") async def get_discussion_thread_messages( slug: str, thread_id: int, request: Request ) -> dict[str, Any]: _viewer = auth.current_user(request) _require_rfc_readable(slug) thread = _require_discussion_thread(slug, thread_id) rows = db.conn().execute( """ SELECT m.id, m.role, m.author_user_id, u.gitea_login AS author_login, u.display_name AS author_display, m.model_id, m.text, m.quote, m.created_at FROM thread_messages m LEFT JOIN users u ON u.id = m.author_user_id WHERE m.thread_id = ? ORDER BY m.id """, (thread_id,), ).fetchall() # Roadmap #28 Part 1: enrich each discussion comment with RFC # auto-link segments scanned against the live accepted-RFC corpus # (read-time; see rfc_links.py). exclude_slug suppresses self-links # to this RFC inside its own discussion. link_index = rfc_links.build_index(db.conn(), exclude_slug=slug) messages = [_serialize_message(r) for r in rows] for m in messages: m["text_segments"] = link_index.segment(m["text"]) return { "thread": _serialize_thread(thread), "messages": messages, } # ------------------------------------------------------------------- # POST /api/rfcs//discussion/threads//messages # ------------------------------------------------------------------- @router.post("/api/rfcs/{slug}/discussion/threads/{thread_id}/messages") async def post_discussion_message( slug: str, thread_id: int, body: DiscussionMessageBody, request: Request ) -> dict[str, Any]: viewer = auth.require_contributor(request) _require_rfc_readable(slug) # v0.16.0 (item #12): same per-RFC gate as create_discussion_thread. if not auth.can_discuss_rfc(viewer, slug): raise HTTPException( 403, "This RFC's owner has not invited you to its discussion", ) _require_discussion_thread(slug, thread_id) message_id = chat_layer.append_user_message( thread_id=thread_id, author_user_id=viewer.user_id, text=body.text, quote=body.quote, ) return {"ok": True, "message_id": message_id} # ------------------------------------------------------------------- # POST /api/rfcs//discussion/threads//resolve # ------------------------------------------------------------------- @router.post("/api/rfcs/{slug}/discussion/threads/{thread_id}/resolve") async def resolve_discussion_thread( slug: str, thread_id: int, request: Request ) -> dict[str, Any]: viewer = auth.require_contributor(request) rfc = _require_rfc_readable(slug) thread = _require_discussion_thread(slug, thread_id) if not _can_resolve(rfc, thread, viewer): raise HTTPException( 403, "Only the thread creator, an RFC owner/arbiter, or an app admin/owner may resolve", ) db.conn().execute( """ UPDATE threads SET state = 'resolved', resolved_by = ?, resolved_at = datetime('now') WHERE id = ? """, (viewer.user_id, thread_id), ) return {"ok": True, "thread_id": thread_id} return router # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _require_rfc_readable(slug: str): """Per the v0.3.0 anonymous-read contract: any cached RFC is readable by anyone. Withdrawn entries refuse reads of every shape — same rule `_require_rfc_with_repo` in `api_branches.py` follows.""" row = db.conn().execute( "SELECT * FROM cached_rfcs WHERE slug = ?", (slug,) ).fetchone() if row is None: raise HTTPException(404, "RFC not found") if row["state"] == "withdrawn": raise HTTPException(409, "RFC is withdrawn") return row def _require_discussion_thread(slug: str, thread_id: int): """A discussion thread is one whose (rfc_slug, branch_name) = (slug, NULL). Refuse cleanly if the thread id resolves to a branch-scoped thread instead — that lookup belongs on the branch endpoints.""" row = db.conn().execute( """ SELECT * FROM threads WHERE id = ? AND rfc_slug = ? AND branch_name IS NULL """, (thread_id, slug), ).fetchone() if not row: raise HTTPException(404, "Discussion thread not found") return row def _ensure_discussion_thread(slug: str, viewer) -> int: """Per the §8.12 lazy-create pattern, materialize a default whole-doc chat thread on the RFC's discussion surface on first read. Created_by is null when an anonymous viewer triggers creation — the thread is structurally owned by the RFC, not by whoever opened the view.""" row = db.conn().execute( """ SELECT id FROM threads WHERE rfc_slug = ? AND branch_name IS NULL AND anchor_kind = 'whole-doc' AND thread_kind = 'chat' ORDER BY id LIMIT 1 """, (slug,), ).fetchone() if row: return row["id"] cur = db.conn().execute( """ INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, label, created_by) VALUES (?, NULL, 'whole-doc', 'chat', NULL, ?) """, (slug, viewer.user_id if viewer else None), ) return cur.lastrowid def _can_resolve(rfc, thread, viewer) -> bool: if viewer is None: return False if viewer.role in ("owner", "admin"): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") if viewer.gitea_login in owners or viewer.gitea_login in arbiters: return True if thread["created_by"] == viewer.user_id: return True return False # --------------------------------------------------------------------------- # Serializers — mirror api_branches.py's shape # --------------------------------------------------------------------------- def _serialize_thread(row) -> dict[str, Any]: payload = row["anchor_payload"] try: anchor = json.loads(payload) if payload else None except Exception: anchor = None return { "id": row["id"], "anchor_kind": row["anchor_kind"], "anchor_payload": anchor, "thread_kind": row["thread_kind"], "label": row["label"], "state": row["state"], "created_by": row["created_by"], "created_at": row["created_at"], "resolved_at": row["resolved_at"] if "resolved_at" in row.keys() else None, "resolved_by": row["resolved_by"] if "resolved_by" in row.keys() else None, } def _serialize_message(row) -> dict[str, Any]: return { "id": row["id"], "role": row["role"], "author_user_id": row["author_user_id"], "author_login": row["author_login"], "author_display": row["author_display"], "model_id": row["model_id"], "text": row["text"], "quote": row["quote"], "created_at": row["created_at"], }