Slice 5: graduation per §13

The §13.3 transactional sequence flips a super-draft to active —
five steps with paired undoes, an in-process orchestrator fed by
an asyncio.Queue, the §17 SSE endpoint streaming step transitions
to the dialog. Each step is a new bot primitive that logs an
`actions` row, bracketed by `graduate_start` / `graduate_complete`
for the linkable audit sequence. Rollback runs the undoes in
reverse from the last completed step; merge_pr has no undo by
design per §13.5.

The §9.8 precondition gate is enforced server-side at the top of
POST /graduate so the §13.3 rollback complexity does not grow.
The §13.4 chat migration is a database semantic no-op — the
(slug, branch_name='main') threads keep their identity, only the
interpretation changes. The §9.8 pre-graduation history surfaces
via a new _is_meta_target(rfc, branch) dispatch helper and lands
as pre_graduation_history on /main.

§13.1 claim flow landed alongside since it's the prerequisite for
non-admin graduation — bot.open_claim_pr plus broadening
api_prs._require_pr to accept meta_claim.

45/45 tests green; ten new integration tests cover the validator,
the §9.8 precondition refusal, happy path with audit verification,
mid-sequence rollback at steps 2 and 3, concurrent refusal,
chat-survives-without-data-movement, pre-graduation history, and
the §13.1 claim PR cycle.

SPEC.md §19.1 rewritten for Slice 6 (notifications); §19.2 grew
four candidates surfaced during the slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 21:52:29 -07:00
parent 4565a6cb95
commit 1b0968a9a2
14 changed files with 2872 additions and 172 deletions
+308
View File
@@ -627,6 +627,314 @@ class Bot:
)
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-<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")
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-<slug>-<hex>`
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/<slug>.md`. 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")
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(