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
+12
View File
@@ -128,6 +128,18 @@ class FakeGitea:
return httpx.Response(200, json={"name": repo, "full_name": f"{owner}/{repo}"})
return httpx.Response(404, json={"message": "not found"})
# DELETE /repos/{owner}/{repo} — Slice 5 graduation rollback uses
# this to undo step 1 (repo create). The FakeGitea drops every
# file, branch, and PR tied to the repo so a subsequent retry
# graduation can re-create the repo cleanly.
if method == "DELETE" and m_repo:
owner, repo = m_repo.groups()
self.repos.discard((owner, repo))
self.branches.pop((owner, repo), None)
self.pulls.pop((owner, repo), None)
self.files = {k: v for k, v in self.files.items() if (k[0], k[1]) != (owner, repo)}
return httpx.Response(204, json={})
# POST /orgs/{org}/repos
m = re.fullmatch(r"/orgs/([^/]+)/repos", path)
if method == "POST" and m: