"""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:
`.
"""
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/.md`. One commit, one PR, easy to triage. The branch
name uses the dash-separated `metadata--<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
# ----- §13 graduation: per-step primitives and rollback inverses -----
async def create_rfc_repo_for_graduation(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
title: str,
) -> dict:
"""§13.3 step 1: create the per-RFC repo.
Empty repo (no auto-init) — `seed_graduated_rfc` writes the first
commit on `main`. Returns the Gitea repo payload."""
repo = await self._gitea.create_org_repo(
org, repo_name, description=f"RFC: {title}"
)
_log(
actor,
"graduate_repo_create",
rfc_slug=slug,
details={"repo": f"{org}/{repo_name}", "title": title},
)
return repo
async def seed_graduated_rfc(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
title: str,
rfc_body: str,
rfc_id: str,
meta_full: str,
meta_path: str,
owners: list[str],
arbiters: list[str],
tags: list[str],
) -> str:
"""§13.3 step 2: seed RFC.md, README.md, .rfc/metadata.yaml on the
new repo's `main`. Three create_file calls; one audit row.
Returns the final commit sha on main.
"""
import yaml as _yaml
ae = actor.email or f"{actor.gitea_login}@users.noreply"
# 2a) RFC.md — the document. The super-draft's body is migrated
# verbatim per §13.3; if the body is empty we seed a minimal
# placeholder so the editor has something to render on first open.
body = rfc_body.strip() + "\n" if rfc_body.strip() else (
f"# {title}\n\n*RFC.md to be filled in — the super-draft graduated with an empty body.*\n"
)
rfc_msg = _stamp_single(f"Seed RFC.md from super-draft {slug}", actor)
rfc_result = await self._gitea.create_file(
org, repo_name, "RFC.md",
content=body, message=rfc_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
# 2b) README.md — header pointing back at the meta-repo entry.
readme = (
f"# {rfc_id} — {title}\n\n"
f"This repository carries the canonical text of {rfc_id}.\n"
f"The meta-repo entry is `{meta_path}` in `{meta_full}`.\n\n"
f"The RFC body is in `RFC.md`. Contributions go through the\n"
f"app's §8 RFC view — open a branch, propose changes, land a PR.\n"
)
readme_msg = _stamp_single(f"Seed README.md for {rfc_id}", actor)
await self._gitea.create_file(
org, repo_name, "README.md",
content=readme, message=readme_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
# 2c) .rfc/metadata.yaml — mirror of meta-repo frontmatter for
# future tooling (linting, automation, CI lookups).
meta_yaml = _yaml.safe_dump(
{
"slug": slug, "title": title, "id": rfc_id,
"owners": owners, "arbiters": arbiters, "tags": list(tags),
},
sort_keys=False,
)
meta_msg = _stamp_single(f"Seed .rfc/metadata.yaml for {rfc_id}", actor)
meta_result = await self._gitea.create_file(
org, repo_name, ".rfc/metadata.yaml",
content=meta_yaml, message=meta_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
last_sha = (
meta_result.get("commit", {}).get("sha")
or rfc_result.get("commit", {}).get("sha")
or ""
)
_log(
actor,
"graduate_repo_seed",
rfc_slug=slug,
branch_name="main",
bot_commit_sha=last_sha,
details={"repo": f"{org}/{repo_name}", "rfc_id": rfc_id},
)
return last_sha
async def open_graduation_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
slug: str,
new_file_contents: str,
prior_sha: str,
rfc_id: str,
repo_full: str,
owners: list[str],
) -> dict:
"""§13.3 step 3: open a PR against the meta repo that strips the
super-draft body and fills graduation frontmatter fields. Branch
name uses the `graduate--<6hex>` shape — dash-separated like
the other meta-repo branches per the §19.2 path-routing candidate.
"""
import secrets
branch = f"graduate-{slug}-{secrets.token_hex(3)}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
ae = actor.email or f"{actor.gitea_login}@users.noreply"
commit_subject = f"Graduate {slug} → {rfc_id}"
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=ae,
)
commit_sha = (
result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha")
or ""
)
pr_title = f"Graduate {slug} → {rfc_id}"
owners_str = ", ".join(owners) if owners else "(none)"
pr_body_text = (
f"Graduates super-draft `{slug}` to active.\n\n"
f"- ID: `{rfc_id}`\n"
f"- Repo: `{repo_full}`\n"
f"- Owners: {owners_str}\n\n"
f"The meta-repo entry becomes frontmatter-only; the canonical body\n"
f"moves to `RFC.md` in the new repo. The graduation sequence is\n"
f"transactional per §13.3."
)
_subject, pr_body = _stamp("", pr_body_text, actor)
pr = await self._gitea.create_pull(
org, meta_repo,
title=pr_title, body=pr_body, head=branch, base="main",
)
_log(
actor,
"graduate_pr_open",
rfc_slug=slug,
branch_name=branch,
pr_number=pr["number"],
bot_commit_sha=commit_sha,
details={"pr_title": pr_title, "rfc_id": rfc_id, "repo": repo_full},
)
return pr
async def merge_graduation_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
pr_number: int,
head_branch: str,
slug: str,
rfc_id: str,
) -> None:
"""§13.3 step 4: auto-merge the graduation PR with the admin as
merge actor. Distinct action_kind so the audit log carries the
graduation as a linkable sequence per §13.3's transactional shape."""
subject = f"Graduate {slug} → {rfc_id}"
body = _trailer(actor)
await self._gitea.merge_pull(
org, meta_repo, pr_number,
merge_message_title=subject,
merge_message_body=body,
style="merge",
)
_log(
actor,
"graduate_pr_merge",
rfc_slug=slug,
branch_name=head_branch,
pr_number=pr_number,
details={"rfc_id": rfc_id},
)
# ----- §13.3 rollback inverses -----
async def delete_rfc_repo(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
reason: str,
) -> None:
"""Undo of `create_rfc_repo_for_graduation`. Records `graduate_repo_delete`
in the audit log with the rollback reason so the §13.3 stack's
rendered failure surface can be reconstructed from `actions`."""
await self._gitea.delete_repo(org, repo_name)
_log(
actor,
"graduate_repo_delete",
rfc_slug=slug,
details={"repo": f"{org}/{repo_name}", "reason": reason},
)
async def close_graduation_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
pr_number: int,
head_branch: str,
slug: str,
reason: str,
) -> None:
"""Undo of `open_graduation_pr`. Closes the PR without merging; the
branch is left in place to dodge the case where another graduation
attempt runs immediately — it'll get its own `graduate--`
suffix."""
await self._gitea.close_pull(org, meta_repo, pr_number)
_log(
actor,
"graduate_pr_close",
rfc_slug=slug,
branch_name=head_branch,
pr_number=pr_number,
details={"reason": reason},
)
# ----- §13.1 claim PRs -----
async def open_claim_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
slug: str,
new_file_contents: str,
prior_sha: str,
) -> dict:
"""§13.1: open a PR adding the actor to the entry's `owners:` list.
Touches only the frontmatter of `rfcs/.md`. Branch shape is
`claim/` — single attempt per super-draft per actor (Gitea
refuses duplicate branch creation, which is the right behavior:
if the claim is still open, point the contributor at the existing
PR rather than opening a second one).
"""
branch = f"claim/{slug}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
ae = actor.email or f"{actor.gitea_login}@users.noreply"
commit_subject = f"Claim ownership of {slug} for {actor.gitea_login}"
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=ae,
)
commit_sha = (
result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha")
or ""
)
pr_title = f"Claim ownership: {slug}"
pr_description = (
f"`{actor.gitea_login}` claims ownership of super-draft `{slug}`.\n\n"
f"Per §13.1, owners and admins can merge."
)
_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_claim_pr",
rfc_slug=slug,
branch_name=branch,
pr_number=pr["number"],
bot_commit_sha=commit_sha,
details={"new_owner": actor.gitea_login},
)
return pr
# ----- 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