Files
rfc-app/backend/app/api_branches.py
T
Ben Stull 3bc8fe92af Slice 2: the §8 active-RFC view in full
Per the §19.1 brief: the three-column shape (§8.1) opens on main
in discuss mode (§8.2), supports the §8.3 discuss-vs-contribute
flip on non-main branches, hosts §8.4's per-branch chat with AI
participation (§18's <change> protocol → §8.14 changes rows), the
§8.8 change-card panel with §8.9 accept/decline/edit-before-accept,
the §8.10 tracked-change markup + DiffView toggle, the §8.11
manual-edit flushes with the stale-change mechanic, the §8.12
range and paragraph sub-threads, the §8.13 flag affordance, and
the §8.14 discuss-mode buffer.

Backend: bot.py grew per-RFC-repo write ops (cut_branch_from_main,
commit_accepted_change with the structured original/proposed/reason
body and Change-Id + Source-Message-Id + On-behalf-of trailers,
commit_manual_flush, ensure_rfc_repo_seed). cache.py grew
refresh_rfc_repo and the webhook dispatches on repository.full_name.
providers.py and chat.py port the §18 carryovers — multi-provider
LLM abstraction and SSE-streaming chat against the §5 threads /
thread_messages / changes schema. api_branches.py mounts the §17
branches/<branch>/* and threads/<thread_id>/* routes with the §6
/ §11 permission checks inline.

Frontend: RFCView.jsx rebuilt as the §8 surface; Editor.jsx,
ChatPanel.jsx, ChangePanel.jsx, PromptBar.jsx, SelectionTooltip.jsx,
DiffView.jsx, ModelPicker.jsx, modelStyles.js lifted from the
prototype and adapted to the canonical schema.

Covered by `backend/tests/test_rfc_view_vertical.py` — eleven new
integration tests against an extended FakeGitea (PUT contents,
POST orgs/{org}/repos, seed_rfc_repo): main-view read,
promote-to-branch, accept (with and without edit-before-accept),
decline, manual flush + system message, flag creation, visibility
flip, anonymous read-but-no-contribute, stale-change refusal, and
the chat-streaming path with a fake provider injected. The 5
Slice 1 tests continue to pass alongside.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:35:14 -07:00

1131 lines
46 KiB
Python

"""Slice 2 API surface — the §8 active-RFC view's endpoints.
Owns every `branches/<branch>/...` and `threads/<thread_id>/...` route
from §17. Read paths fetch branch bodies live from Gitea (§4 #3
exempts branch bodies from the cache); write paths funnel through
`bot.py` so the §1 chokepoint and the §6.5 trailer hold.
Visibility and contribute decisions are enforced inline here against
the §6 four-role model plus the §11 per-branch visibility/contribute
rules; the app's permission model is canonical, and Gitea sees only
the bot.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from . import auth, cache, chat as chat_layer, db
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
from .providers import BaseProvider
log = logging.getLogger(__name__)
RFC_FILE_PATH = "RFC.md"
# ---------------------------------------------------------------------------
# Request bodies
# ---------------------------------------------------------------------------
class PromoteToBranchBody(BaseModel):
branch_name: str | None = Field(default=None, max_length=120)
class AcceptChangeBody(BaseModel):
proposed: str = Field(min_length=0)
was_edited_before_accept: bool = False
force_apply_stale: bool = False
class ManualFlushBody(BaseModel):
new_content: str
paragraph_count: int = Field(ge=1)
class VisibilityBody(BaseModel):
read_public: bool | None = None
contribute_mode: str | None = Field(default=None, pattern="^(just-me|specific|any-contributor)$")
class GrantBody(BaseModel):
grantee_gitea_login: str = Field(min_length=1, max_length=80)
class ThreadCreateBody(BaseModel):
thread_kind: str = Field(pattern="^(chat|flag)$")
anchor_kind: str = Field(pattern="^(whole-doc|range|paragraph)$")
anchor_payload: dict | None = None
label: str | None = Field(default=None, max_length=400)
message: str | None = Field(default=None, max_length=20_000)
class ThreadMessageBody(BaseModel):
text: str = Field(min_length=1, max_length=20_000)
quote: str | None = Field(default=None, max_length=2000)
class ChatTurnBody(BaseModel):
text: str = Field(min_length=1, max_length=20_000)
quote: str | None = Field(default=None, max_length=2000)
model: str | None = None
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
def make_router(
config: Config,
gitea: Gitea,
bot: Bot,
providers: dict[str, BaseProvider],
) -> APIRouter:
router = APIRouter()
default_model = next(iter(providers)) if providers else ""
# -------------------------------------------------------------------
# §17: model picker (the prototype carryover, scoped here since
# Slice 2 is where chat lights up).
# -------------------------------------------------------------------
@router.get("/api/models")
async def list_models() -> dict[str, Any]:
return {
"models": [
{"id": key, "name": p.display_name}
for key, p in providers.items()
],
"default": default_model,
}
# -------------------------------------------------------------------
# §17: GET /api/rfcs/<slug>/main
# Body + branches + open PRs for the breadcrumb dropdown.
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/main")
async def get_rfc_main(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_active_rfc(slug)
# Branches the viewer can read per §11.1.
branch_rows = db.conn().execute(
"""
SELECT branch_name, head_sha, state, last_commit_at, pinned
FROM cached_branches
WHERE rfc_slug = ? AND state != 'deleted'
ORDER BY last_commit_at DESC NULLS LAST
""",
(slug,),
).fetchall()
branches = [
_branch_summary(slug, br, viewer)
for br in branch_rows
if _can_read_branch(slug, br["branch_name"], viewer)
]
pr_rows = db.conn().execute(
"""
SELECT pr_number, title, state, head_branch, opened_by, opened_at
FROM cached_prs
WHERE rfc_slug = ? AND pr_kind = 'rfc_branch' AND state = 'open'
ORDER BY opened_at DESC
""",
(slug,),
).fetchall()
prs = [
{
"pr_number": r["pr_number"],
"title": r["title"],
"state": r["state"],
"head_branch": r["head_branch"],
"opened_by": r["opened_by"],
"opened_at": r["opened_at"],
}
for r in pr_rows
]
return {
"slug": slug,
"title": rfc["title"],
"id": rfc["rfc_id"],
"repo": rfc["repo"],
"body": rfc["body"] or "",
"body_sha": rfc["body_sha"],
"branches": branches,
"open_prs": prs,
}
# -------------------------------------------------------------------
# §17: GET /api/rfcs/<slug>/branches/<branch>
# Per §4: branch bodies are NOT cached — fetch live from Gitea.
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/branches/{branch}")
async def get_branch_view(slug: str, branch: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_active_rfc(slug)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
owner, repo = _owner_repo(rfc)
# Ensure branch exists in cache so freshness measures match
# reality; the read path is read-only so a missing row is a
# cue to refresh, not an error.
result = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=branch)
if result is None:
# The branch might exist but be empty; check the branch
# itself before deciding whether this is 404 or 200-with-empty.
br = await gitea.get_branch(owner, repo, branch)
if br is None:
raise HTTPException(404, "Branch not found")
body, body_sha = "", ""
else:
body, body_sha = result
# Ensure the whole-doc chat thread for the branch exists.
thread_id = _ensure_branch_chat_thread(slug, branch, viewer)
# Sub-threads (range/paragraph) and flags scoped to this branch.
thread_rows = db.conn().execute(
"""
SELECT id, anchor_kind, anchor_payload, thread_kind, label, state, created_by, created_at
FROM threads
WHERE rfc_slug = ? AND branch_name = ?
ORDER BY id
""",
(slug, branch),
).fetchall()
threads = [_serialize_thread(t) for t in thread_rows]
# Visibility, contribute, grants.
vis = _branch_vis(slug, branch)
grants = _branch_grants(slug, branch)
# Pending and resolved changes scoped to this branch.
changes_rows = db.conn().execute(
"""
SELECT id, thread_id, source_message_id, kind, state, original, proposed, reason,
was_edited_before_accept, stale_since, acted_by, acted_at, commit_sha, created_at
FROM changes
WHERE rfc_slug = ? AND branch_name = ?
ORDER BY id
""",
(slug, branch),
).fetchall()
changes = [_serialize_change(c) for c in changes_rows]
# Branch metadata for the breadcrumb / header.
creator = _branch_creator(slug, branch)
capabilities = _capabilities(rfc, slug, branch, viewer, creator)
return {
"slug": slug,
"title": rfc["title"],
"branch_name": branch,
"body": body,
"body_sha": body_sha,
"main_thread_id": thread_id,
"threads": threads,
"changes": changes,
"visibility": vis,
"grants": grants,
"creator": creator,
"capabilities": capabilities,
}
# -------------------------------------------------------------------
# §17: POST /api/rfcs/<slug>/branches/main/promote-to-branch
# The §8.14 "Start Contributing on main" gesture.
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/branches/main/promote-to-branch")
async def promote_to_branch(slug: str, body: PromoteToBranchBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
owner, repo = _owner_repo(rfc)
new_branch = (body.branch_name or "").strip()
if not new_branch:
new_branch = _auto_branch_name(viewer.gitea_login)
_validate_branch_name(new_branch)
try:
await bot.cut_branch_from_main(
viewer.as_actor(),
owner=owner,
repo=repo,
new_branch=new_branch,
slug=slug,
)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
# Per §8.14, re-anchor any pending main-scoped changes by
# mutating branch_name. They haven't been acted on yet, so
# there is no audit trail to corrupt; `source_message_id`
# continues to point at messages in main's chat — the schema
# permits the cross-branch reference and the UI labels it as
# "from a conversation on main."
db.conn().execute(
"""
UPDATE changes
SET branch_name = ?
WHERE rfc_slug = ? AND branch_name = 'main' AND state = 'pending'
""",
(new_branch, slug),
)
# Set the branch creator's default visibility (the spec
# defaults already match, but we materialize the row so the
# creator's identity travels with the branch).
_ensure_branch_vis(slug, new_branch, creator_user_id=viewer.user_id)
# Make the cache aware immediately so the breadcrumb reflects
# the new branch without waiting for the webhook hop.
await cache.refresh_rfc_repo(config, gitea, slug)
return {"branch_name": new_branch, "slug": slug}
# -------------------------------------------------------------------
# §17 / §8.9: accept / decline / reask a change
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/branches/{branch}/changes/{change_id}/accept")
async def accept_change(
slug: str,
branch: str,
change_id: int,
body: AcceptChangeBody,
request: Request,
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
_require_can_contribute(slug, branch, viewer)
row = _require_pending_change(slug, branch, change_id)
if row["kind"] != "ai":
raise HTTPException(409, "Manual changes are accepted via manual-flush")
# Fetch current branch body and locate the change's `original`.
owner, repo = _owner_repo(rfc)
fetched = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=branch)
if fetched is None:
raise HTTPException(409, "Branch RFC.md not found")
current_body, prior_sha = fetched
original = row["original"]
# §8.9: the fallback for ambiguous ranges — the `original` text
# appearing in more than one place — is to refuse the apply.
occurrences = current_body.count(original)
if occurrences == 0:
if not body.force_apply_stale:
# Per §8.11: mark stale and refuse. The contributor can
# re-ask, or force-apply if they judge it still applicable.
db.conn().execute(
"UPDATE changes SET stale_since = COALESCE(stale_since, datetime('now')) WHERE id = ?",
(change_id,),
)
raise HTTPException(409, "Change is stale — original text no longer in document")
# force-apply path: append the proposed text at the end of
# the document as a coarse fallback. The contributor's
# explicit consent (force_apply_stale) is the gate.
new_body = current_body.rstrip() + "\n\n" + body.proposed.strip() + "\n"
elif occurrences > 1:
raise HTTPException(409, "Change cannot be auto-applied: original text appears multiple times")
else:
new_body = current_body.replace(original, body.proposed, 1)
try:
sha = await bot.commit_accepted_change(
viewer.as_actor(),
owner=owner,
repo=repo,
branch=branch,
file_path=RFC_FILE_PATH,
new_content=new_body,
prior_sha=prior_sha,
change_id=change_id,
original=original,
proposed=body.proposed,
ai_proposed=row["proposed"] if body.was_edited_before_accept else None,
reason=row["reason"] or "",
source_message_id=row["source_message_id"],
slug=slug,
)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
db.conn().execute(
"""
UPDATE changes
SET state = 'accepted',
proposed = ?,
was_edited_before_accept = ?,
acted_by = ?, acted_at = datetime('now'),
commit_sha = ?,
stale_since = NULL
WHERE id = ?
""",
(
body.proposed,
1 if body.was_edited_before_accept else 0,
viewer.user_id,
sha,
change_id,
),
)
# Per §8.11: a successful manual or AI commit changes the
# document; mark any pending AI proposals whose anchor no
# longer locates as stale.
chat_layer.mark_stale_overlapping(rfc_slug=slug, branch_name=branch, new_body=new_body)
await cache.refresh_rfc_repo(config, gitea, slug)
return {"ok": True, "commit_sha": sha, "change_id": change_id}
@router.post("/api/rfcs/{slug}/branches/{branch}/changes/{change_id}/decline")
async def decline_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_active_rfc(slug)
_require_can_contribute(slug, branch, viewer)
row = _require_pending_change(slug, branch, change_id)
if row["kind"] != "ai":
raise HTTPException(409, "Manual changes are declined via manual-flush revert")
# Per §8.9: decline is not a commit. The card persists with
# state='declined' as evidence.
db.conn().execute(
"""
UPDATE changes
SET state = 'declined', acted_by = ?, acted_at = datetime('now')
WHERE id = ?
""",
(viewer.user_id, change_id),
)
return {"ok": True, "change_id": change_id}
@router.post("/api/rfcs/{slug}/branches/{branch}/changes/{change_id}/reask")
async def reask_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]:
"""Per §8.11: re-prompt the AI against the current text to
regenerate a proposal anchored to the new phrasing. The old
row stays for audit; the new row lands when the streaming
turn completes.
For Slice 2 the reask is a synchronous, non-streaming call
that returns the new change ids. The richer "streams the
regeneration into the originating thread" version lands when
the per-thread SSE chat surface needs it across more flows.
"""
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
_require_can_contribute(slug, branch, viewer)
row = _require_change(slug, branch, change_id)
if row["kind"] != "ai":
raise HTTPException(409, "Only AI changes can be reasked")
thread_id = row["thread_id"]
if thread_id is None:
raise HTTPException(409, "Change has no originating thread")
if not providers:
raise HTTPException(503, "No AI providers configured")
owner, repo = _owner_repo(rfc)
fetched = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=branch)
body_text = fetched[0] if fetched else ""
provider = next(iter(providers.values()))
system = chat_layer.build_system_prompt(title=rfc["title"], body=body_text)
history = chat_layer.build_history(thread_id)
reask_prompt = (
"The earlier proposal's `<original>` text no longer matches the document — "
"the contributor has edited that passage. Please regenerate your proposal "
"anchored to the current phrasing. Earlier <original>:\n\n"
f"{row['original']}\n\nEarlier <proposed>:\n\n{row['proposed']}"
)
user_id = chat_layer.append_user_message(
thread_id=thread_id, author_user_id=viewer.user_id, text=reask_prompt, quote=None
)
assistant_id = chat_layer.append_assistant_placeholder(
thread_id=thread_id, model_id=default_model
)
text = provider.send(system, history + [{"role": "user", "content": reask_prompt}])
chat_layer.finalize_assistant_message(message_id=assistant_id, text=text)
parsed = chat_layer.parse_changes(text)
new_ids = chat_layer.materialize_changes(
rfc_slug=slug,
branch_name=branch,
thread_id=thread_id,
source_message_id=assistant_id,
parsed=parsed,
)
return {
"ok": True,
"user_message_id": user_id,
"assistant_message_id": assistant_id,
"new_change_ids": new_ids,
"assistant_text": text,
}
# -------------------------------------------------------------------
# §17 / §8.11 / §10.6: manual-edit flush
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/branches/{branch}/manual-flush")
async def manual_flush(slug: str, branch: str, body: ManualFlushBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
_require_can_contribute(slug, branch, viewer)
owner, repo = _owner_repo(rfc)
fetched = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=branch)
if fetched is None:
raise HTTPException(409, "Branch RFC.md not found")
prior_body, prior_sha = fetched
if prior_body == body.new_content:
return {"ok": True, "noop": True}
# Per §8.11: the manual change is materialized as a `changes`
# row first (state='accepted' on flush, with the commit_sha
# backfilled), so the resolved card binds 1:1 to the commit.
cur = db.conn().execute(
"""
INSERT INTO changes
(rfc_slug, branch_name, kind, state, original, proposed, reason,
acted_by, acted_at)
VALUES (?, ?, 'manual', 'accepted', ?, ?, ?, ?, datetime('now'))
""",
(
slug,
branch,
prior_body,
body.new_content,
f"manual edit: {body.paragraph_count} paragraph(s) changed",
viewer.user_id,
),
)
change_id = cur.lastrowid
try:
sha = await bot.commit_manual_flush(
viewer.as_actor(),
owner=owner,
repo=repo,
branch=branch,
file_path=RFC_FILE_PATH,
new_content=body.new_content,
prior_sha=prior_sha,
change_id=change_id,
paragraph_count=body.paragraph_count,
slug=slug,
)
except GiteaError as e:
# Roll back the changes row so a failed commit doesn't
# leave a phantom resolved card in the panel.
db.conn().execute("DELETE FROM changes WHERE id = ?", (change_id,))
raise HTTPException(502, f"Gitea: {e.detail}")
db.conn().execute(
"UPDATE changes SET commit_sha = ? WHERE id = ?",
(sha, change_id),
)
# Per §10.6: every manual flush drops a system-author message
# into the branch chat. Even before the PR exists, the chat is
# the canonical evidence timeline and a silent diff shift
# would corrupt it.
main_thread_id = _ensure_branch_chat_thread(slug, branch, viewer)
chat_layer.append_system_message(
thread_id=main_thread_id,
text=f"manual edit: {body.paragraph_count} paragraph(s) changed",
)
chat_layer.mark_stale_overlapping(rfc_slug=slug, branch_name=branch, new_body=body.new_content)
await cache.refresh_rfc_repo(config, gitea, slug)
return {"ok": True, "commit_sha": sha, "change_id": change_id}
# -------------------------------------------------------------------
# §17 / §11: visibility + contribute + grants
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/branches/{branch}/visibility")
async def set_branch_visibility(slug: str, branch: str, body: VisibilityBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
creator = _branch_creator(slug, branch)
_require_branch_owner(rfc, viewer, creator)
current = _branch_vis(slug, branch)
read_public = body.read_public if body.read_public is not None else current["read_public"]
contribute_mode = body.contribute_mode or current["contribute_mode"]
db.conn().execute(
"""
INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
VALUES (?, ?, ?, ?)
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
read_public = excluded.read_public,
contribute_mode = excluded.contribute_mode
""",
(slug, branch, 1 if read_public else 0, contribute_mode),
)
return {"ok": True, "visibility": _branch_vis(slug, branch)}
@router.post("/api/rfcs/{slug}/branches/{branch}/grants")
async def add_branch_grant(slug: str, branch: str, body: GrantBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
creator = _branch_creator(slug, branch)
_require_branch_owner(rfc, viewer, creator)
grantee = db.conn().execute(
"SELECT id FROM users WHERE gitea_login = ?", (body.grantee_gitea_login,)
).fetchone()
if not grantee:
raise HTTPException(404, f"User '{body.grantee_gitea_login}' has no account in this app")
db.conn().execute(
"""
INSERT OR IGNORE INTO branch_contribute_grants
(rfc_slug, branch_name, grantee_user_id, granted_by)
VALUES (?, ?, ?, ?)
""",
(slug, branch, grantee["id"], viewer.user_id),
)
return {"ok": True, "grants": _branch_grants(slug, branch)}
@router.delete("/api/rfcs/{slug}/branches/{branch}/grants/{grantee_login}")
async def revoke_branch_grant(slug: str, branch: str, grantee_login: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
creator = _branch_creator(slug, branch)
_require_branch_owner(rfc, viewer, creator)
grantee = db.conn().execute(
"SELECT id FROM users WHERE gitea_login = ?", (grantee_login,)
).fetchone()
if grantee:
db.conn().execute(
"DELETE FROM branch_contribute_grants WHERE rfc_slug = ? AND branch_name = ? AND grantee_user_id = ?",
(slug, branch, grantee["id"]),
)
return {"ok": True, "grants": _branch_grants(slug, branch)}
# -------------------------------------------------------------------
# §17 / §8.12 / §8.13: threads
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/branches/{branch}/threads")
async def list_branch_threads(slug: str, branch: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
_require_active_rfc(slug)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
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 = ?
ORDER BY id
""",
(slug, branch),
).fetchall()
return {"items": [_serialize_thread(r) for r in rows]}
@router.post("/api/rfcs/{slug}/branches/{branch}/threads")
async def create_branch_thread(slug: str, branch: str, body: ThreadCreateBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_active_rfc(slug)
if body.thread_kind == "flag" and not body.label:
raise HTTPException(422, "Flag threads require a label")
cur = db.conn().execute(
"""
INSERT INTO threads (rfc_slug, branch_name, anchor_kind, anchor_payload,
thread_kind, label, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
slug,
branch,
body.anchor_kind,
json.dumps(body.anchor_payload) if body.anchor_payload else None,
body.thread_kind,
body.label,
viewer.user_id,
),
)
thread_id = cur.lastrowid
message_id = None
if body.message and body.thread_kind == "chat":
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}
@router.get("/api/rfcs/{slug}/branches/{branch}/threads/{thread_id}/messages")
async def get_thread_messages(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
_require_active_rfc(slug)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
thread = _require_thread(slug, branch, 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],
}
@router.post("/api/rfcs/{slug}/branches/{branch}/threads/{thread_id}/messages")
async def post_thread_message(
slug: str, branch: str, thread_id: int, body: ThreadMessageBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_active_rfc(slug)
_require_thread(slug, branch, thread_id)
# Posting in a branch chat does NOT require contribute access —
# §8.4 / §11.4: chat visibility follows read visibility, and
# posting requires contributor + read. Anonymous is gated by
# require_contributor already.
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
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}
@router.post("/api/rfcs/{slug}/branches/{branch}/threads/{thread_id}/resolve")
async def resolve_thread(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
thread = _require_thread(slug, branch, thread_id)
creator = _branch_creator(slug, branch)
if not _can_resolve_thread(rfc, thread, creator, viewer):
raise HTTPException(403, "Only the thread creator, the branch 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}
# -------------------------------------------------------------------
# §17 / §18 carryover: SSE-streaming chat turn on a thread
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/branches/{branch}/threads/{thread_id}/chat")
async def stream_chat_turn(
slug: str, branch: str, thread_id: int, body: ChatTurnBody, request: Request
):
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
thread = _require_thread(slug, branch, thread_id)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
if not providers:
raise HTTPException(503, "No AI providers configured")
model_key = body.model if body.model in providers else default_model
provider = providers[model_key]
# Fetch the live branch body so the prompt is anchored to
# what's in Gitea right now, not the cache.
owner, repo = _owner_repo(rfc)
fetched = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=branch)
body_text = fetched[0] if fetched else ""
# Per §8.12: when a chat turn carries a quote (the selection),
# the model needs to see the quote alongside the document.
prompt_text = body.text
if body.quote:
prompt_text = f'The contributor has selected this passage:\n"{body.quote}"\n\n---\n\n{body.text}'
user_message_id = chat_layer.append_user_message(
thread_id=thread_id, author_user_id=viewer.user_id, text=body.text, quote=body.quote
)
assistant_message_id = chat_layer.append_assistant_placeholder(
thread_id=thread_id, model_id=model_key
)
system = chat_layer.build_system_prompt(title=rfc["title"], body=body_text)
# History is every prior user/assistant row strictly before the
# one we just inserted; the orchestrator appends the current
# user message itself when calling the provider.
rows = db.conn().execute(
"""
SELECT role, text FROM thread_messages
WHERE thread_id = ? AND id < ? AND role IN ('user', 'assistant')
ORDER BY id
""",
(thread_id, user_message_id),
).fetchall()
history = [{"role": r["role"], "content": r["text"]} for r in rows]
async def event_stream():
async for chunk in chat_layer.stream_assistant_turn(
provider=provider,
system_prompt=system,
history=history,
user_message=prompt_text,
thread_id=thread_id,
rfc_slug=slug,
branch_name=branch,
assistant_message_id=assistant_message_id,
):
yield chunk
# Per §8.4 the response includes the assistant's message id so
# the client can bind the streamed text to a chat row that
# already exists.
headers = {
"X-Assistant-Message-Id": str(assistant_message_id),
"X-User-Message-Id": str(user_message_id),
"Cache-Control": "no-cache",
}
return StreamingResponse(event_stream(), media_type="text/event-stream", headers=headers)
# ------------------------------------------------------------------
# Permission + state helpers (closures, share `config` etc.)
# ------------------------------------------------------------------
def _require_active_rfc(slug: str):
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"] != "active":
raise HTTPException(409, f"RFC is {row['state']}, not active — Slice 4 owns super-draft edits")
if not row["repo"]:
raise HTTPException(409, "RFC has no repo")
return row
def _owner_repo(rfc) -> tuple[str, str]:
owner, repo = rfc["repo"].split("/", 1)
return owner, repo
def _ensure_branch_chat_thread(slug: str, branch: str, viewer) -> int:
"""Per §8.12: every branch has a default whole-doc chat thread.
Create it lazily on first read. The created_by is null when an
anonymous viewer triggers creation — the thread is structurally
owned by the branch, not by whoever opened the view."""
row = db.conn().execute(
"""
SELECT id FROM threads
WHERE rfc_slug = ? AND branch_name = ?
AND anchor_kind = 'whole-doc' AND thread_kind = 'chat'
ORDER BY id LIMIT 1
""",
(slug, branch),
).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 (?, ?, 'whole-doc', 'chat', NULL, ?)
""",
(slug, branch, viewer.user_id if viewer else None),
)
return cur.lastrowid
def _ensure_branch_vis(slug: str, branch: str, *, creator_user_id: int) -> None:
"""Materialize the §11.1 / §6.4 defaults row when a branch is
created. The creator identity is recovered separately by joining
against the `actions` log per §15.9.
"""
del creator_user_id # creator is sourced from actions log
db.conn().execute(
"""
INSERT OR IGNORE INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
VALUES (?, ?, 1, 'just-me')
""",
(slug, branch),
)
def _branch_vis(slug: str, branch: str) -> dict:
row = db.conn().execute(
"SELECT read_public, contribute_mode FROM branch_visibility WHERE rfc_slug = ? AND branch_name = ?",
(slug, branch),
).fetchone()
if row:
return {"read_public": bool(row["read_public"]), "contribute_mode": row["contribute_mode"]}
# §11.1 / §6.4 defaults.
return {"read_public": True, "contribute_mode": "just-me"}
def _branch_grants(slug: str, branch: str) -> list[dict]:
rows = db.conn().execute(
"""
SELECT u.gitea_login, u.display_name, g.granted_at
FROM branch_contribute_grants g
JOIN users u ON u.id = g.grantee_user_id
WHERE g.rfc_slug = ? AND g.branch_name = ?
ORDER BY g.granted_at
""",
(slug, branch),
).fetchall()
return [{"gitea_login": r["gitea_login"], "display_name": r["display_name"], "granted_at": r["granted_at"]} for r in rows]
def _branch_creator(slug: str, branch: str) -> str | None:
"""Per §15.9: the underlying-actor-not-bot rule applies to every
attribution surface. We look the creator up in the actions log."""
if branch == "main":
return None
row = db.conn().execute(
"""
SELECT on_behalf_of FROM actions
WHERE action_kind = 'create_branch' AND rfc_slug = ? AND branch_name = ?
ORDER BY id LIMIT 1
""",
(slug, branch),
).fetchone()
return row["on_behalf_of"] if row else None
def _can_read_branch(slug: str, branch: str, viewer) -> bool:
"""Per §11.1: branches default read_public=true; the creator
and owners/arbiters can still read a private branch."""
if branch == "main":
return True
vis = _branch_vis(slug, branch)
if vis["read_public"]:
return True
if viewer is None:
return False
if viewer.role in ("owner", "admin"):
return True
creator = _branch_creator(slug, branch)
if creator and viewer.gitea_login == creator:
return True
rfc = db.conn().execute("SELECT owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if rfc:
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
# Explicit grant (used for the §11.4 "specific" contribute case;
# grant-ees inherit read on the §11.1 default-private branch).
row = db.conn().execute(
"""
SELECT 1 FROM branch_contribute_grants g
WHERE g.rfc_slug = ? AND g.branch_name = ? AND g.grantee_user_id = ?
""",
(slug, branch, viewer.user_id),
).fetchone()
return row is not None
def _can_contribute(rfc, slug: str, branch: str, viewer) -> bool:
"""§6.4 contribute_mode + §6.3 per-RFC authority + §6.1 admin/owner."""
if viewer is None:
return False
if branch == "main":
return False # main is read-only per §8.3; PRs are the only path
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
creator = _branch_creator(slug, branch)
if creator and viewer.gitea_login == creator:
return True
vis = _branch_vis(slug, branch)
if vis["contribute_mode"] == "any-contributor":
return True
if vis["contribute_mode"] == "specific":
row = db.conn().execute(
"""
SELECT 1 FROM branch_contribute_grants
WHERE rfc_slug = ? AND branch_name = ? AND grantee_user_id = ?
""",
(slug, branch, viewer.user_id),
).fetchone()
return row is not None
return False
def _require_can_contribute(slug: str, branch: str, viewer) -> None:
rfc = db.conn().execute(
"SELECT owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if not _can_contribute(rfc, slug, branch, viewer):
raise HTTPException(403, "You do not have contribute access to this branch")
def _require_branch_owner(rfc, viewer, creator: str | None) -> None:
"""The set who can flip visibility / add grants: branch creator,
owners/arbiters, and app admins/owners per §11.1, §11.2, §6.3."""
if viewer.role in ("owner", "admin"):
return
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
if creator and viewer.gitea_login == creator:
return
raise HTTPException(403, "Only the branch creator, an RFC owner/arbiter, or an admin/owner may change branch settings")
def _capabilities(rfc, slug: str, branch: str, viewer, creator: str | None) -> dict:
return {
"can_read": _can_read_branch(slug, branch, viewer),
"can_contribute": _can_contribute(rfc, slug, branch, viewer) if viewer else False,
"can_change_branch_settings": viewer is not None and (
viewer.role in ("owner", "admin")
or (creator is not None and viewer.gitea_login == creator)
or viewer.gitea_login in (json.loads(rfc["owners_json"] or "[]") + json.loads(rfc["arbiters_json"] or "[]"))
),
"is_anonymous": viewer is None,
}
def _branch_summary(slug: str, br, viewer) -> dict:
return {
"name": br["branch_name"],
"head_sha": br["head_sha"],
"state": br["state"],
"last_commit_at": br["last_commit_at"],
"pinned": bool(br["pinned"]),
"creator": _branch_creator(slug, br["branch_name"]),
"visibility": _branch_vis(slug, br["branch_name"]),
}
def _require_change(slug: str, branch: str, change_id: int):
row = db.conn().execute(
"SELECT * FROM changes WHERE id = ? AND rfc_slug = ? AND branch_name = ?",
(change_id, slug, branch),
).fetchone()
if not row:
raise HTTPException(404, "Change not found on this branch")
return row
def _require_pending_change(slug: str, branch: str, change_id: int):
row = _require_change(slug, branch, change_id)
if row["state"] != "pending":
raise HTTPException(409, f"Change is already {row['state']}")
return row
def _require_thread(slug: str, branch: str, thread_id: int):
row = db.conn().execute(
"SELECT * FROM threads WHERE id = ? AND rfc_slug = ? AND branch_name = ?",
(thread_id, slug, branch),
).fetchone()
if not row:
raise HTTPException(404, "Thread not found on this branch")
return row
def _can_resolve_thread(rfc, thread, creator: str | None, 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 creator and viewer.gitea_login == creator:
return True
if thread["created_by"] == viewer.user_id:
return True
return False
return router
# ---------------------------------------------------------------------------
# Serialization helpers (module-level for clarity)
# ---------------------------------------------------------------------------
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_change(row) -> dict[str, Any]:
return {
"id": row["id"],
"thread_id": row["thread_id"],
"source_message_id": row["source_message_id"],
"kind": row["kind"],
"state": row["state"],
"original": row["original"],
"proposed": row["proposed"],
"reason": row["reason"],
"was_edited_before_accept": bool(row["was_edited_before_accept"]),
"stale_since": row["stale_since"],
"acted_by": row["acted_by"],
"acted_at": row["acted_at"],
"commit_sha": row["commit_sha"],
"created_at": row["created_at"],
}
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"],
}
# ---------------------------------------------------------------------------
# Branch name validation + auto-generation per §8.14
# ---------------------------------------------------------------------------
_BRANCH_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._\-/]*[a-z0-9]$")
def _validate_branch_name(name: str) -> None:
# §8.14: "exact format is an implementation detail." We accept Git's
# standard ref-friendly subset and reject anything with whitespace,
# leading/trailing punctuation, or path components Gitea would refuse.
if len(name) > 120 or not _BRANCH_NAME_RE.match(name):
raise HTTPException(422, "Branch name must be lowercase alphanumerics, hyphens, dots, slashes")
if name == "main" or name.startswith(("propose/", "edit/", "claim/", "metadata/")):
raise HTTPException(422, "Branch name conflicts with a reserved prefix")
def _auto_branch_name(login: str) -> str:
# Per §8.14: "auto-generated value (user-renamable); the exact
# format is an implementation detail." We use `<login>-draft-<hex>`
# — no slash so FastAPI's default {branch} path segment matches —
# which keeps the branch's origin legible in the Git log without
# depending on the `actions` join to render it. Users who type
# their own name and want a slash can still do so; the
# {branch:path}-tolerant routing in the next slice covers that.
import secrets
return f"{login.lower()}-draft-{secrets.token_hex(3)}"