Files
rfc-app/backend/app/bot.py
T
Ben Stull aee9b582e5 fix(slice4): make all entry write paths sidecar-aware (§22.4a carried from SLICE-1)
graduate, claim, retire/unretire, _read_meta_entry, mark_entry_reviewed,
body extract/wrap (api_branches + api_prs replay) now dual-read and write
metadata to the sidecar via write_entry_files + bot.commit_entry_files/
open_entry_pr — a migrated body-only .md no longer crashes entry.parse or
re-grows frontmatter; legacy entries lazy-migrate on first metadata write.
Existing tests updated to assert the sidecar (INV-2 clean docs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:57:54 -07:00

1302 lines
45 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 asyncio
import json
import logging
from dataclasses import dataclass
from . import db, entry as entry_mod, metadata as metadata_mod, notify
from .gitea import Gitea, GiteaError
log = logging.getLogger(__name__)
_MERGE_TRANSIENT_HINTS = (
"please try again later",
"is not ready",
"not ready to be merged",
)
async def _merge_with_retry(
gitea: Gitea,
org: str,
meta_repo: str,
pr_number: int,
*,
merge_message_title: str,
merge_message_body: str,
attempts: int = 3,
delay_seconds: float = 1.0,
) -> None:
"""Call gitea.merge_pull and retry on Gitea's transient
"Please try again later" / "not ready" responses. After
`wait_for_mergeable` returns this is rare, but Gitea has been
observed to flip back to the unready state momentarily on a
write-amplified instance; a small bounded retry closes the gap.
Any other GiteaError propagates immediately so the §13.3
orchestrator can fail the step and run rollback.
"""
last_error: GiteaError | None = None
for attempt in range(1, attempts + 1):
try:
await gitea.merge_pull(
org, meta_repo, pr_number,
merge_message_title=merge_message_title,
merge_message_body=merge_message_body,
style="merge",
)
return
except GiteaError as e:
detail = (e.detail or "").lower()
if not any(hint in detail for hint in _MERGE_TRANSIENT_HINTS):
raise
last_error = e
log.warning(
"merge_pull transient (attempt %d/%d) for %s/%s#%d: %s",
attempt, attempts, org, meta_repo, pr_number, e.detail,
)
if attempt < attempts:
await asyncio.sleep(delay_seconds)
assert last_error is not None
raise last_error
@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,
),
)
# §15 chokepoint per §19.1 brief: fan-out runs inline after the
# audit row lands. notify.py owns the routing rules and the
# auto-watch upsert per §15.6; the call is intentionally a
# single line here so the chokepoint is one place to read.
notify.fan_out_from_action(
actor_user_id=actor.user_id,
action_kind=action_kind,
rfc_slug=rfc_slug,
branch_name=branch_name,
pr_number=pr_number,
details=details,
)
class Bot:
def __init__(self, gitea: Gitea):
self._gitea = gitea
# ----- Content repo: collection structure (§22 S2) -----
async def create_collection(
self,
actor: Actor,
*,
org: str,
content_repo: str,
collection_id: str,
manifest_yaml: str,
) -> dict:
"""§22 S2: commit `<collection_id>/.collection.yaml` to the content
repo's main. A structural admin action — committed straight to main (no
PR), like the registry config it feeds; the registry mirror then upserts
the collections row (§22.2 keeps the registry the source of truth). Logs
an audit row for the §6.5 trail."""
path = f"{collection_id}/.collection.yaml"
created = await self._gitea.create_file(
org,
content_repo,
path,
content=manifest_yaml,
message=_stamp_single(f"chore: create collection {collection_id}", actor),
branch="main",
author_name=actor.display_name,
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
)
_log(
actor,
"create_collection",
bot_commit_sha=created.get("commit", {}).get("sha"),
details={"collection_id": collection_id, "repo": content_repo},
)
return created
async def create_project(
self,
actor: Actor,
*,
org: str,
registry_repo: str,
content_repo: str,
project_id: str,
projects_yaml_new: str,
projects_yaml_sha: str,
readme_text: str,
) -> dict:
"""§22 S5 (§A.2): stand up a new project. A global-Owner action wrapping
a bot write at two git sources:
1. **provision the content repo** — create `org/content_repo` if it
doesn't exist, then seed a `README.md` on `main` so the branch
exists (the contents API initialises the repo with that commit; the
corpus mirror and the propose path both need a `main` to write to).
2. **register the project** — commit the caller-composed
`projects.yaml` (the existing doc with the new project appended) to
the registry repo's `main`.
Like `create_collection`, this is a structural admin action committed
straight to main (no PR), like the registry config it feeds; the caller
then re-runs the registry mirror so the new `projects` + default
`collections` rows flow from the registry (§22.2 keeps the registry the
source of truth). Logs a `create_project` audit row for the §6.5 trail.
Returns the registry update_file result (carries the new commit sha)."""
ae = actor.email or f"{actor.gitea_login}@users.noreply"
existing = await self._gitea.get_repo(org, content_repo)
if existing is None:
await self._gitea.create_org_repo(
org, content_repo, description=f"Content repo for project {project_id}"
)
# Seed a README if absent, which also establishes `main` on a freshly
# created (auto_init=False) repo — the contents API initialises the repo
# with that commit. Keyed on the README rather than the branch so it is
# idempotent and behaves identically whether the repo has a bare `main`
# or no branch at all. Mirrors `ensure_rfc_repo_seed`'s empty-repo seed.
readme = await self._gitea.get_contents(org, content_repo, "README.md", ref="main")
if readme is None:
await self._gitea.create_file(
org,
content_repo,
"README.md",
content=readme_text,
message=_stamp_single(f"chore: initialise content repo for {project_id}", actor),
branch="main",
author_name=actor.display_name,
author_email=ae,
)
result = await self._gitea.update_file(
org,
registry_repo,
"projects.yaml",
content=projects_yaml_new,
sha=projects_yaml_sha,
message=_stamp_single(f"chore: create project {project_id}", actor),
branch="main",
author_name=actor.display_name,
author_email=ae,
)
commit_sha = (
result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha")
or ""
)
_log(
actor,
"create_project",
bot_commit_sha=commit_sha,
details={"project_id": project_id, "content_repo": content_repo},
)
return result
# ----- 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,
rfcs_dir: str = "rfcs",
) -> dict:
"""Per §9.1: open a meta-repo PR adding one file under `<rfcs_dir>/`.
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>`. §22 S2:
`rfcs_dir` carries the target collection's `<subfolder>/rfcs` so a
propose into a named collection writes under its subfolder; it
defaults to `rfcs` (the default collection / shipped behaviour).
"""
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_dir}/{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,
)
# ----- Entry sidecar writes (§22.4a SLICE-4) -----
async def commit_entry_files(
self, actor: Actor, *, org: str, repo: str,
files: list[dict], message: str, branch: str = "main",
) -> dict:
"""Commit a set of entry file ops (sidecar + body-only `.md`, from
`metadata.write_entry_files`) in one commit. Used by the direct-commit
metadata paths and, on a branch, by `open_entry_pr`."""
return await self._gitea.change_files(
org, repo, files=files,
message=_stamp_single(message, actor), branch=branch,
author_name=actor.display_name,
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
)
async def open_entry_pr(
self, actor: Actor, *, org: str, repo: str, slug: str,
files: list[dict], pr_title: str, pr_description: str,
branch_prefix: str = "metadata",
) -> dict:
"""Create a branch, commit entry file ops there, and open a PR — the
sidecar-aware successor to `open_metadata_pr`'s single-file write."""
import secrets
branch = f"{branch_prefix}-{slug}-{secrets.token_hex(3)}"
await self._gitea.create_branch(org, repo, branch, from_branch="main")
await self.commit_entry_files(
actor, org=org, repo=repo, files=files,
message=pr_title, branch=branch)
_subject, pr_body = _stamp("", pr_description, actor)
pr = await self._gitea.create_pull(
org, repo, title=pr_title, body=pr_body, head=branch, base="main")
_log(actor, "open_entry_pr", rfc_slug=slug, branch_name=branch,
pr_number=pr["number"], details={"pr_title": pr_title})
return pr
# ----- 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
# ----- §13 graduation (meta-only): open + merge the flip PR -----
async def open_graduation_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
slug: str,
files: list[dict],
rfc_id: str | None,
owners: list[str],
) -> dict:
"""§13.3 (meta-only): open a PR against the meta repo that flips the
entry to `state: active` with the graduation stamps and — **optionally**
— the integer `id`, **keeping the body unchanged** (§1 meta-only
topology; no repo is created and no body is stripped). The graduation
metadata is written to the entry's sidecar (§22.4a) via `files`; a legacy
`.md` is lazy-migrated to body-only in the same commit. When `rfc_id` is
None the entry graduates without a number (id stays null, slug is
canonical per §2.3, §13.2). Branch name uses the `graduate-<slug>-<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")
commit_subject = f"Graduate {slug}{rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
result = await self.commit_entry_files(
actor, org=org, repo=meta_repo, files=files,
message=commit_subject, branch=branch)
commit_sha = (
result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha")
or ""
)
pr_title = f"Graduate {slug}{rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
owners_str = ", ".join(owners) if owners else "(none)"
id_line = f"- ID: `{rfc_id}`\n" if rfc_id else "- ID: (none — identified by slug)\n"
pr_body_text = (
f"Graduates super-draft `{slug}` to active.\n\n"
f"{id_line}"
f"- Owners: {owners_str}\n\n"
f"This is an in-place state flip per the meta-only topology\n"
f"(SPEC §1, §13.3): the entry `rfcs/{slug}.md` keeps its body and\n"
f"stays in the meta repo. Only the frontmatter changes — `state`,\n"
f"`id`, and the graduation stamps."
)
_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},
)
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,
) -> 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.
Gitea computes a PR's mergeability asynchronously in a background
job. Step 3 (`open_graduation_pr`) returns the moment the PR is
created, which is typically before that job has finished — calling
`merge_pull` immediately produces a `405 "Please try again later"`
and the §13.3 orchestrator interprets it as a terminal failure.
We wait for the mergeability computation to settle before the
merge call, then retry the merge a couple of times if Gitea still
reports the transient state (belt-and-suspenders against the same
race appearing in a different shape).
"""
subject = f"Graduate {slug}{rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
body = _trailer(actor)
try:
await self._gitea.wait_for_mergeable(org, meta_repo, pr_number)
except TimeoutError as e:
raise GiteaError(409, str(e)) from e
await _merge_with_retry(
self._gitea, org, meta_repo, pr_number,
merge_message_title=subject, merge_message_body=body,
)
_log(
actor,
"graduate_pr_merge",
rfc_slug=slug,
branch_name=head_branch,
pr_number=pr_number,
details={"rfc_id": rfc_id},
)
# ----- §13.7 retire / un-retire: open + merge a state-flip PR -----
async def open_retire_flip_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
slug: str,
files: list[dict],
verb: str,
target_state: str,
) -> dict:
"""§13.7: open a PR flipping an entry to `state: <target_state>` — for
retire (`verb='retire'`, target `retired`) or un-retire
(`verb='unretire'`, target the restored prior state). The `state` change
is written to the entry's metadata sidecar (§22.4a), keeping the `.md`
body and every other field, so an un-retire restores the entry exactly.
`files` come from `metadata.write_entry_files`. Branch shape mirrors
graduation's `<verb>-<slug>-<6hex>`.
"""
import secrets
branch = f"{verb}-{slug}-{secrets.token_hex(3)}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
verb_title = "Retire" if verb == "retire" else "Un-retire"
result = await self.commit_entry_files(
actor, org=org, repo=meta_repo, files=files,
message=f"{verb_title} {slug}", branch=branch)
commit_sha = (
result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha")
or ""
)
pr_title = f"{verb_title} {slug}"
pr_body_text = (
f"{verb_title}s `{slug}` (state → `{target_state}`).\n\n"
f"This is an in-place frontmatter flip per SPEC §13.7 — the\n"
f"entry `rfcs/{slug}.md` keeps its body and every other field;\n"
f"only `state` changes."
)
_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,
f"{verb}_pr_open",
rfc_slug=slug,
branch_name=branch,
pr_number=pr["number"],
bot_commit_sha=commit_sha,
details={"pr_title": pr_title, "target_state": target_state},
)
return pr
async def merge_retire_flip_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
pr_number: int,
head_branch: str,
slug: str,
verb: str,
) -> None:
"""§13.7: auto-merge the retire / un-retire flip PR (same
mergeable-wait + retry shape as `merge_graduation_pr`)."""
verb_title = "Retire" if verb == "retire" else "Un-retire"
subject = f"{verb_title} {slug}"
body = _trailer(actor)
try:
await self._gitea.wait_for_mergeable(org, meta_repo, pr_number)
except TimeoutError as e:
raise GiteaError(409, str(e)) from e
await _merge_with_retry(
self._gitea, org, meta_repo, pr_number,
merge_message_title=subject, merge_message_body=body,
)
_log(
actor,
f"{verb}_pr_merge",
rfc_slug=slug,
branch_name=head_branch,
pr_number=pr_number,
details={},
)
# ----- §13.3 (meta-only): cleanup of an unmerged flip PR -----
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 companion `delete_branch` call lives next to the rollback
caller in `api_graduation.py` per the §19.2 'graduation rollback's
branch cleanup' candidate Slice 8 settles — the §12 hygiene
sweep would catch the branch eventually, but closing the loop
on rollback avoids accumulation."""
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},
)
# ----- §12 hygiene: branch deletion -----
async def delete_branch(
self,
actor: Actor | None,
*,
owner: str,
repo: str,
branch: str,
slug: str | None,
action_kind: str,
reason: str,
bot_login: str | None = None,
) -> None:
"""Per §12: the bot deletes a stale branch from Gitea.
Three callers: the §12 hygiene sweep (90-day boundary on
meta-repo edit branches), the §10.7 90-day post-merge timer
for per-RFC PR branches, and the graduation-rollback cleanup
for `graduate-<slug>-<6hex>` per §19.2 "graduation rollback's
branch cleanup."
For the timer paths the caller passes `actor=None`; the audit
row lands with `actor_user_id=NULL` and `on_behalf_of=bot_login`
per §15.9's "system-generated events" rule — "the app" in the
noun slot. For the rollback case the human actor flows through
the standard `_log` shape.
Idempotent against the Gitea API — 404 from a prior delete is
swallowed so a retried sweep doesn't crash.
"""
try:
await self._gitea.delete_branch(owner, repo, branch)
except Exception as exc:
from .gitea import GiteaError as _GE
if isinstance(exc, _GE) and exc.status == 404:
pass
else:
raise
details = {"repo": f"{owner}/{repo}", "reason": reason}
if actor is None:
# System actor: write the audit row directly. Fan-out is
# skipped — `delete_stale_branch` and `delete_post_merge_branch`
# are intentionally absent from `notify._AUTO_WATCH_ACTIONS`
# and `_ROUTING`, so no notification fires. The branches
# being deleted are stale; the population that watched them
# would be churn-grade noise per §15.4.
db.conn().execute(
"""
INSERT INTO actions
(actor_user_id, on_behalf_of, action_kind, rfc_slug, branch_name, details)
VALUES (NULL, ?, ?, ?, ?, ?)
""",
(
bot_login or "",
action_kind,
slug,
branch,
json.dumps(details),
),
)
return
_log(
actor,
action_kind,
rfc_slug=slug,
branch_name=branch,
details=details,
)
# ----- §13.1 claim PRs -----
async def open_claim_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
slug: str,
files: list[dict],
) -> dict:
"""§13.1: open a PR adding the actor to the entry's `owners:` list.
Writes the updated `owners:` to the entry's metadata sidecar (§22.4a)
via `files`; a legacy `.md` is lazy-migrated to body-only in the same
commit. Branch shape is `claim/<slug>` — 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")
commit_subject = f"Claim ownership of {slug} for {actor.gitea_login}"
result = await self.commit_entry_files(
actor, org=org, repo=meta_repo, files=files,
message=commit_subject, branch=branch)
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
# ----- §22.4c: mark-reviewed (direct main write) -----
async def mark_entry_reviewed(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
slug: str,
reviewed_by: str,
reviewed_at: str,
) -> None:
"""Clear §22.4c unreviewed on an active entry by writing its metadata
sidecar on main (§22.4a). Dual-reads the entry (so a migrated body-only
`.md` doesn't crash) and lazy-migrates a legacy `.md` to body-only in the
same commit. Stamps the §6.5 On-behalf-of trailer and writes an
actions-log row, mirroring the graduation stamp's bot-write shape."""
path = f"rfcs/{slug}.md"
st = await metadata_mod.read_entry_from_git(self._gitea, org, meta_repo, path)
if st is None:
raise GiteaError(404, f"{path} not found")
e = metadata_mod.apply_values(st.entry, {
"unreviewed": False, "reviewed_at": reviewed_at, "reviewed_by": reviewed_by,
})
files = metadata_mod.write_entry_files(path, e, st)
result = await self.commit_entry_files(
actor, org=org, repo=meta_repo, files=files,
message=f"Mark {slug} reviewed", branch="main")
commit_sha = (
result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha")
or ""
)
_log(
actor,
"mark_reviewed",
rfc_slug=slug,
bot_commit_sha=commit_sha,
details={"reviewed_by": reviewed_by, "reviewed_at": reviewed_at},
)
# ----- 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