1dab24eef0
Integrates the multi-project work so far — the §22 design drafts, M1 (the schema spine), and M2 (project-scoped authorization + the §22.7 resolver) — on top of the v0.32.0 retire/§13 changes that landed on main meanwhile. Integration decisions: - Renumbered the M1 projects migration 025_projects.sql -> 026_projects.sql. v0.32.0 shipped 025_retired_state.sql, which rebuilds cached_rfcs and predates the project_id column; running projects *after* the retire rebuild is required so project_id survives on a fresh database (the runner applies *.sql in filename order). Comment/doc references bumped to match. - Resolved the get_rfc conflict in api.py by composing both gates: compute the viewer once, apply the §22.5 visibility gate, then v0.32.0's §13.7 retired-entry owner-only check. - api_graduation.py / api_discussion.py auto-merged cleanly (M2's threaded viewer/visibility gate coexists with the new retire endpoints + retired state). Full suite 401 passed on a fresh DB. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
363 lines
15 KiB
Python
363 lines
15 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, 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/<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, viewer)
|
|
# 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, viewer)
|
|
# 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/<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, viewer)
|
|
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/<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, viewer)
|
|
# 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/<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, viewer)
|
|
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, viewer):
|
|
"""Per the v0.3.0 anonymous-read contract: any cached RFC in a *readable*
|
|
project is readable by anyone. The §22.5 visibility gate is subtractive on
|
|
top (§22.7): a gated project's entries 404 to non-members. 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")
|
|
auth.require_project_readable(viewer, row["project_id"])
|
|
if row["state"] == "withdrawn":
|
|
raise HTTPException(409, "RFC is withdrawn")
|
|
# §13.7: a retired entry is soft-deleted — refuse reads of every shape
|
|
# (a 404, not a 409: the entry is not surfaced anywhere a browser looks).
|
|
if row["state"] == "retired":
|
|
raise HTTPException(404, "RFC not found")
|
|
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 auth.is_project_superuser(viewer, rfc["project_id"]):
|
|
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"],
|
|
}
|