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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user