Files
rfc-app/backend/app/bot.py
T
Ben Stull 4565a6cb95 Slice 4: super-draft body editing per §9.5 + §9.6
The §17 routing-collapse rule lands in api_branches.py and
api_prs.py — every branches/<branch>/... and prs/<n>/... route
dispatches on the entry's state to pick the right Gitea repo, and
the body extracted from the entry's frontmatter envelope is what
the editor and the diff see. The bot grows open_metadata_pr;
cache grows refresh_meta_branches. Two §17 routes added:
start-edit-branch and metadata. The §9.4 super-draft view replaces
RFCView.jsx's Slice 2 placeholder; a metadata pane modal opens
from the breadcrumb. Branch naming uses edit-<slug>-<6hex> to
dodge the §19.2 path-routing candidate while preserving §9.5's
structural shape.

Covered by tests/test_super_draft_vertical.py (10 tests). The
full Slices 1-4 suite is 35/35 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:43:21 -07:00

689 lines
22 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,
)
# ----- Meta repo: metadata-pane PRs (§9.5) -----
async def open_metadata_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
slug: str,
new_file_contents: str,
prior_sha: str,
pr_title: str,
pr_description: str,
) -> dict:
"""Per §9.5: a metadata-pane edit (title or tags) on a super-draft
opens a tiny meta-repo PR that touches only the frontmatter of
`rfcs/<slug>.md`. One commit, one PR, easy to triage. The branch
name uses the dash-separated `metadata-<slug>-<6hex>` shape — same
routing-friendly form Slice 4 picked for edit branches per the
§19.2 path-routing candidate.
"""
import secrets
branch = f"metadata-{slug}-{secrets.token_hex(3)}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
commit_subject = pr_title
commit_message = _stamp_single(commit_subject, actor)
result = await self._gitea.update_file(
org,
meta_repo,
f"rfcs/{slug}.md",
content=new_file_contents,
sha=prior_sha,
message=commit_message,
branch=branch,
author_name=actor.display_name,
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
)
commit_sha = result.get("commit", {}).get("sha") or result.get("content", {}).get("sha") or ""
_subject, pr_body = _stamp("", pr_description, actor)
pr = await self._gitea.create_pull(
org,
meta_repo,
title=pr_title,
body=pr_body,
head=branch,
base="main",
)
_log(
actor,
"open_metadata_pr",
rfc_slug=slug,
branch_name=branch,
pr_number=pr["number"],
bot_commit_sha=commit_sha,
details={"pr_title": pr_title},
)
return pr
# ----- 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) -----
# ----- Per-RFC repo: PRs (§10) -----
async def open_branch_pr(
self,
actor: Actor,
*,
owner: str,
repo: str,
head_branch: str,
title: str,
description: str,
slug: str,
supersedes_pr_number: int | None = None,
) -> dict:
"""Per §10.1: open a PR from a branch against main.
The PR's body carries the contributor's description, optional
`Supersedes:` trailer for the §10.9 replay path, and the
standard `On-behalf-of:` trailer per §6.5.
"""
body_lines = [description.strip()]
if supersedes_pr_number is not None:
body_lines += ["", f"Supersedes: #{supersedes_pr_number}"]
body_lines += ["", _trailer(actor)]
body = "\n".join(body_lines).strip()
pr = await self._gitea.create_pull(
owner,
repo,
title=title,
body=body,
head=head_branch,
base="main",
)
_log(
actor,
"open_branch_pr",
rfc_slug=slug,
branch_name=head_branch,
pr_number=pr["number"],
details={
"title": title,
"supersedes": supersedes_pr_number,
"repo": f"{owner}/{repo}",
},
)
return pr
async def merge_branch_pr(
self,
actor: Actor,
*,
owner: str,
repo: str,
pr_number: int,
head_branch: str,
slug: str,
) -> None:
"""Per §10.5: no-fast-forward merge.
Gitea's `style='merge'` produces a merge commit; the
per-acceptance commits from §8.6 remain individually reachable
in main's history. The merge commit's body records the merging
user via the `On-behalf-of:` trailer — the merge commit's
author stays the bot (the bot is the only Git writer per §1)
but the trailer carries the human accountability.
"""
subject = f"Merge branch '{head_branch}'"
body = _trailer(actor)
await self._gitea.merge_pull(
owner,
repo,
pr_number,
merge_message_title=subject,
merge_message_body=body,
style="merge",
)
_log(
actor,
"merge_branch_pr",
rfc_slug=slug,
branch_name=head_branch,
pr_number=pr_number,
details={"repo": f"{owner}/{repo}"},
)
async def withdraw_branch_pr(
self,
actor: Actor,
*,
owner: str,
repo: str,
pr_number: int,
head_branch: str,
slug: str,
reason: str = "withdraw",
) -> None:
"""Per §10.8: close the PR; do not delete the branch."""
await self._gitea.close_pull(owner, repo, pr_number)
_log(
actor,
"withdraw_branch_pr" if reason == "withdraw" else "supersede_branch_pr",
rfc_slug=slug,
branch_name=head_branch,
pr_number=pr_number,
details={"repo": f"{owner}/{repo}", "reason": reason},
)
async def cut_resolution_branch(
self,
actor: Actor,
*,
owner: str,
repo: str,
original_branch: str,
resolution_branch: str,
slug: str,
) -> dict:
"""Per §10.9: cut a fresh branch off main's tip into which the
original branch's changes will be replayed. The bot owns the
cut; the replay itself is a sequence of commit_accepted_change
/ manual flush operations driven by the API layer."""
created = await self._gitea.create_branch(
owner, repo, resolution_branch, from_branch="main"
)
_log(
actor,
"create_resolution_branch",
rfc_slug=slug,
branch_name=resolution_branch,
details={
"repo": f"{owner}/{repo}",
"original_branch": original_branch,
},
)
return created
async def commit_replay_change(
self,
actor: Actor,
*,
owner: str,
repo: str,
branch: str,
file_path: str,
new_content: str,
prior_sha: str,
original_change_id: int,
original: str,
proposed: str,
reason: str,
slug: str,
) -> str:
"""Per §10.9: a single replayed accept lands as its own commit on
the resolution branch, so the §8.6 evidence shape is preserved.
The subject mirrors `commit_accepted_change`'s but the body
records the original change id so the resolution PR's
conversation can stitch back to the original branch's chat."""
subject = _subject_from_reason(reason, fallback="Replay change")
body_lines = [
"**Original:**",
original.strip(),
"",
"**Proposed:**",
proposed.strip(),
]
if reason and reason.strip():
body_lines += ["", "**Reason:**", reason.strip()]
body_lines += ["", f"Replayed-Change-Id: {original_change_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,
"replay_change",
rfc_slug=slug,
branch_name=branch,
bot_commit_sha=sha,
details={"original_change_id": original_change_id, "file_path": file_path},
)
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