3bc8fe92af
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>
436 lines
14 KiB
Python
436 lines
14 KiB
Python
"""The bot wrapper.
|
|
|
|
Per §1: the bot service account is the only Git writer in the system.
|
|
Per §6.5: every commit, branch creation, and PR merge carries an
|
|
On-behalf-of: trailer naming the acting user.
|
|
|
|
This module is the single chokepoint. Every write to Gitea — file
|
|
creation, branch creation, PR open, PR merge, PR close — flows through
|
|
a Bot method that takes an `actor` (the authenticated user whose gesture
|
|
produced the action) and an `action_kind` (one of the values recorded
|
|
in the `actions` table). The wrapper:
|
|
|
|
- calls the Gitea HTTP client with the bot's credentials,
|
|
- appends the trailer to commit/PR/comment bodies,
|
|
- records a row in `actions` so the app's accountability surface and
|
|
the Git log carry the same record.
|
|
|
|
If you find yourself wanting to import gitea.py directly to perform a
|
|
write, the spec is right and you are wrong: the wrapper is the
|
|
invariant. Read operations live in `gitea.py` and can be called from
|
|
anywhere.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
|
|
from . import db
|
|
from .gitea import Gitea
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Actor:
|
|
"""The user whose gesture is producing a write."""
|
|
user_id: int
|
|
gitea_login: str
|
|
display_name: str
|
|
email: str
|
|
|
|
|
|
def _trailer(actor: Actor) -> str:
|
|
return f"On-behalf-of: {actor.display_name} <{actor.gitea_login}>"
|
|
|
|
|
|
def _stamp(message_subject: str, message_body: str, actor: Actor) -> tuple[str, str]:
|
|
"""Compose subject + body with the On-behalf-of trailer appended.
|
|
|
|
Subject and body are returned separately because Gitea's merge API
|
|
takes them on distinct fields; for file commits we hand back a
|
|
single string in the caller.
|
|
"""
|
|
body = message_body.rstrip()
|
|
trailer = _trailer(actor)
|
|
if body:
|
|
return message_subject, f"{body}\n\n{trailer}"
|
|
return message_subject, trailer
|
|
|
|
|
|
def _stamp_single(message: str, actor: Actor) -> str:
|
|
subject, _, rest = message.partition("\n")
|
|
subject, body = _stamp(subject, rest.lstrip(), actor)
|
|
return f"{subject}\n\n{body}".rstrip()
|
|
|
|
|
|
def _log(
|
|
actor: Actor,
|
|
action_kind: str,
|
|
*,
|
|
rfc_slug: str | None = None,
|
|
branch_name: str | None = None,
|
|
pr_number: int | None = None,
|
|
bot_commit_sha: str | None = None,
|
|
details: dict | None = None,
|
|
) -> None:
|
|
db.conn().execute(
|
|
"""
|
|
INSERT INTO actions
|
|
(actor_user_id, on_behalf_of, action_kind, rfc_slug, branch_name, pr_number, bot_commit_sha, details)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
actor.user_id,
|
|
actor.gitea_login,
|
|
action_kind,
|
|
rfc_slug,
|
|
branch_name,
|
|
pr_number,
|
|
bot_commit_sha,
|
|
json.dumps(details) if details else None,
|
|
),
|
|
)
|
|
|
|
|
|
class Bot:
|
|
def __init__(self, gitea: Gitea):
|
|
self._gitea = gitea
|
|
|
|
# ----- Meta repo: idea PRs (§9.1 / §9.2) -----
|
|
|
|
async def open_idea_pr(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
org: str,
|
|
meta_repo: str,
|
|
slug: str,
|
|
file_contents: str,
|
|
pr_title: str,
|
|
pr_description: str,
|
|
) -> dict:
|
|
"""Per §9.1: open a meta-repo PR adding one file under rfcs/.
|
|
|
|
One file per PR keeps idea submissions atomic and conflict-free.
|
|
The PR title and the file-add commit subject share §9.2's fixed
|
|
pattern; callers compose `pr_title` as `Propose: <Title>`.
|
|
"""
|
|
branch = f"propose/{slug}"
|
|
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
|
commit_subject = pr_title # §9.2: shared pattern
|
|
commit_message = _stamp_single(commit_subject, actor)
|
|
created = await self._gitea.create_file(
|
|
org,
|
|
meta_repo,
|
|
f"rfcs/{slug}.md",
|
|
content=file_contents,
|
|
message=commit_message,
|
|
branch=branch,
|
|
author_name=actor.display_name,
|
|
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
|
)
|
|
commit_sha = created.get("commit", {}).get("sha")
|
|
pr_body_subject, pr_body = _stamp("", pr_description, actor)
|
|
del pr_body_subject # only the body matters here
|
|
pr = await self._gitea.create_pull(
|
|
org,
|
|
meta_repo,
|
|
title=pr_title,
|
|
body=pr_body,
|
|
head=branch,
|
|
base="main",
|
|
)
|
|
_log(
|
|
actor,
|
|
"propose_rfc",
|
|
rfc_slug=slug,
|
|
branch_name=branch,
|
|
pr_number=pr["number"],
|
|
bot_commit_sha=commit_sha,
|
|
details={"pr_title": pr_title},
|
|
)
|
|
return pr
|
|
|
|
async def merge_idea_pr(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
org: str,
|
|
meta_repo: str,
|
|
pr_number: int,
|
|
slug: str,
|
|
) -> None:
|
|
"""Per §9.3: owner/admin merges an idea PR, creating the super-draft."""
|
|
subject = f"Merge proposal: {slug}"
|
|
body = _trailer(actor)
|
|
await self._gitea.merge_pull(
|
|
org,
|
|
meta_repo,
|
|
pr_number,
|
|
merge_message_title=subject,
|
|
merge_message_body=body,
|
|
)
|
|
_log(
|
|
actor,
|
|
"merge_proposal",
|
|
rfc_slug=slug,
|
|
pr_number=pr_number,
|
|
)
|
|
|
|
async def decline_idea_pr(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
org: str,
|
|
meta_repo: str,
|
|
pr_number: int,
|
|
slug: str,
|
|
comment: str,
|
|
) -> None:
|
|
"""Per §9.3: owner/admin declines an idea PR with a required comment.
|
|
|
|
The comment is posted to the PR (the durable Git artifact) and a
|
|
mirroring system-author thread_messages row is written by the
|
|
caller so the chat record carries the act inline.
|
|
"""
|
|
commented = comment.strip() or "(no comment provided)"
|
|
body = f"{commented}\n\n{_trailer(actor)}"
|
|
await self._gitea.create_issue_comment(org, meta_repo, pr_number, body)
|
|
await self._gitea.close_pull(org, meta_repo, pr_number)
|
|
_log(
|
|
actor,
|
|
"decline_proposal",
|
|
rfc_slug=slug,
|
|
pr_number=pr_number,
|
|
details={"comment": commented},
|
|
)
|
|
|
|
async def withdraw_idea_pr(
|
|
self,
|
|
actor: Actor,
|
|
*,
|
|
org: str,
|
|
meta_repo: str,
|
|
pr_number: int,
|
|
slug: str,
|
|
) -> None:
|
|
await self._gitea.close_pull(org, meta_repo, pr_number)
|
|
_log(
|
|
actor,
|
|
"withdraw_proposal",
|
|
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
|