Slice 8 WIP: §12 hygiene + §10.7 + routing + rollback cleanup

- Add §12 30/90 hygiene scheduler in hygiene.py, mirroring the
  DigestScheduler shape; wires next to digest in main.py with the
  same start/stop/run_tick test seam.
- Extend bot.delete_branch to accept actor=None for system gestures,
  per §15.9 (actor_user_id=NULL, on_behalf_of=bot_login).
- Convert every branches/{branch} route in api_branches.py and
  api_prs.py to {branch:path}; move the bare GET to the bottom of
  the router so deeper GETs match before greedy-path swallow.
- Extend api_prs.py's _require_pr to accept pr_kind='meta_metadata'
  so the §9.5 metadata-pane PRs land an in-app merge.
- Graduation rollback now deletes the graduate-<slug>-<6hex> branch
  after closing the PR — §19.2 candidate that lands here.
- Email-bounce webhook gains a WEBHOOK_EMAIL_BOUNCE_SECRET seam.
- FakeGitea grows a DELETE /branches/{branch:path} handler and a
  slashed-branch read; integration tests for the hygiene vertical
  cover the 30d close, 90d delete, post-merge delete, pinned
  exemption, per-user cursor preservation, no-notification rule,
  and the graduation-rollback cleanup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-25 04:03:09 -07:00
parent 060fa408a2
commit 1a0c4428af
9 changed files with 965 additions and 105 deletions
+24 -2
View File
@@ -157,8 +157,11 @@ class FakeGitea:
items.append({"name": name, "commit": {"id": b["sha"], "timestamp": b.get("ts")}})
return httpx.Response(200, json=items)
# GET /repos/{owner}/{repo}/branches/{branch}
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches/([^/]+)", path)
# GET /repos/{owner}/{repo}/branches/{branch}. Branch name may
# contain slashes per the §19.2 path-routing candidate Slice 8
# settles — the FakeGitea matcher mirrors what real Gitea
# accepts on the wire.
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches/(.+)", path)
if method == "GET" and m:
owner, repo, branch = m.groups()
b = self.branches.get((owner, repo), {}).get(branch)
@@ -166,6 +169,25 @@ class FakeGitea:
return httpx.Response(404, json={"message": "not found"})
return httpx.Response(200, json={"name": branch, "commit": {"id": b["sha"]}})
# DELETE /repos/{owner}/{repo}/branches/{branch} — Slice 8 §12
# hygiene actuator and the graduation-rollback branch cleanup
# both reach this endpoint via `bot.delete_branch`. Branch path
# may contain slashes (the §19.2 path-routing candidate) so the
# regex catches the rest-of-path.
m_delbr = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches/(.+)", path)
if method == "DELETE" and m_delbr:
owner, repo, branch = m_delbr.groups()
br_map = self.branches.get((owner, repo), {})
if branch not in br_map:
return httpx.Response(404, json={"message": "not found"})
br_map.pop(branch, None)
# Drop the branch's files too so a subsequent read 404s.
self.files = {
k: v for k, v in self.files.items()
if not ((k[0], k[1], k[2]) == (owner, repo, branch))
}
return httpx.Response(204, json={})
# POST /repos/{owner}/{repo}/branches
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches", path)
if method == "POST" and m: