"""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--<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--<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