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