3bc8fe92af
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>
1131 lines
46 KiB
Python
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)}"
|