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
+144
View File
@@ -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: