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:
@@ -0,0 +1,371 @@
|
||||
"""End-to-end integration tests for the Slice 8 §12 + §10.7 hygiene
|
||||
vertical.
|
||||
|
||||
The slice closes §11.5's branch-lifecycle loop. The scheduler shape
|
||||
mirrors `DigestScheduler` — the `run_tick(now=...)` seam lets the tests
|
||||
compress the 30/90-day windows to seconds without monkey-patching the
|
||||
clock.
|
||||
|
||||
The tests prove:
|
||||
|
||||
* Per §11.5 idle hygiene: a branch with no PR and `last_commit_at`
|
||||
past the 30-day mark flips to `state='closed'` and an audit row
|
||||
with `action_kind='close_idle_branch'` lands. `on_behalf_of` is
|
||||
the bot login and `actor_user_id` is NULL per §15.9.
|
||||
* Per §10.7: a per-RFC PR whose merge fell past the 90-day mark
|
||||
triggers a branch deletion via the bot — `delete_post_merge_branch`
|
||||
audit row, `cached_branches.state` flips to 'deleted', and the
|
||||
branch is gone from Gitea (the FakeGitea models the DELETE).
|
||||
* Per §12: a closed branch past the 90-day mark gets deleted
|
||||
(`delete_stale_branch`).
|
||||
* The per-user message-cursor preservation contract per §11.5:
|
||||
`branch_chat_seen` rows survive the hygiene sweep even when the
|
||||
branch row flips to 'deleted'.
|
||||
* The graduation rollback's branch cleanup deletes the
|
||||
`graduate-<slug>-<6hex>` branch (per the §19.2 candidate Slice 8
|
||||
settles).
|
||||
* `notify._AUTO_WATCH_ACTIONS` doesn't include the hygiene kinds,
|
||||
so no notifications fire.
|
||||
* Pinned branches are exempt from the close and delete passes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json as _json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_super_draft_vertical import seed_super_draft # noqa: F401
|
||||
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc # noqa: F401
|
||||
|
||||
|
||||
def _run_hygiene(*, now: datetime, app):
|
||||
"""Drive one hygiene tick synchronously. The scheduler's tests-only
|
||||
`run_tick(now=...)` seam mirrors DigestScheduler's `run_tick()`
|
||||
pattern from Slice 6."""
|
||||
from app import hygiene
|
||||
config = app.state.config
|
||||
bot = app.state.bot
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
hygiene.run_tick(config=config, bot=bot, now=now)
|
||||
)
|
||||
|
||||
|
||||
def _aiorun(coro):
|
||||
"""Synchronous awaiter for use inside TestClient blocks (already
|
||||
inside an event loop). The hygiene module is async; pytest's
|
||||
asyncio support would also work, but matching the Slice 6 digest
|
||||
tests' shape keeps the file readable."""
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §12 30/90 timers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_idle_branch_flips_to_closed_at_30_day_mark(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, hygiene
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
# Insert an idle open branch directly; the bot's create_branch
|
||||
# path normally writes this row through the cache reconciler.
|
||||
# We backdate `last_commit_at` to 40 days ago so the 30-day
|
||||
# window is breached.
|
||||
forty_days = (datetime.now(timezone.utc) - timedelta(days=40)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
|
||||
VALUES ('ohm', 'idle-branch', 'sha-idle', 'open', ?)
|
||||
""",
|
||||
(forty_days,),
|
||||
)
|
||||
# No cached_prs row → idle path applies.
|
||||
|
||||
counters = _aiorun(hygiene.run_tick(config=app.state.config, bot=app.state.bot))
|
||||
assert counters["closed_idle"] == 1, counters
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT state, closed_at FROM cached_branches WHERE branch_name = 'idle-branch'"
|
||||
).fetchone()
|
||||
assert row["state"] == "closed"
|
||||
assert row["closed_at"] # populated
|
||||
|
||||
audit = db.conn().execute(
|
||||
"SELECT actor_user_id, on_behalf_of, action_kind FROM actions WHERE action_kind = 'close_idle_branch'"
|
||||
).fetchone()
|
||||
assert audit is not None
|
||||
assert audit["actor_user_id"] is None # §15.9 system-generated
|
||||
assert audit["on_behalf_of"] == "rfc-bot"
|
||||
|
||||
|
||||
def test_closed_branch_deleted_at_90_day_mark(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, hygiene
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
owner, repo = "wiggleverse", "rfc-0001-ohm"
|
||||
|
||||
# Create the branch in FakeGitea so the delete has something to
|
||||
# remove; the cached_branches row points at the same name.
|
||||
fake.branches[(owner, repo)]["stale-branch"] = {
|
||||
"sha": "sha-stale", "ts": "2026-01-01T00:00:00Z",
|
||||
}
|
||||
long_ago = (datetime.now(timezone.utc) - timedelta(days=120)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at, closed_at)
|
||||
VALUES ('ohm', 'stale-branch', 'sha-stale', 'closed', ?, ?)
|
||||
""",
|
||||
(long_ago, long_ago),
|
||||
)
|
||||
|
||||
counters = _aiorun(hygiene.run_tick(config=app.state.config, bot=app.state.bot))
|
||||
assert counters["deleted_stale"] == 1, counters
|
||||
|
||||
# Cached row flipped + Gitea branch gone.
|
||||
row = db.conn().execute(
|
||||
"SELECT state FROM cached_branches WHERE branch_name = 'stale-branch'"
|
||||
).fetchone()
|
||||
assert row["state"] == "deleted"
|
||||
assert "stale-branch" not in fake.branches[(owner, repo)]
|
||||
|
||||
audit = db.conn().execute(
|
||||
"""
|
||||
SELECT actor_user_id, on_behalf_of, branch_name
|
||||
FROM actions WHERE action_kind = 'delete_stale_branch'
|
||||
"""
|
||||
).fetchone()
|
||||
assert audit["actor_user_id"] is None
|
||||
assert audit["on_behalf_of"] == "rfc-bot"
|
||||
assert audit["branch_name"] == "stale-branch"
|
||||
|
||||
|
||||
def test_pinned_branch_skipped_by_both_passes(app_with_fake_gitea):
|
||||
"""§12 / §11.5: owners and arbiters can pin a branch to disable
|
||||
the auto-close timer. The pin must hold against both the 30-day
|
||||
close and the 90-day delete."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, hygiene
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
owner, repo = "wiggleverse", "rfc-0001-ohm"
|
||||
fake.branches[(owner, repo)]["pinned-branch"] = {
|
||||
"sha": "sha-pin", "ts": "2026-01-01T00:00:00Z",
|
||||
}
|
||||
ancient = (datetime.now(timezone.utc) - timedelta(days=200)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
# Pinned + ancient + open → should NOT close.
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO cached_branches
|
||||
(rfc_slug, branch_name, head_sha, state, last_commit_at, pinned)
|
||||
VALUES ('ohm', 'pinned-branch', 'sha-pin', 'open', ?, 1)
|
||||
""",
|
||||
(ancient,),
|
||||
)
|
||||
|
||||
counters = _aiorun(hygiene.run_tick(config=app.state.config, bot=app.state.bot))
|
||||
assert counters["closed_idle"] == 0
|
||||
assert counters["deleted_stale"] == 0
|
||||
assert "pinned-branch" in fake.branches[(owner, repo)]
|
||||
|
||||
|
||||
def test_post_merge_branch_deleted_at_90_day_mark(app_with_fake_gitea):
|
||||
"""§10.7: a per-RFC PR whose merge fell past the 90-day mark gets
|
||||
its branch deleted by the bot, with audit_kind='delete_post_merge_branch'.
|
||||
Rides on the §12 hygiene sweep per the §19.1 brief."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, hygiene
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
owner, repo = "wiggleverse", "rfc-0001-ohm"
|
||||
|
||||
# Mock a merged PR with a stale merged_at.
|
||||
fake.branches[(owner, repo)]["alice-draft-deadbeef"] = {
|
||||
"sha": "sha-merged", "ts": "2026-01-01T00:00:00Z",
|
||||
}
|
||||
stale_merge = (datetime.now(timezone.utc) - timedelta(days=120)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
|
||||
VALUES ('ohm', 'alice-draft-deadbeef', 'sha-merged', 'open', ?)
|
||||
""",
|
||||
(stale_merge,),
|
||||
)
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO cached_prs
|
||||
(rfc_slug, pr_kind, repo, pr_number, title, state,
|
||||
opened_by, opened_at, merged_at, head_branch, base_branch, head_sha)
|
||||
VALUES
|
||||
('ohm', 'rfc_branch', ?, 42, 'Edit OHM', 'merged',
|
||||
'alice', ?, ?, 'alice-draft-deadbeef', 'main', 'sha-merged')
|
||||
""",
|
||||
(f"{owner}/{repo}", stale_merge, stale_merge),
|
||||
)
|
||||
|
||||
counters = _aiorun(hygiene.run_tick(config=app.state.config, bot=app.state.bot))
|
||||
# The branch was open + had a merged PR 120d ago → both the 30d
|
||||
# post-merge close and the 90d delete fire in the same sweep.
|
||||
# The 90d delete is the load-bearing assertion.
|
||||
assert counters["deleted_post_merge"] == 1, counters
|
||||
|
||||
assert "alice-draft-deadbeef" not in fake.branches[(owner, repo)]
|
||||
|
||||
audit = db.conn().execute(
|
||||
"""
|
||||
SELECT actor_user_id, on_behalf_of FROM actions
|
||||
WHERE action_kind = 'delete_post_merge_branch' AND branch_name = 'alice-draft-deadbeef'
|
||||
"""
|
||||
).fetchone()
|
||||
assert audit is not None
|
||||
assert audit["actor_user_id"] is None
|
||||
assert audit["on_behalf_of"] == "rfc-bot"
|
||||
|
||||
|
||||
def test_branch_chat_seen_survives_branch_deletion(app_with_fake_gitea):
|
||||
"""§11.5 per-user message-cursor preservation contract: deleting
|
||||
the branch in Gitea and flipping cached_branches.state='deleted'
|
||||
leaves the per-user branch_chat_seen rows untouched. Chat history
|
||||
survives the branch's deletion because the chat tables are
|
||||
app-canonical, not cached."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, hygiene
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
owner, repo = "wiggleverse", "rfc-0001-ohm"
|
||||
fake.branches[(owner, repo)]["doomed"] = {
|
||||
"sha": "sha-doomed", "ts": "2026-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
long_ago = (datetime.now(timezone.utc) - timedelta(days=120)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at, closed_at)
|
||||
VALUES ('ohm', 'doomed', 'sha-doomed', 'closed', ?, ?)
|
||||
""",
|
||||
(long_ago, long_ago),
|
||||
)
|
||||
# Seed a per-user seen cursor against the doomed branch.
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO branch_chat_seen (user_id, rfc_slug, branch_name, last_seen_message_id, seen_at)
|
||||
VALUES (2, 'ohm', 'doomed', 999, datetime('now'))
|
||||
"""
|
||||
)
|
||||
|
||||
_aiorun(hygiene.run_tick(config=app.state.config, bot=app.state.bot))
|
||||
|
||||
# The branch row flipped to deleted.
|
||||
b = db.conn().execute(
|
||||
"SELECT state FROM cached_branches WHERE branch_name = 'doomed'"
|
||||
).fetchone()
|
||||
assert b["state"] == "deleted"
|
||||
# But the per-user cursor is preserved.
|
||||
seen = db.conn().execute(
|
||||
"SELECT last_seen_message_id FROM branch_chat_seen WHERE user_id = 2 AND branch_name = 'doomed'"
|
||||
).fetchone()
|
||||
assert seen is not None
|
||||
assert seen["last_seen_message_id"] == 999
|
||||
|
||||
|
||||
def test_hygiene_action_kinds_fire_no_notifications(app_with_fake_gitea):
|
||||
"""§15.1 routing: the hygiene action kinds (`close_idle_branch`,
|
||||
`delete_stale_branch`, `delete_post_merge_branch`) are intentionally
|
||||
absent from notify._AUTO_WATCH_ACTIONS and notify._ROUTING. The
|
||||
spec doesn't commit a notification for them, and the right call is
|
||||
no notification — stale-branch deletes targeting watchers who
|
||||
haven't engaged in 90 days would be churn-grade noise per §15.4."""
|
||||
from app import notify
|
||||
|
||||
for kind in ("close_idle_branch", "delete_stale_branch", "delete_post_merge_branch"):
|
||||
assert kind not in notify._AUTO_WATCH_ACTIONS, kind
|
||||
assert kind not in notify._ROUTING, kind
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Graduation rollback's branch cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea):
|
||||
"""§19.2 candidate Slice 8 settles: when graduation rolls back
|
||||
after step 3 (open_pr), the `graduate-<slug>-<6hex>` branch is
|
||||
deleted alongside the PR close so failed-graduation branches
|
||||
don't accumulate on the meta repo across retries."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
from app.bot import Bot
|
||||
from app.gitea import GiteaError
|
||||
|
||||
from test_graduation_vertical import seed_owned_super_draft, PITCH # noqa: F401
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(
|
||||
fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"],
|
||||
)
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Force a step-4 (merge_pr) failure so step 3 (open_pr) has
|
||||
# already landed and the rollback exercises the branch cleanup.
|
||||
orig_merge = Bot.merge_graduation_pr
|
||||
async def boom(self, *args, **kwargs):
|
||||
raise GiteaError(502, "simulated merge failure for rollback test")
|
||||
Bot.merge_graduation_pr = boom
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
|
||||
"owners": ["ben"]},
|
||||
)
|
||||
finally:
|
||||
Bot.merge_graduation_pr = orig_merge
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["succeeded"] is False
|
||||
|
||||
# The dash-suffixed graduation branch was deleted on rollback.
|
||||
meta_branches = fake.branches[("wiggleverse", "meta")]
|
||||
graduation_branches = [n for n in meta_branches if n.startswith("graduate-ohm-")]
|
||||
assert graduation_branches == [], (
|
||||
f"expected no graduate-ohm-* branches after rollback, got {graduation_branches}"
|
||||
)
|
||||
|
||||
# Audit log carries delete_post_merge_branch (the action kind
|
||||
# the rollback path reuses for the cleanup).
|
||||
kinds = [
|
||||
r["action_kind"]
|
||||
for r in db.conn().execute(
|
||||
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
|
||||
)
|
||||
]
|
||||
assert "graduate_pr_close" in kinds
|
||||
assert "delete_post_merge_branch" in kinds
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user