Slice 3: the PR flow

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 12:37:54 -07:00
parent 33d9d7a482
commit a2bf89e90b
15 changed files with 2928 additions and 141 deletions
+194
View File
@@ -376,6 +376,200 @@ class Bot:
# ----- 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,