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:
Ben Stull
2026-05-24 04:35:14 -07:00
parent 779ba6db59
commit 3bc8fe92af
24 changed files with 5433 additions and 151 deletions
+54 -6
View File
@@ -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: