From 734290f34443f92ea51dcc0c0487a64efa381b1b Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 7 Jun 2026 18:46:09 -0700 Subject: [PATCH] =?UTF-8?q?feat(slice4):=20bot.commit=5Fentry=5Ffiles=20+?= =?UTF-8?q?=20open=5Fentry=5Fpr=20multi-file=20primitives=20(=C2=A722.4a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/bot.py | 39 ++++++++++++++++- backend/tests/test_bot_entry_files.py | 62 +++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_bot_entry_files.py diff --git a/backend/app/bot.py b/backend/app/bot.py index f58d3df..eb3609d 100644 --- a/backend/app/bot.py +++ b/backend/app/bot.py @@ -27,7 +27,7 @@ import json import logging from dataclasses import dataclass -from . import db, entry as entry_mod, notify +from . import db, entry as entry_mod, metadata as metadata_mod, notify from .gitea import Gitea, GiteaError log = logging.getLogger(__name__) @@ -404,6 +404,43 @@ class Bot: 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( diff --git a/backend/tests/test_bot_entry_files.py b/backend/tests/test_bot_entry_files.py new file mode 100644 index 0000000..60b01ca --- /dev/null +++ b/backend/tests/test_bot_entry_files.py @@ -0,0 +1,62 @@ +"""SLICE-4 — bot multi-file commit + PR primitives.""" +from __future__ import annotations + +import asyncio + +from app import gitea as gitea_mod, metadata +from app.bot import Actor, Bot +from app.config import load_config + +from test_propose_vertical import ( # noqa: F401 + app_with_fake_gitea, + provision_user_row, + tmp_env, +) + +LEGACY = "---\nslug: alpha\ntitle: Alpha\nstate: active\ntags:\n- one\n---\n\nBody.\n" + + +def _actor(): + return Actor(user_id=1, gitea_login="ben.stull", display_name="Ben", email="ben@x.io") + + +def test_commit_entry_files_direct_to_main(app_with_fake_gitea): + _app, fake = app_with_fake_gitea + gitea = gitea_mod.Gitea(load_config()) + bot = Bot(gitea) + fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = { + "content": LEGACY, "sha": "s1"} + st = asyncio.run(metadata.read_entry_from_git(gitea, "wiggleverse", "meta", "rfcs/alpha.md")) + e2 = metadata.apply_values(st.entry, {"tags": ["two"]}) + ops = metadata.write_entry_files("rfcs/alpha.md", e2, st) + asyncio.run(bot.commit_entry_files( + _actor(), org="wiggleverse", repo="meta", files=ops, + message="Edit metadata", branch="main")) + sc = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"] + assert "two" in sc + # .md is now body-only + assert "---" not in fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"] + + +def test_open_entry_pr_commits_on_branch_and_opens_pr(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, fake = app_with_fake_gitea + gitea = gitea_mod.Gitea(load_config()) + bot = Bot(gitea) + fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = { + "content": LEGACY, "sha": "s1"} + with TestClient(app): # lifespan inits the DB + provision_user_row(user_id=1, login="ben.stull", role="owner") + st = asyncio.run(metadata.read_entry_from_git(gitea, "wiggleverse", "meta", "rfcs/alpha.md")) + e2 = metadata.apply_values(st.entry, {"tags": ["two"]}) + ops = metadata.write_entry_files("rfcs/alpha.md", e2, st) + pr = asyncio.run(bot.open_entry_pr( + _actor(), org="wiggleverse", repo="meta", slug="alpha", files=ops, + pr_title="Metadata: Alpha", pr_description="edit", branch_prefix="metadata")) + assert pr["number"] >= 1 + head = pr["head"]["ref"] + assert head.startswith("metadata-alpha-") + # committed on the branch, main untouched + assert ("wiggleverse", "meta", head, "rfcs/alpha.meta.yaml") in fake.files + assert ("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml") not in fake.files