c92730a737
Roadmap item #3. An RFC's main view now carries a discussion surface distinct from PR comments and from branch chat. The substrate is the existing threads/thread_messages tables — rows with branch_name IS NULL scope to the RFC's main view; the schema already permitted that shape, v0.5.0 is the first build to write it. Five new endpoints under /api/rfcs/<slug>/discussion/..., a new RFCDiscussionPanel right-column component used when branchParam === main, SPEC §10.10 settling discussion-vs-contribution, and §17 listing the new routes. Notification routing reuses the existing chat_message_in_participated_thread / chat_reply_to_my_message event kinds with branch_name=null on the fan-out row; a distinct event_kind is a §19.2 candidate. Anonymous viewers can read; writes require contributor — v0.6.0's item #4 will harden adjacent gates. No schema migration; minor bump, no operator action required beyond rebuild and restart. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
331 lines
13 KiB
Python
331 lines
13 KiB
Python
"""§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 `<change>` 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
|
|
<RFC title>") 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
|
|
|
|
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/<slug>/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/<slug>/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)
|
|
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/<slug>/discussion/threads/<thread_id>/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()
|
|
return {
|
|
"thread": _serialize_thread(thread),
|
|
"messages": [_serialize_message(r) for r in rows],
|
|
}
|
|
|
|
# -------------------------------------------------------------------
|
|
# POST /api/rfcs/<slug>/discussion/threads/<thread_id>/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)
|
|
_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/<slug>/discussion/threads/<thread_id>/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"],
|
|
}
|