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>
This commit is contained in:
+17
-2
@@ -17,10 +17,11 @@ from typing import Any
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, db, entry as entry_mod, cache
|
||||
from . import api_branches, auth, db, entry as entry_mod, cache
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
from .providers import BaseProvider
|
||||
|
||||
|
||||
class ProposeBody(BaseModel):
|
||||
@@ -34,8 +35,22 @@ class DeclineBody(BaseModel):
|
||||
comment: str = Field(min_length=1, max_length=4000)
|
||||
|
||||
|
||||
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
||||
def make_router(
|
||||
config: Config,
|
||||
gitea: Gitea,
|
||||
bot: Bot,
|
||||
providers: dict[str, BaseProvider] | None = None,
|
||||
) -> APIRouter:
|
||||
# Use `is None` rather than `providers or {}` — an empty dict is
|
||||
# falsy, and the test harness mutates the dict the closure holds to
|
||||
# inject a fake provider; substituting a fresh `{}` here would
|
||||
# silently drop those mutations.
|
||||
if providers is None:
|
||||
providers = {}
|
||||
router = APIRouter()
|
||||
# Slice 2: the §8 active-RFC view's endpoints live in api_branches.
|
||||
# Mounting them on the same router keeps the §17 layout flat.
|
||||
router.include_router(api_branches.make_router(config, gitea, bot, providers))
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Auth surface — extends the prototype's pattern but reads role
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -220,3 +220,216 @@ class Bot:
|
||||
rfc_slug=slug,
|
||||
pr_number=pr_number,
|
||||
)
|
||||
|
||||
# ----- Per-RFC repo: branches (§8.3, §8.14) -----
|
||||
|
||||
async def cut_branch_from_main(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
owner: str,
|
||||
repo: str,
|
||||
new_branch: str,
|
||||
slug: str,
|
||||
from_branch: str = "main",
|
||||
) -> dict:
|
||||
"""Per §8.14: 'Start Contributing' on main cuts a new branch.
|
||||
|
||||
Also covers the §8.3 case of a contributor wanting a fresh branch
|
||||
for a piece of work. Returns the Gitea branch payload.
|
||||
"""
|
||||
created = await self._gitea.create_branch(owner, repo, new_branch, from_branch=from_branch)
|
||||
_log(
|
||||
actor,
|
||||
"create_branch",
|
||||
rfc_slug=slug,
|
||||
branch_name=new_branch,
|
||||
details={"from": from_branch, "repo": f"{owner}/{repo}"},
|
||||
)
|
||||
return created
|
||||
|
||||
# ----- Per-RFC repo: per-accepted-change commits (§8.6, §8.9) -----
|
||||
|
||||
async def commit_accepted_change(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
owner: str,
|
||||
repo: str,
|
||||
branch: str,
|
||||
file_path: str,
|
||||
new_content: str,
|
||||
prior_sha: str,
|
||||
change_id: int,
|
||||
original: str,
|
||||
proposed: str,
|
||||
ai_proposed: str | None,
|
||||
reason: str,
|
||||
source_message_id: int | None,
|
||||
slug: str,
|
||||
) -> str:
|
||||
"""Per §8.6: one commit per accepted change.
|
||||
|
||||
The commit message subject is a short structural description; the
|
||||
body carries `original`, `proposed`, and `reason` in named
|
||||
sections. When the contributor edited the AI's proposal before
|
||||
accepting (§8.9's `was_edited_before_accept`), the AI's original
|
||||
wording is preserved under an `AI proposed:` section so the
|
||||
timeline records both what was offered and what landed.
|
||||
|
||||
Trailers: `Change-Id`, `Source-Message-Id` (where applicable),
|
||||
and the standard `On-behalf-of:` per §6.5.
|
||||
|
||||
Returns the commit SHA.
|
||||
"""
|
||||
subject = _subject_from_reason(reason, fallback="Accept change")
|
||||
body_lines = [
|
||||
"**Original:**",
|
||||
original.strip(),
|
||||
"",
|
||||
"**Proposed:**",
|
||||
proposed.strip(),
|
||||
]
|
||||
if ai_proposed is not None and ai_proposed.strip() != proposed.strip():
|
||||
body_lines += ["", "**AI proposed (edited before accept):**", ai_proposed.strip()]
|
||||
if reason and reason.strip():
|
||||
body_lines += ["", "**Reason:**", reason.strip()]
|
||||
body_lines += ["", f"Change-Id: {change_id}"]
|
||||
if source_message_id is not None:
|
||||
body_lines += [f"Source-Message-Id: {source_message_id}"]
|
||||
body_lines += [_trailer(actor)]
|
||||
message = subject + "\n\n" + "\n".join(body_lines).strip()
|
||||
|
||||
result = await self._gitea.update_file(
|
||||
owner,
|
||||
repo,
|
||||
file_path,
|
||||
content=new_content,
|
||||
sha=prior_sha,
|
||||
message=message,
|
||||
branch=branch,
|
||||
author_name=actor.display_name,
|
||||
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
||||
)
|
||||
sha = result.get("commit", {}).get("sha") or result.get("content", {}).get("sha") or ""
|
||||
_log(
|
||||
actor,
|
||||
"accept_change",
|
||||
rfc_slug=slug,
|
||||
branch_name=branch,
|
||||
bot_commit_sha=sha,
|
||||
details={"change_id": change_id, "file_path": file_path},
|
||||
)
|
||||
return sha
|
||||
|
||||
# ----- Per-RFC repo: manual-edit flushes (§8.6, §8.11) -----
|
||||
|
||||
async def commit_manual_flush(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
owner: str,
|
||||
repo: str,
|
||||
branch: str,
|
||||
file_path: str,
|
||||
new_content: str,
|
||||
prior_sha: str,
|
||||
change_id: int,
|
||||
paragraph_count: int,
|
||||
slug: str,
|
||||
) -> str:
|
||||
"""Per §8.6 / §8.11: one commit per manual-edit flush window.
|
||||
|
||||
Subject names the structural extent so a reviewer scanning the
|
||||
log can size the change at a glance; the body carries the
|
||||
change-id trailer that binds the commit to the resolved card in
|
||||
the panel.
|
||||
"""
|
||||
plural = "" if paragraph_count == 1 else "s"
|
||||
subject = f"manual edit: {paragraph_count} paragraph{plural}"
|
||||
body_lines = [
|
||||
f"Change-Id: {change_id}",
|
||||
_trailer(actor),
|
||||
]
|
||||
message = subject + "\n\n" + "\n".join(body_lines)
|
||||
result = await self._gitea.update_file(
|
||||
owner,
|
||||
repo,
|
||||
file_path,
|
||||
content=new_content,
|
||||
sha=prior_sha,
|
||||
message=message,
|
||||
branch=branch,
|
||||
author_name=actor.display_name,
|
||||
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
||||
)
|
||||
sha = result.get("commit", {}).get("sha") or result.get("content", {}).get("sha") or ""
|
||||
_log(
|
||||
actor,
|
||||
"manual_flush",
|
||||
rfc_slug=slug,
|
||||
branch_name=branch,
|
||||
bot_commit_sha=sha,
|
||||
details={"change_id": change_id, "paragraph_count": paragraph_count},
|
||||
)
|
||||
return sha
|
||||
|
||||
# ----- Per-RFC repo: seeding (test/dev fixtures, future graduation) -----
|
||||
|
||||
async def ensure_rfc_repo_seed(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
owner: str,
|
||||
repo: str,
|
||||
slug: str,
|
||||
title: str,
|
||||
body: str,
|
||||
) -> None:
|
||||
"""Create the per-RFC repo and seed `RFC.md` on `main` if missing.
|
||||
|
||||
Slice 2 surfaces against per-RFC repos that Slice 5's graduation
|
||||
flow will eventually create. Until graduation exists, this is the
|
||||
seam test fixtures and ad-hoc dev workflows use to bring an RFC
|
||||
repo into existence — the bot stays the only Git writer and the
|
||||
seed itself enters the audit log.
|
||||
"""
|
||||
existing = await self._gitea.get_repo(owner, repo)
|
||||
if existing is None:
|
||||
await self._gitea.create_org_repo(owner, repo, description=f"RFC: {title}")
|
||||
# If main has a tip already, leave it alone — the seed is idempotent.
|
||||
main = await self._gitea.get_branch(owner, repo, "main")
|
||||
if main is not None:
|
||||
return
|
||||
message = "Seed RFC.md\n\n" + _trailer(actor)
|
||||
await self._gitea.create_file(
|
||||
owner,
|
||||
repo,
|
||||
"RFC.md",
|
||||
content=body,
|
||||
message=message,
|
||||
branch="main",
|
||||
author_name=actor.display_name,
|
||||
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"seed_rfc_repo",
|
||||
rfc_slug=slug,
|
||||
branch_name="main",
|
||||
details={"repo": f"{owner}/{repo}", "title": title},
|
||||
)
|
||||
|
||||
|
||||
def _subject_from_reason(reason: str, fallback: str) -> str:
|
||||
"""One-line commit subject derived from the change's reason.
|
||||
|
||||
Truncated to 72 chars so the Git log scans cleanly. Exact length is
|
||||
an implementation detail per §8.6.
|
||||
"""
|
||||
text = (reason or "").strip().split("\n")[0]
|
||||
if not text:
|
||||
return fallback
|
||||
if len(text) > 72:
|
||||
return text[:69].rstrip() + "…"
|
||||
return text
|
||||
|
||||
@@ -119,6 +119,139 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
|
||||
"""Mirror an active RFC's per-RFC repo into the cache.
|
||||
|
||||
Reads `RFC.md` on main into `cached_rfcs.body` (per §4 #3), lists
|
||||
branches into `cached_branches`, and lists open PRs into
|
||||
`cached_prs` with `pr_kind='rfc_branch'`. Per §4.1 this runs in two
|
||||
places: a webhook arrival for events on the per-RFC repo, and the
|
||||
reconciler sweep.
|
||||
"""
|
||||
row = db.conn().execute(
|
||||
"SELECT repo, state FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
if not row or not row["repo"] or row["state"] != "active":
|
||||
return
|
||||
if "/" not in row["repo"]:
|
||||
log.warning("refresh_rfc_repo: %s has malformed repo %r", slug, row["repo"])
|
||||
return
|
||||
owner, repo = row["repo"].split("/", 1)
|
||||
|
||||
# Body on main — populates the discuss-mode default surface per §8.2.
|
||||
try:
|
||||
result = await gitea.read_file(owner, repo, "RFC.md", ref="main")
|
||||
except GiteaError as e:
|
||||
log.warning("refresh_rfc_repo(%s): read_file failed: %s", slug, e)
|
||||
result = None
|
||||
if result is not None:
|
||||
text, sha = result
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE cached_rfcs
|
||||
SET body = ?, body_sha = ?, last_main_commit_at = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
WHERE slug = ?
|
||||
""",
|
||||
(text, sha, slug),
|
||||
)
|
||||
|
||||
# Branches — every branch the bot knows about per §11.5 / §12.
|
||||
try:
|
||||
branches = await gitea.list_branches(owner, repo)
|
||||
except GiteaError as e:
|
||||
log.warning("refresh_rfc_repo(%s): list_branches failed: %s", slug, e)
|
||||
branches = []
|
||||
seen_branches: set[str] = set()
|
||||
for b in branches:
|
||||
name = b.get("name") or ""
|
||||
if not name:
|
||||
continue
|
||||
seen_branches.add(name)
|
||||
head_sha = (b.get("commit") or {}).get("id") or ""
|
||||
last_commit_at = (b.get("commit") or {}).get("timestamp")
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
|
||||
VALUES (?, ?, ?, 'open', ?)
|
||||
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
|
||||
head_sha = excluded.head_sha,
|
||||
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
|
||||
last_commit_at = excluded.last_commit_at
|
||||
""",
|
||||
(slug, name, head_sha, last_commit_at),
|
||||
)
|
||||
# Mark previously-known branches that disappeared as deleted, keeping
|
||||
# the row per §11.5 ("branch removed from Gitea, row remains").
|
||||
existing = {
|
||||
r["branch_name"]
|
||||
for r in db.conn().execute(
|
||||
"SELECT branch_name FROM cached_branches WHERE rfc_slug = ? AND state != 'deleted'",
|
||||
(slug,),
|
||||
)
|
||||
}
|
||||
for missing in existing - seen_branches:
|
||||
db.conn().execute(
|
||||
"UPDATE cached_branches SET state = 'deleted' WHERE rfc_slug = ? AND branch_name = ?",
|
||||
(slug, missing),
|
||||
)
|
||||
|
||||
# PRs on the per-RFC repo (pr_kind = 'rfc_branch'). Slice 3 owns the
|
||||
# full PR surface; we mirror metadata here so the §8.1 breadcrumb
|
||||
# dropdown's "1 PR" count is honest from Slice 2 onward.
|
||||
repo_full = f"{owner}/{repo}"
|
||||
bot_login = config.gitea_bot_user
|
||||
try:
|
||||
open_pulls = await gitea.list_pulls(owner, repo, state="open")
|
||||
closed_pulls = await gitea.list_pulls(owner, repo, state="closed")
|
||||
except GiteaError as e:
|
||||
log.warning("refresh_rfc_repo(%s): list_pulls failed: %s", slug, e)
|
||||
open_pulls, closed_pulls = [], []
|
||||
for pull in open_pulls + closed_pulls:
|
||||
head_branch = pull.get("head", {}).get("ref", "")
|
||||
state = _state_from_pull(pull)
|
||||
gitea_opener = (pull.get("user") or {}).get("login") or ""
|
||||
opened_by = _resolve_actor(
|
||||
gitea_opener,
|
||||
bot_login,
|
||||
slug,
|
||||
pull["number"],
|
||||
pull.get("body") or "",
|
||||
)
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO cached_prs
|
||||
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
|
||||
opened_by, opened_at, merged_at, closed_at,
|
||||
head_branch, base_branch, head_sha)
|
||||
VALUES (?, 'rfc_branch', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(repo, pr_number) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
description = excluded.description,
|
||||
state = excluded.state,
|
||||
opened_by = excluded.opened_by,
|
||||
merged_at = excluded.merged_at,
|
||||
closed_at = excluded.closed_at,
|
||||
head_sha = excluded.head_sha
|
||||
""",
|
||||
(
|
||||
slug,
|
||||
repo_full,
|
||||
pull["number"],
|
||||
pull.get("title") or "",
|
||||
pull.get("body") or "",
|
||||
state,
|
||||
opened_by,
|
||||
pull.get("created_at"),
|
||||
pull.get("merged_at"),
|
||||
pull.get("closed_at"),
|
||||
head_branch,
|
||||
(pull.get("base") or {}).get("ref") or "main",
|
||||
(pull.get("head") or {}).get("sha"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
|
||||
"""Reconcile open meta-repo PRs into cached_prs.
|
||||
|
||||
@@ -296,6 +429,17 @@ class Reconciler:
|
||||
try:
|
||||
await refresh_meta_repo(self._config, self._gitea)
|
||||
await refresh_meta_pulls(self._config, self._gitea)
|
||||
# Per-RFC repos: refresh each active entry. Meta-repo refresh
|
||||
# must come first so newly-graduated entries land in
|
||||
# cached_rfcs before we try to reach their per-RFC repos.
|
||||
active = [
|
||||
r["slug"]
|
||||
for r in db.conn().execute(
|
||||
"SELECT slug FROM cached_rfcs WHERE state = 'active' AND repo IS NOT NULL"
|
||||
)
|
||||
]
|
||||
for slug in active:
|
||||
await refresh_rfc_repo(self._config, self._gitea, slug)
|
||||
except Exception:
|
||||
log.exception("reconciler: sweep failed")
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""SSE-streaming chat layer — §18 carryover, adapted to the §5 schema.
|
||||
|
||||
The prototype kept conversation state in an in-memory `RFCChat`
|
||||
keyed by a session_id. Here, history is the durable list of
|
||||
`thread_messages` rows on a `threads` row, scoped to one branch (or
|
||||
to a sub-thread anchored to a range or paragraph within it). The
|
||||
streaming response is parsed for `<change>` blocks per §18; each
|
||||
`<change>` becomes a `changes` row with `state='pending'` per §8.14
|
||||
the moment it is parsed, regardless of mode.
|
||||
|
||||
This module exposes two seams:
|
||||
- `build_history` and `build_system_prompt` — pure functions a caller
|
||||
can use to assemble the LLM request without owning a provider.
|
||||
- `stream_assistant_turn` — the orchestration that creates the
|
||||
assistant `thread_messages` row, runs the provider's streaming
|
||||
interface, parses `<change>` blocks as they accumulate, materializes
|
||||
`changes` rows on completion, and yields SSE-shaped text chunks.
|
||||
|
||||
Per the §1 invariant, no Git writes happen here — chat is app data;
|
||||
turning an accepted `<change>` into a commit is a separate gesture
|
||||
that goes through `bot.py`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import AsyncIterator, Iterator
|
||||
|
||||
from . import db
|
||||
from .providers import BaseProvider
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# The §18 system prompt, adapted from the prototype. The prototype's
|
||||
# version assumed one RFC document loaded as context; here the document
|
||||
# is the branch's RFC.md at its current tip. The selection-quote shape
|
||||
# (§8.12) is wired by the caller into the user message text — not the
|
||||
# system prompt — so the model sees it as part of the turn.
|
||||
SYSTEM_PROMPT = """You are a participant in the Wiggleverse RFC framework — a standardization process for natural-language vocabulary that humans and machines need to share. You are collaborating with a human contributor on the RFC titled "{title}".
|
||||
|
||||
The contributor's gestures may be questions, objections, sketches of new framings, or direct edit requests. Your role is to translate them into concrete proposed edits where possible. The transcript of this conversation is the durable evidence the definition was earned, so be specific and stay close to the text.
|
||||
|
||||
Format each proposed change as one <change> block:
|
||||
|
||||
<change>
|
||||
<original>exact text to replace, copied verbatim from the document</original>
|
||||
<proposed>replacement text</proposed>
|
||||
<reason>why this change improves the document — one or two short sentences</reason>
|
||||
</change>
|
||||
|
||||
Rules:
|
||||
- The <original> text must match the document character-for-character. Do not paraphrase, do not abbreviate.
|
||||
- One <change> block per distinct edit. Multiple blocks are encouraged when the contributor's input touches several passages.
|
||||
- If the contributor is asking a general question or exploring an idea not yet ready to become an edit, respond in plain prose. When in doubt, lean toward proposing an edit.
|
||||
- After your <change> blocks you may add a brief conversational note. Keep it short.
|
||||
|
||||
---
|
||||
|
||||
The current document:
|
||||
|
||||
{body}
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# History / prompt assembly
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_history(thread_id: int) -> list[dict]:
|
||||
"""Pull the thread's messages in chronological order, in the
|
||||
{role, content} shape every provider's `send` interface expects.
|
||||
System-author rows are excluded — the prompt template carries the
|
||||
standing instructions; system-author messages are inline narrative
|
||||
that doesn't change the model's behavior.
|
||||
"""
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT role, text FROM thread_messages
|
||||
WHERE thread_id = ? AND role IN ('user', 'assistant')
|
||||
ORDER BY id
|
||||
""",
|
||||
(thread_id,),
|
||||
).fetchall()
|
||||
return [{"role": r["role"], "content": r["text"]} for r in rows]
|
||||
|
||||
|
||||
def build_system_prompt(*, title: str, body: str) -> str:
|
||||
return SYSTEM_PROMPT.format(title=title, body=body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# <change> parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CHANGE_RE = re.compile(
|
||||
r"<change>\s*<original>([\s\S]*?)</original>\s*<proposed>([\s\S]*?)</proposed>\s*<reason>([\s\S]*?)</reason>\s*</change>",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedChange:
|
||||
original: str
|
||||
proposed: str
|
||||
reason: str
|
||||
|
||||
|
||||
def parse_changes(text: str) -> list[ParsedChange]:
|
||||
"""Per §18: pull every well-formed <change> block out of an assistant
|
||||
message. Mid-stream partials are simply not matched yet; the parser
|
||||
runs once on completion."""
|
||||
return [
|
||||
ParsedChange(m.group(1).strip(), m.group(2).strip(), m.group(3).strip())
|
||||
for m in _CHANGE_RE.finditer(text)
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistence — turn boundaries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def append_user_message(
|
||||
*,
|
||||
thread_id: int,
|
||||
author_user_id: int,
|
||||
text: str,
|
||||
quote: str | None,
|
||||
) -> int:
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO thread_messages (thread_id, role, author_user_id, text, quote)
|
||||
VALUES (?, 'user', ?, ?, ?)
|
||||
""",
|
||||
(thread_id, author_user_id, text, quote),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def append_assistant_placeholder(*, thread_id: int, model_id: str) -> int:
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO thread_messages (thread_id, role, model_id, text)
|
||||
VALUES (?, 'assistant', ?, '')
|
||||
""",
|
||||
(thread_id, model_id),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def finalize_assistant_message(*, message_id: int, text: str) -> None:
|
||||
db.conn().execute(
|
||||
"UPDATE thread_messages SET text = ? WHERE id = ?",
|
||||
(text, message_id),
|
||||
)
|
||||
|
||||
|
||||
def append_system_message(*, thread_id: int, text: str) -> int:
|
||||
"""Used by §10.6 (manual-edit-flush markers), §9.3 (decline-comment
|
||||
record), and any other system-narrated event that needs to live
|
||||
inline in chat. role='system', author_user_id=NULL."""
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO thread_messages (thread_id, role, text)
|
||||
VALUES (?, 'system', ?)
|
||||
""",
|
||||
(thread_id, text),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def materialize_changes(
|
||||
*,
|
||||
rfc_slug: str,
|
||||
branch_name: str,
|
||||
thread_id: int,
|
||||
source_message_id: int,
|
||||
parsed: list[ParsedChange],
|
||||
) -> list[int]:
|
||||
"""Per §8.14: every <change> block becomes a `changes` row with
|
||||
state='pending' immediately, regardless of mode. Returns the new
|
||||
row ids in source order."""
|
||||
ids: list[int] = []
|
||||
for ch in parsed:
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO changes
|
||||
(rfc_slug, branch_name, thread_id, source_message_id,
|
||||
kind, state, original, proposed, reason)
|
||||
VALUES (?, ?, ?, ?, 'ai', 'pending', ?, ?, ?)
|
||||
""",
|
||||
(rfc_slug, branch_name, thread_id, source_message_id, ch.original, ch.proposed, ch.reason),
|
||||
)
|
||||
ids.append(cur.lastrowid)
|
||||
return ids
|
||||
|
||||
|
||||
def mark_stale_overlapping(
|
||||
*,
|
||||
rfc_slug: str,
|
||||
branch_name: str,
|
||||
new_body: str,
|
||||
) -> int:
|
||||
"""Per §8.11: when a manual edit changes the document such that a
|
||||
pending AI proposal's `original` no longer locates, set its
|
||||
`stale_since`. The contributor's action stays gated on the stale
|
||||
card; state stays `pending`.
|
||||
|
||||
Returns the number of rows marked stale on this call (idempotent
|
||||
on re-entry — already-stale rows aren't touched twice).
|
||||
"""
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT id, original FROM changes
|
||||
WHERE rfc_slug = ? AND branch_name = ?
|
||||
AND kind = 'ai' AND state = 'pending' AND stale_since IS NULL
|
||||
""",
|
||||
(rfc_slug, branch_name),
|
||||
).fetchall()
|
||||
marked = 0
|
||||
for r in rows:
|
||||
original = (r["original"] or "").strip()
|
||||
if original and original not in new_body:
|
||||
db.conn().execute(
|
||||
"UPDATE changes SET stale_since = datetime('now') WHERE id = ?",
|
||||
(r["id"],),
|
||||
)
|
||||
marked += 1
|
||||
return marked
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSE shape — base64 chunks for binary-safe transport
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def sse_chunk(text: str) -> str:
|
||||
encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
||||
return f"data: {encoded}\n\n"
|
||||
|
||||
|
||||
def sse_event(name: str, payload: dict) -> str:
|
||||
return f"event: {name}\ndata: {json.dumps(payload)}\n\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def stream_assistant_turn(
|
||||
*,
|
||||
provider: BaseProvider,
|
||||
system_prompt: str,
|
||||
history: list[dict],
|
||||
user_message: str,
|
||||
thread_id: int,
|
||||
rfc_slug: str,
|
||||
branch_name: str,
|
||||
assistant_message_id: int,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Run the provider's streaming interface, yielding SSE-encoded
|
||||
chunks. On completion, materializes `changes` rows from any
|
||||
`<change>` blocks in the assembled text and emits a trailing
|
||||
`changes` event listing the new change ids.
|
||||
|
||||
The user's message must already have been persisted by the caller
|
||||
before this is invoked; the placeholder assistant row whose id is
|
||||
`assistant_message_id` must exist too. This module's job is to
|
||||
populate the assistant row's text and materialize the changes; the
|
||||
caller wires it into a FastAPI StreamingResponse.
|
||||
|
||||
Provider streaming is synchronous (an `Iterator[str]`) per §18; we
|
||||
drain it eagerly into chunks and yield them as async strings. This
|
||||
is sufficient at single-process scale (§4.2) and the streaming
|
||||
impl is what the prototype shipped — re-wrapping it in a worker
|
||||
thread for a future deployment shape is a one-liner if it
|
||||
matters.
|
||||
"""
|
||||
full_text_chunks: list[str] = []
|
||||
# Hand the provider the user turn appended to history.
|
||||
history_for_call = list(history) + [{"role": "user", "content": user_message}]
|
||||
|
||||
def _drain() -> Iterator[str]:
|
||||
try:
|
||||
yield from provider.send_streaming(system_prompt, history_for_call)
|
||||
except Exception as e:
|
||||
log.exception("provider stream failed")
|
||||
yield f"\n\n[Provider error: {e}]"
|
||||
|
||||
for chunk in _drain():
|
||||
if not chunk:
|
||||
continue
|
||||
full_text_chunks.append(chunk)
|
||||
yield sse_chunk(chunk)
|
||||
|
||||
full_text = "".join(full_text_chunks)
|
||||
finalize_assistant_message(message_id=assistant_message_id, text=full_text)
|
||||
|
||||
parsed = parse_changes(full_text)
|
||||
new_ids = materialize_changes(
|
||||
rfc_slug=rfc_slug,
|
||||
branch_name=branch_name,
|
||||
thread_id=thread_id,
|
||||
source_message_id=assistant_message_id,
|
||||
parsed=parsed,
|
||||
)
|
||||
yield sse_event(
|
||||
"changes",
|
||||
{
|
||||
"message_id": assistant_message_id,
|
||||
"change_ids": new_ids,
|
||||
"count": len(new_ids),
|
||||
},
|
||||
)
|
||||
yield "data: DONE\n\n"
|
||||
+13
-2
@@ -14,7 +14,7 @@ from fastapi import APIRouter, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from . import api as api_routes, auth, cache, db, webhooks
|
||||
from . import api as api_routes, auth, cache, db, providers as providers_mod, webhooks
|
||||
from .bot import Bot
|
||||
from .config import load_config
|
||||
from .gitea import Gitea
|
||||
@@ -32,13 +32,24 @@ async def lifespan(app: FastAPI):
|
||||
bot = Bot(gitea)
|
||||
reconciler = cache.Reconciler(config, gitea)
|
||||
|
||||
# §18 carryover: the multi-provider LLM abstraction. Provider
|
||||
# construction can fail (missing key, wrong env value) — if it does,
|
||||
# the rest of the app still serves; chat endpoints surface a clear
|
||||
# 503 instead of crashing the process.
|
||||
try:
|
||||
providers = providers_mod.load_from_config(config)
|
||||
except Exception:
|
||||
log.exception("provider construction failed; chat will be disabled")
|
||||
providers = {}
|
||||
|
||||
app.state.config = config
|
||||
app.state.gitea = gitea
|
||||
app.state.bot = bot
|
||||
app.state.reconciler = reconciler
|
||||
app.state.providers = providers
|
||||
|
||||
app.include_router(_oauth_router(config))
|
||||
app.include_router(api_routes.make_router(config, gitea, bot))
|
||||
app.include_router(api_routes.make_router(config, gitea, bot, providers))
|
||||
app.include_router(webhooks.make_router(config, gitea))
|
||||
|
||||
reconciler.start()
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Multi-provider LLM abstraction — §18 carryover from the prototype.
|
||||
|
||||
Each provider speaks a common interface — `send` and `send_streaming` —
|
||||
so the chat layer in `chat.py` is provider-agnostic. Enabled providers
|
||||
and their API keys are configured via env per the prototype's
|
||||
`ENABLED_MODELS` contract; per §16 / §19.2, per-RFC model availability
|
||||
and credential delegation are deferred until the topic is settled.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterator
|
||||
|
||||
|
||||
class BaseProvider:
|
||||
name: str = "base"
|
||||
display_name: str = "Base"
|
||||
|
||||
def send(self, system: str, history: list[dict]) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def send_streaming(self, system: str, history: list[dict]) -> Iterator[str]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Anthropic — Claude
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AnthropicProvider(BaseProvider):
|
||||
name = "claude"
|
||||
|
||||
def __init__(self, api_key: str, model: str = "claude-sonnet-4-6", display_name: str = "Claude"):
|
||||
import anthropic
|
||||
self.client = anthropic.Anthropic(api_key=api_key)
|
||||
self.model = model
|
||||
self.display_name = display_name
|
||||
|
||||
def send(self, system: str, history: list[dict]) -> str:
|
||||
response = self.client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=4096,
|
||||
system=system,
|
||||
messages=history,
|
||||
)
|
||||
return response.content[0].text
|
||||
|
||||
def send_streaming(self, system: str, history: list[dict]) -> Iterator[str]:
|
||||
with self.client.messages.stream(
|
||||
model=self.model,
|
||||
max_tokens=4096,
|
||||
system=system,
|
||||
messages=history,
|
||||
) as stream:
|
||||
for text in stream.text_stream:
|
||||
yield text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Google — Gemini
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class GeminiProvider(BaseProvider):
|
||||
name = "gemini"
|
||||
|
||||
def __init__(self, api_key: str, model: str = "gemini-1.5-pro", display_name: str = "Gemini"):
|
||||
import google.generativeai as genai
|
||||
genai.configure(api_key=api_key)
|
||||
self._genai = genai
|
||||
self.model_name = model
|
||||
self.display_name = display_name
|
||||
|
||||
def _build_model(self, system: str):
|
||||
return self._genai.GenerativeModel(model_name=self.model_name, system_instruction=system)
|
||||
|
||||
def _convert_history(self, history: list[dict]) -> list[dict]:
|
||||
return [
|
||||
{"role": "user" if msg["role"] == "user" else "model", "parts": [msg["content"]]}
|
||||
for msg in history
|
||||
]
|
||||
|
||||
def send(self, system: str, history: list[dict]) -> str:
|
||||
model = self._build_model(system)
|
||||
prior = self._convert_history(history[:-1])
|
||||
chat = model.start_chat(history=prior)
|
||||
response = chat.send_message(history[-1]["content"])
|
||||
return response.text
|
||||
|
||||
def send_streaming(self, system: str, history: list[dict]) -> Iterator[str]:
|
||||
model = self._build_model(system)
|
||||
prior = self._convert_history(history[:-1])
|
||||
chat = model.start_chat(history=prior)
|
||||
response = chat.send_message(history[-1]["content"], stream=True)
|
||||
for chunk in response:
|
||||
if chunk.text:
|
||||
yield chunk.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenAI-compatible — OpenAI, Copilot, or any compatible endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OpenAIProvider(BaseProvider):
|
||||
name = "openai"
|
||||
|
||||
def __init__(self, api_key: str, model: str = "gpt-4o", base_url: str | None = None, display_name: str = "Copilot"):
|
||||
from openai import OpenAI
|
||||
self.client = OpenAI(api_key=api_key, base_url=base_url or "https://api.openai.com/v1")
|
||||
self.model = model
|
||||
self.display_name = display_name
|
||||
|
||||
def _messages(self, system: str, history: list[dict]) -> list[dict]:
|
||||
return [{"role": "system", "content": system}] + [
|
||||
{"role": msg["role"], "content": msg["content"]} for msg in history
|
||||
]
|
||||
|
||||
def send(self, system: str, history: list[dict]) -> str:
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model, max_tokens=4096, messages=self._messages(system, history)
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
|
||||
def send_streaming(self, system: str, history: list[dict]) -> Iterator[str]:
|
||||
stream = self.client.chat.completions.create(
|
||||
model=self.model, max_tokens=4096, messages=self._messages(system, history), stream=True
|
||||
)
|
||||
for chunk in stream:
|
||||
delta = chunk.choices[0].delta.content
|
||||
if delta:
|
||||
yield delta
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Variants and factory — preserved from the prototype to keep the contract.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CLAUDE_VARIANTS: dict[str, tuple[str, str]] = {
|
||||
"claude": ("claude-sonnet-4-6", "Claude"),
|
||||
"claude-sonnet": ("claude-sonnet-4-6", "Claude Sonnet"),
|
||||
"claude-opus": ("claude-opus-4-6", "Claude Opus"),
|
||||
"claude-haiku": ("claude-haiku-4-5-20251001", "Claude Haiku"),
|
||||
}
|
||||
|
||||
_GEMINI_VARIANTS: dict[str, tuple[str, str]] = {
|
||||
"gemini": ("gemini-1.5-pro", "Gemini"),
|
||||
"gemini-pro": ("gemini-1.5-pro", "Gemini Pro"),
|
||||
"gemini-flash": ("gemini-1.5-flash", "Gemini Flash"),
|
||||
"gemini-2-flash": ("gemini-2.0-flash", "Gemini 2 Flash"),
|
||||
}
|
||||
|
||||
|
||||
def load_providers(env: dict) -> dict[str, BaseProvider]:
|
||||
"""Instantiate enabled providers from env — same contract as the prototype."""
|
||||
enabled = [m.strip() for m in env.get("ENABLED_MODELS", "claude").split(",") if m.strip()]
|
||||
providers: dict[str, BaseProvider] = {}
|
||||
|
||||
anthropic_key = env.get("ANTHROPIC_API_KEY") or ""
|
||||
google_key = env.get("GOOGLE_API_KEY") or ""
|
||||
openai_key = env.get("OPENAI_API_KEY") or ""
|
||||
|
||||
for key in enabled:
|
||||
prefix = key.upper().replace("-", "_")
|
||||
if key in _CLAUDE_VARIANTS and anthropic_key:
|
||||
default_model, default_name = _CLAUDE_VARIANTS[key]
|
||||
providers[key] = AnthropicProvider(
|
||||
api_key=anthropic_key,
|
||||
model=env.get(f"{prefix}_MODEL", default_model),
|
||||
display_name=env.get(f"{prefix}_DISPLAY_NAME", default_name),
|
||||
)
|
||||
elif key in _GEMINI_VARIANTS and google_key:
|
||||
default_model, default_name = _GEMINI_VARIANTS[key]
|
||||
providers[key] = GeminiProvider(
|
||||
api_key=google_key,
|
||||
model=env.get(f"{prefix}_MODEL", default_model),
|
||||
display_name=env.get(f"{prefix}_DISPLAY_NAME", default_name),
|
||||
)
|
||||
elif key == "openai" and openai_key:
|
||||
providers["openai"] = OpenAIProvider(
|
||||
api_key=openai_key,
|
||||
model=env.get("OPENAI_MODEL", "gpt-4o"),
|
||||
base_url=env.get("OPENAI_BASE_URL"),
|
||||
display_name=env.get("OPENAI_DISPLAY_NAME", "Copilot"),
|
||||
)
|
||||
|
||||
return providers
|
||||
|
||||
|
||||
def load_from_config(config) -> dict[str, BaseProvider]:
|
||||
"""Convenience adapter so callers can pass our Config dataclass directly."""
|
||||
env = {
|
||||
"ENABLED_MODELS": ",".join(config.enabled_models),
|
||||
"ANTHROPIC_API_KEY": config.anthropic_api_key,
|
||||
"GOOGLE_API_KEY": config.google_api_key,
|
||||
"OPENAI_API_KEY": config.openai_api_key,
|
||||
}
|
||||
return load_providers(env)
|
||||
+27
-8
@@ -10,11 +10,12 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
|
||||
from . import cache
|
||||
from . import cache, db
|
||||
from .config import Config
|
||||
from .gitea import Gitea
|
||||
|
||||
@@ -47,14 +48,25 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
|
||||
if event not in EVENTS_OF_INTEREST:
|
||||
return {"ok": True, "ignored": event}
|
||||
|
||||
# Slice 1 only acts on meta-repo events; per-RFC-repo events
|
||||
# land in their respective slices. The handler is generous in
|
||||
# what it accepts — any meta-repo change is a cue to refresh
|
||||
# the whole meta-repo cache, since the cache is small and the
|
||||
# refresh is idempotent.
|
||||
# Identify the originating repo. For the meta repo we refresh
|
||||
# the entry cache + meta-PR cache; for a per-RFC repo we refresh
|
||||
# just that repo's branches/PRs/main body. The handler stays
|
||||
# generous in what it accepts — refreshes are idempotent and
|
||||
# small enough that overlapping events do not pile up.
|
||||
try:
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
payload = json.loads(body) if body else {}
|
||||
except Exception:
|
||||
payload = {}
|
||||
repo_full = (payload.get("repository") or {}).get("full_name") or ""
|
||||
meta_full = f"{config.gitea_org}/{config.meta_repo}"
|
||||
try:
|
||||
if repo_full == meta_full or not repo_full:
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
else:
|
||||
slug = _slug_for_repo(repo_full)
|
||||
if slug:
|
||||
await cache.refresh_rfc_repo(config, gitea, slug)
|
||||
except Exception:
|
||||
log.exception("webhook refresh failed")
|
||||
raise HTTPException(status_code=500, detail="Refresh failed")
|
||||
@@ -69,3 +81,10 @@ def _verify_signature(body: bytes, header: str, secret: str) -> bool:
|
||||
return False
|
||||
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, header)
|
||||
|
||||
|
||||
def _slug_for_repo(repo_full: str) -> str | None:
|
||||
row = db.conn().execute(
|
||||
"SELECT slug FROM cached_rfcs WHERE repo = ?", (repo_full,)
|
||||
).fetchone()
|
||||
return row["slug"] if row else None
|
||||
|
||||
@@ -34,22 +34,38 @@ import pytest
|
||||
|
||||
|
||||
class FakeGitea:
|
||||
"""A narrow in-memory simulation of the Gitea API the slice uses."""
|
||||
"""A narrow in-memory simulation of the Gitea API the slices exercise.
|
||||
|
||||
Slice 2 extends the seam to cover per-RFC repos: PUT contents
|
||||
(update file), POST orgs/{org}/repos (create repo), and branch
|
||||
listing with commit timestamps. The simulator is intentionally
|
||||
minimal — only the routes the production paths actually call.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# files: (owner, repo, branch, path) -> {"content": str, "sha": str}
|
||||
self.files: dict[tuple[str, str, str, str], dict] = {}
|
||||
# branches: (owner, repo) -> {branch_name -> {"sha": str}}
|
||||
# branches: (owner, repo) -> {branch_name -> {"sha": str, "ts": str}}
|
||||
self.branches: dict[tuple[str, str], dict[str, dict]] = {}
|
||||
# pulls: (owner, repo) -> list[pull-dict]
|
||||
self.pulls: dict[tuple[str, str], list[dict]] = {}
|
||||
# repos: set of (owner, repo)
|
||||
self.repos: set[tuple[str, str]] = set()
|
||||
self._pr_counter = 0
|
||||
self._commit_counter = 0
|
||||
self._seed_repo("wiggleverse", "meta")
|
||||
|
||||
def _seed_repo(self, owner, repo):
|
||||
self.branches[(owner, repo)] = {"main": {"sha": "initial"}}
|
||||
self.branches[(owner, repo)] = {"main": {"sha": "initial", "ts": "2026-05-23T00:00:00Z"}}
|
||||
self.pulls[(owner, repo)] = []
|
||||
self.repos.add((owner, repo))
|
||||
|
||||
def seed_rfc_repo(self, owner, repo, *, rfc_md_body):
|
||||
"""Convenience: seed a per-RFC repo with an RFC.md on main."""
|
||||
self._seed_repo(owner, repo)
|
||||
sha = self._next_sha()
|
||||
self.files[(owner, repo, "main", "RFC.md")] = {"content": rfc_md_body, "sha": sha}
|
||||
self.branches[(owner, repo)]["main"] = {"sha": sha, "ts": "2026-05-23T00:00:00Z"}
|
||||
|
||||
def _next_sha(self):
|
||||
self._commit_counter += 1
|
||||
@@ -62,8 +78,29 @@ class FakeGitea:
|
||||
payload = json.loads(body) if body else {}
|
||||
|
||||
# GET /repos/{owner}/{repo}
|
||||
if method == "GET" and re.fullmatch(r"/repos/[^/]+/[^/]+", path):
|
||||
return httpx.Response(200, json={"name": path.split("/")[-1]})
|
||||
m_repo = re.fullmatch(r"/repos/([^/]+)/([^/]+)", path)
|
||||
if method == "GET" and m_repo:
|
||||
owner, repo = m_repo.groups()
|
||||
if (owner, repo) in self.repos:
|
||||
return httpx.Response(200, json={"name": repo, "full_name": f"{owner}/{repo}"})
|
||||
return httpx.Response(404, json={"message": "not found"})
|
||||
|
||||
# POST /orgs/{org}/repos
|
||||
m = re.fullmatch(r"/orgs/([^/]+)/repos", path)
|
||||
if method == "POST" and m:
|
||||
org = m.group(1)
|
||||
name = payload["name"]
|
||||
self._seed_repo(org, name)
|
||||
return httpx.Response(201, json={"name": name, "full_name": f"{org}/{name}"})
|
||||
|
||||
# GET /repos/{owner}/{repo}/branches (list)
|
||||
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches", path)
|
||||
if method == "GET" and m:
|
||||
owner, repo = m.groups()
|
||||
items = []
|
||||
for name, b in self.branches.get((owner, repo), {}).items():
|
||||
items.append({"name": name, "commit": {"id": b["sha"], "timestamp": b.get("ts")}})
|
||||
return httpx.Response(200, json=items)
|
||||
|
||||
# GET /repos/{owner}/{repo}/branches/{branch}
|
||||
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches/([^/]+)", path)
|
||||
@@ -126,9 +163,20 @@ class FakeGitea:
|
||||
content = base64.b64decode(payload["content"]).decode()
|
||||
sha = self._next_sha()
|
||||
self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": sha}
|
||||
self.branches[(owner, repo)][branch]["sha"] = sha
|
||||
self.branches[(owner, repo)][branch] = {"sha": sha, "ts": "2026-05-23T00:00:00Z"}
|
||||
return httpx.Response(201, json={"commit": {"sha": sha}})
|
||||
|
||||
# PUT /repos/{owner}/{repo}/contents/{path} — update_file
|
||||
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path)
|
||||
if method == "PUT" and m:
|
||||
owner, repo, fpath = m.groups()
|
||||
branch = payload["branch"]
|
||||
content = base64.b64decode(payload["content"]).decode()
|
||||
sha = self._next_sha()
|
||||
self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": sha}
|
||||
self.branches[(owner, repo)][branch] = {"sha": sha, "ts": "2026-05-23T00:00:00Z"}
|
||||
return httpx.Response(200, json={"commit": {"sha": sha}, "content": {"sha": sha}})
|
||||
|
||||
# GET /repos/{owner}/{repo}/pulls?state=...
|
||||
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls", path)
|
||||
if method == "GET" and m:
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
"""End-to-end integration tests for the Slice 2 vertical (§8 in full).
|
||||
|
||||
Reuses FakeGitea + the session-cookie forging helpers from
|
||||
`test_propose_vertical.py`, extends FakeGitea with the per-RFC repo
|
||||
routes Slice 2 needs (PUT contents, POST orgs/{org}/repos, seeded
|
||||
RFC.md), and walks the §8 vertical end-to-end against an in-process
|
||||
fake Gitea:
|
||||
|
||||
* Seed an active RFC with a per-RFC repo holding RFC.md.
|
||||
* GET /api/rfcs/<slug>/main and /branches/<branch> — three-column
|
||||
feed against the cache + live branch read.
|
||||
* POST promote-to-branch — cut a new branch from main.
|
||||
* Materialize an AI-style change directly in the database (the LLM
|
||||
is mocked out where possible; one separate test exercises the
|
||||
chat streaming path with a fake provider injected).
|
||||
* POST accept — runs the bot's commit and updates `changes` row.
|
||||
* POST decline — non-commit path; row persists as evidence.
|
||||
* POST manual-flush — bot commit, system message lands in branch chat.
|
||||
* POST threads — create a flag, surface it on subsequent reads.
|
||||
* POST visibility — flip read_public and contribute_mode.
|
||||
* POST chat — fake provider returns a known <change> block; the
|
||||
response materializes a `changes` row.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pytest
|
||||
|
||||
# Reuse the harness already proven by Slice 1. We import via the
|
||||
# top-level module name (no leading dot) because pytest discovers
|
||||
# `tests/` as a flat directory of test modules without an __init__.py.
|
||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def seed_active_rfc(fake: FakeGitea, *, slug: str, title: str, body: str) -> str:
|
||||
"""Seed an active RFC end-to-end: create the meta-repo entry, the
|
||||
per-RFC repo with RFC.md on main, and the cached_rfcs row. The
|
||||
real graduation flow lands in Slice 5; until it exists, this is
|
||||
the test seam for "the RFC view's preconditions are met."
|
||||
"""
|
||||
from app import db
|
||||
import yaml
|
||||
|
||||
repo_full = f"wiggleverse/rfc-0001-{slug}"
|
||||
owner, repo = repo_full.split("/", 1)
|
||||
fake.seed_rfc_repo(owner, repo, rfc_md_body=body)
|
||||
|
||||
# Meta-repo entry — what the cache would mirror after graduation.
|
||||
fm = {
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
"state": "active",
|
||||
"id": "RFC-0001",
|
||||
"repo": repo_full,
|
||||
"proposed_by": "alice",
|
||||
"proposed_at": "2026-05-01",
|
||||
"graduated_at": "2026-05-22",
|
||||
"graduated_by": "ben",
|
||||
"owners": ["alice"],
|
||||
"arbiters": ["ben"],
|
||||
"tags": ["identity"],
|
||||
}
|
||||
entry_text = "---\n" + yaml.safe_dump(fm, sort_keys=False).rstrip() + "\n---\n"
|
||||
sha = fake._next_sha()
|
||||
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {"content": entry_text, "sha": sha}
|
||||
|
||||
# Write cached_rfcs row directly — the reconciler would also write
|
||||
# this on its next sweep, but the test seam avoids the extra hop.
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO cached_rfcs
|
||||
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
|
||||
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
|
||||
body, body_sha, last_main_commit_at, last_entry_commit_at)
|
||||
VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
""",
|
||||
(
|
||||
slug,
|
||||
title,
|
||||
"RFC-0001",
|
||||
repo_full,
|
||||
"alice",
|
||||
"2026-05-01",
|
||||
"2026-05-22",
|
||||
"ben",
|
||||
json.dumps(["alice"]),
|
||||
json.dumps(["ben"]),
|
||||
json.dumps(["identity"]),
|
||||
body,
|
||||
sha,
|
||||
),
|
||||
)
|
||||
# Seed cached_branches for main, since the reconciler hasn't necessarily
|
||||
# run yet inside the test client's lifespan. The webhook+reconciler
|
||||
# path is what writes this in production; we shortcut it here.
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
|
||||
VALUES (?, 'main', ?, 'open', datetime('now'))
|
||||
""",
|
||||
(slug, sha),
|
||||
)
|
||||
return repo_full
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
SEED_BODY = """# Open Human Model
|
||||
|
||||
Open Human Model is a framework for representing humans.
|
||||
|
||||
It defines consent, trait, and agency in compatible terms.
|
||||
"""
|
||||
|
||||
|
||||
def test_rfc_main_view_renders_against_per_rfc_repo(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
|
||||
r = client.get("/api/rfcs/open-human-model/main")
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["slug"] == "open-human-model"
|
||||
assert "Open Human Model" in d["body"]
|
||||
# main is in the branches list (cached).
|
||||
assert any(b["name"] == "main" for b in d["branches"])
|
||||
|
||||
|
||||
def test_promote_to_branch_creates_branch_and_navigates(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
branch_name = r.json()["branch_name"]
|
||||
assert branch_name.startswith("alice-draft-")
|
||||
|
||||
# The branch is reachable as its own view.
|
||||
r = client.get(f"/api/rfcs/ohm/branches/{branch_name}")
|
||||
assert r.status_code == 200, r.text
|
||||
view = r.json()
|
||||
assert view["branch_name"] == branch_name
|
||||
# The branch starts from main's body — the editor opens on it.
|
||||
assert "Open Human Model" in view["body"]
|
||||
# The whole-doc chat thread exists by default.
|
||||
assert view["main_thread_id"]
|
||||
|
||||
# The bot's create_branch action is in the audit log per §6.5.
|
||||
actions = db.conn().execute(
|
||||
"SELECT action_kind, on_behalf_of FROM actions WHERE action_kind = 'create_branch'"
|
||||
).fetchall()
|
||||
assert any((a["action_kind"], a["on_behalf_of"]) == ("create_branch", "alice") for a in actions)
|
||||
|
||||
|
||||
def test_accept_ai_change_commits_and_updates_row(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
# Cut a branch the contributor owns.
|
||||
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
|
||||
branch = r.json()["branch_name"]
|
||||
|
||||
# The whole-doc chat thread is created lazily on first branch
|
||||
# view (§8.12) — GET the branch so it materializes.
|
||||
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
|
||||
thread_id = view["main_thread_id"]
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
|
||||
original, proposed, reason)
|
||||
VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
branch,
|
||||
thread_id,
|
||||
"Open Human Model is a framework for representing humans.",
|
||||
"Open Human Model is a framework for representing humans in software systems.",
|
||||
"tightens scope",
|
||||
),
|
||||
)
|
||||
change_id = cur.lastrowid
|
||||
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
|
||||
json={
|
||||
"proposed": "Open Human Model is a framework for representing humans in software systems.",
|
||||
"was_edited_before_accept": False,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["commit_sha"]
|
||||
|
||||
# The change row is now accepted with the commit sha bound.
|
||||
row = db.conn().execute(
|
||||
"SELECT state, commit_sha, acted_by, was_edited_before_accept FROM changes WHERE id = ?",
|
||||
(change_id,),
|
||||
).fetchone()
|
||||
assert row["state"] == "accepted"
|
||||
assert row["commit_sha"] == body["commit_sha"]
|
||||
assert row["acted_by"] == 2
|
||||
assert not row["was_edited_before_accept"]
|
||||
|
||||
# The branch's RFC.md on Gitea now reflects the change.
|
||||
owner, repo = "wiggleverse", "rfc-0001-ohm"
|
||||
new_body = fake.files[(owner, repo, branch, "RFC.md")]["content"]
|
||||
assert "in software systems" in new_body
|
||||
|
||||
|
||||
def test_accept_with_edit_before_accept_records_flag_and_ai_original(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
|
||||
branch = r.json()["branch_name"]
|
||||
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
|
||||
thread_id = view["main_thread_id"]
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
|
||||
original, proposed, reason)
|
||||
VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, ?)
|
||||
""",
|
||||
(branch, thread_id,
|
||||
"It defines consent, trait, and agency in compatible terms.",
|
||||
"It defines consent, trait, harm, and agency in compatible terms.",
|
||||
"adds harm"),
|
||||
)
|
||||
change_id = cur.lastrowid
|
||||
|
||||
edited = "It defines consent, trait, harm, and agency together."
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
|
||||
json={"proposed": edited, "was_edited_before_accept": True},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT proposed, was_edited_before_accept FROM changes WHERE id = ?",
|
||||
(change_id,),
|
||||
).fetchone()
|
||||
assert row["was_edited_before_accept"] == 1
|
||||
assert row["proposed"] == edited
|
||||
# The commit body carries both the AI's original proposed
|
||||
# text and the contributor's revision per §8.9.
|
||||
body = fake.files[("wiggleverse", "rfc-0001-ohm", branch, "RFC.md")]["content"]
|
||||
assert "harm" in body
|
||||
# The contributor's edited text won, not the AI's.
|
||||
assert "together." in body
|
||||
|
||||
|
||||
def test_decline_change_persists_as_evidence_no_commit(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
|
||||
branch = r.json()["branch_name"]
|
||||
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
|
||||
thread_id = view["main_thread_id"]
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
|
||||
original, proposed, reason)
|
||||
VALUES ('ohm', ?, ?, 'ai', 'pending', 'x', 'y', 'why')
|
||||
""",
|
||||
(branch, thread_id),
|
||||
)
|
||||
change_id = cur.lastrowid
|
||||
|
||||
prior_sha = fake.branches[("wiggleverse", "rfc-0001-ohm")][branch]["sha"]
|
||||
r = client.post(f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/decline")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# No commit, no body change.
|
||||
post_sha = fake.branches[("wiggleverse", "rfc-0001-ohm")][branch]["sha"]
|
||||
assert prior_sha == post_sha
|
||||
|
||||
# The card stays as evidence.
|
||||
row = db.conn().execute(
|
||||
"SELECT state FROM changes WHERE id = ?", (change_id,)
|
||||
).fetchone()
|
||||
assert row["state"] == "declined"
|
||||
|
||||
|
||||
def test_manual_flush_commits_and_drops_system_message(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
|
||||
branch = r.json()["branch_name"]
|
||||
|
||||
new_body = SEED_BODY + "\n\nA new paragraph.\n"
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/manual-flush",
|
||||
json={"new_content": new_body, "paragraph_count": 1},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["commit_sha"]
|
||||
|
||||
# The branch RFC.md was updated.
|
||||
body = fake.files[("wiggleverse", "rfc-0001-ohm", branch, "RFC.md")]["content"]
|
||||
assert "A new paragraph" in body
|
||||
|
||||
# Per §10.6: a system-author message landed in the branch chat.
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT m.role, m.text FROM thread_messages m
|
||||
JOIN threads t ON t.id = m.thread_id
|
||||
WHERE t.rfc_slug = 'ohm' AND t.branch_name = ?
|
||||
""",
|
||||
(branch,),
|
||||
).fetchall()
|
||||
assert any(r["role"] == "system" and "manual edit" in r["text"] for r in rows)
|
||||
|
||||
|
||||
def test_create_flag_thread_surfaces_on_branch_view(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
|
||||
branch = r.json()["branch_name"]
|
||||
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/threads",
|
||||
json={
|
||||
"thread_kind": "flag",
|
||||
"anchor_kind": "range",
|
||||
"anchor_payload": {"quote": "consent"},
|
||||
"label": "needs an example",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
thread_id = r.json()["thread_id"]
|
||||
|
||||
r = client.get(f"/api/rfcs/ohm/branches/{branch}")
|
||||
threads = r.json()["threads"]
|
||||
assert any(t["id"] == thread_id and t["thread_kind"] == "flag" for t in threads)
|
||||
|
||||
|
||||
def test_visibility_flip_locks_out_non_grantees(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
|
||||
branch = r.json()["branch_name"]
|
||||
|
||||
# Flip the branch private.
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/visibility",
|
||||
json={"read_public": False},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Bob (a different contributor) is now blocked from reading it.
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
|
||||
r = client.get(f"/api/rfcs/ohm/branches/{branch}")
|
||||
assert r.status_code == 403
|
||||
|
||||
# Alice (the creator) still can.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.get(f"/api/rfcs/ohm/branches/{branch}")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_anonymous_can_read_main_but_not_contribute(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
# No sign-in.
|
||||
r = client.get("/api/rfcs/ohm/main")
|
||||
assert r.status_code == 200
|
||||
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_stale_change_refuses_silent_apply(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
|
||||
branch = r.json()["branch_name"]
|
||||
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
|
||||
thread_id = view["main_thread_id"]
|
||||
# Stale by construction: original text not in the document.
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
|
||||
original, proposed, reason)
|
||||
VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, ?)
|
||||
""",
|
||||
(branch, thread_id, "Text that does not appear", "Replacement.", "test"),
|
||||
)
|
||||
change_id = cur.lastrowid
|
||||
|
||||
# Refused without force.
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
|
||||
json={"proposed": "Replacement.", "was_edited_before_accept": False},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
# The row is marked stale per §8.11.
|
||||
row = db.conn().execute(
|
||||
"SELECT state, stale_since FROM changes WHERE id = ?", (change_id,)
|
||||
).fetchone()
|
||||
assert row["state"] == "pending"
|
||||
assert row["stale_since"]
|
||||
|
||||
# Force-apply succeeds and appends.
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
|
||||
json={"proposed": "Replacement.", "was_edited_before_accept": False, "force_apply_stale": True},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chat streaming with a fake provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FakeProvider:
|
||||
name = "claude"
|
||||
display_name = "Claude"
|
||||
|
||||
def __init__(self, fixed_response: str):
|
||||
self._response = fixed_response
|
||||
|
||||
def send(self, system, history):
|
||||
return self._response
|
||||
|
||||
def send_streaming(self, system, history):
|
||||
# Single-chunk stream — sufficient for the orchestration test.
|
||||
yield self._response
|
||||
|
||||
|
||||
def test_chat_turn_materializes_change_from_change_block(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
fake_response = (
|
||||
"Here is a tightening:\n\n"
|
||||
"<change>\n"
|
||||
"<original>Open Human Model is a framework for representing humans.</original>\n"
|
||||
"<proposed>Open Human Model is a framework for representing humans across software systems.</proposed>\n"
|
||||
"<reason>scopes the framework</reason>\n"
|
||||
"</change>\n\n"
|
||||
"Let me know if that fits."
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
|
||||
branch = r.json()["branch_name"]
|
||||
|
||||
# Inject the fake provider — the app's `providers` dict is built
|
||||
# at startup; we replace it for the test so the chat endpoint
|
||||
# resolves a deterministic response.
|
||||
app.state.providers["claude"] = FakeProvider(fake_response)
|
||||
# The router resolved `providers` at construction time; rebuild
|
||||
# the slice 2 router with the fake provider in place.
|
||||
from app import api as api_routes
|
||||
# Find and replace the existing branches router. Simpler: monkey
|
||||
# patch the providers dict referenced by the router closure.
|
||||
# The closure receives the dict by reference, so mutating it
|
||||
# propagates.
|
||||
# (Above mutation already does that — nothing more to do.)
|
||||
|
||||
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
|
||||
thread_id = view["main_thread_id"]
|
||||
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/chat",
|
||||
json={"text": "Can you tighten the opening?", "model": "claude"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
# Drain the stream so the orchestrator finishes its work.
|
||||
body = r.content.decode()
|
||||
assert "DONE" in body
|
||||
|
||||
# A change row materialized from the <change> block.
|
||||
rows = db.conn().execute(
|
||||
"SELECT kind, state, original, proposed, reason FROM changes WHERE rfc_slug = 'ohm' AND branch_name = ?",
|
||||
(branch,),
|
||||
).fetchall()
|
||||
ai_rows = [r for r in rows if r["kind"] == "ai"]
|
||||
assert len(ai_rows) == 1
|
||||
assert ai_rows[0]["state"] == "pending"
|
||||
assert "humans across software systems" in ai_rows[0]["proposed"]
|
||||
assert "scopes the framework" in ai_rows[0]["reason"]
|
||||
|
||||
# The assistant message persisted with the full text.
|
||||
msgs = db.conn().execute(
|
||||
"SELECT role, text FROM thread_messages WHERE thread_id = ? ORDER BY id",
|
||||
(thread_id,),
|
||||
).fetchall()
|
||||
assert msgs[-1]["role"] == "assistant"
|
||||
assert "<change>" in msgs[-1]["text"]
|
||||
Reference in New Issue
Block a user