"""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, )