"""§12: the branch-hygiene scheduler. The structural commitment Slice 8 owes. Closes the loop on §11.5's branch lifecycle (open → closed at 30d → deleted at 90d) and on §10.7's post-merge deletion timer for per-RFC PR branches. The sweep rides next to `DigestScheduler` per the §19.1 brief — same `start` / `stop` / `run_tick` shape, same hourly cadence by default, same test seam pattern. The cadence is configurable via `HYGIENE_TICK_SECONDS` for tests and dev. Per §15.9 and the §19.1 brief, hygiene actions fire as "the app" — `actor_user_id = NULL` and `on_behalf_of = bot_login`. The action kinds (`close_idle_branch`, `delete_stale_branch`, `delete_post_merge_branch`) are intentionally outside `notify._AUTO_WATCH_ACTIONS` and `notify._ROUTING`, so no notifications fan out for the hygiene gestures. The branches being touched are stale by definition; the affected population would be churn-grade noise per §15.4. The per-user message-cursor preservation contract per §11.5: this module never touches `branch_chat_messages` or `branch_chat_seen`. Chat history survives the branch's deletion in Gitea because those tables are app-canonical, not cached. """ from __future__ import annotations import asyncio import logging import os from datetime import datetime, timedelta, timezone from . import db from .bot import Bot from .config import Config log = logging.getLogger(__name__) # Window sizes per §11.5 / §12 / §10.7. The 30/90 numbers are the # canonical spec values; they are exposed as env vars so the integration # tests can compress windows to small fractions of a second without # touching production code. def _close_after_days() -> int: return int(os.environ.get("HYGIENE_CLOSE_AFTER_DAYS", "30")) def _delete_after_days() -> int: return int(os.environ.get("HYGIENE_DELETE_AFTER_DAYS", "90")) # --------------------------------------------------------------------------- # Scheduler shell — mirrors DigestScheduler # --------------------------------------------------------------------------- class HygieneScheduler: """Periodic task wrapper that drives `run_tick()`. Same lifecycle contract as DigestScheduler so the operator's mental model stays "the app has three scheduled jobs, all the same shape" (reconciler, digest, hygiene).""" def __init__(self, *, config: Config, bot: Bot, tick_seconds: int | None = None): self._config = config self._bot = bot self._tick = tick_seconds or int(os.environ.get("HYGIENE_TICK_SECONDS", "3600")) self._task: asyncio.Task | None = None self._stop = asyncio.Event() def start(self) -> None: if self._task is None: self._task = asyncio.create_task(self._loop()) async def stop(self) -> None: self._stop.set() if self._task is not None: await self._task async def _loop(self) -> None: await self._safe_tick() while not self._stop.is_set(): try: await asyncio.wait_for(self._stop.wait(), timeout=self._tick) except asyncio.TimeoutError: pass if self._stop.is_set(): break await self._safe_tick() async def _safe_tick(self) -> None: try: await run_tick(config=self._config, bot=self._bot) except Exception: log.exception("hygiene tick failed") # --------------------------------------------------------------------------- # The tick itself # --------------------------------------------------------------------------- async def run_tick(*, config: Config, bot: Bot, now: datetime | None = None) -> dict[str, int]: """One pass over the §12 + §10.7 surfaces. Returns counters for observability and tests. Idempotent — a second tick within the same window is a no-op because the state-flip is monotonic (open → closed → deleted). Tests pass an explicit `now` to control the time horizon; production uses `datetime.now(timezone.utc)`. """ if now is None: now = datetime.now(timezone.utc) closed_after = timedelta(days=_close_after_days()) deleted_after = timedelta(days=_delete_after_days()) close_cutoff = (now - closed_after).strftime("%Y-%m-%d %H:%M:%S") delete_cutoff = (now - deleted_after).strftime("%Y-%m-%d %H:%M:%S") counters = { "closed_idle": 0, "closed_post_merge": 0, "deleted_stale": 0, "deleted_post_merge": 0, } # Order matters: deletes fire BEFORE closes so a branch that # crosses both boundaries in the same sweep (a long-merged PR # whose branch is still open in the cache, the cache-bootstrap # case the brief calls out) goes straight to 'deleted' rather # than spending one tick at 'closed' with a fresh closed_at that # would delay the delete by another 90 days. Real-time sweeps see # the two windows 60 days apart, so this is only load-bearing for # cache-bootstrap and clock-jump cases — but those are exactly the # cases this slice hardens against. # ---- 90-day delete: §10.7 fast-path for merged-PR branches that # never got flipped to 'closed' (cache-bootstrap from history the # bot did not author, or a process restart that skipped enough # ticks for both boundaries to land in one sweep). ---- post_merge_delete = db.conn().execute( f""" SELECT DISTINCT b.rfc_slug, b.branch_name FROM cached_branches b JOIN cached_prs p ON p.rfc_slug = b.rfc_slug AND p.head_branch = b.branch_name WHERE b.state IN ('open', 'closed') AND b.pinned = 0 AND p.state = 'merged' AND COALESCE(p.merged_at, '') != '' AND p.merged_at <= ? """, (delete_cutoff,), ).fetchall() for r in post_merge_delete: ok = await _delete_branch_via_bot( config=config, bot=bot, slug=r["rfc_slug"], branch=r["branch_name"], action_kind="delete_post_merge_branch", reason="90d post-merge", ) if ok: counters["deleted_post_merge"] += 1 # ---- 90-day delete: idle branches that closed long enough ago ---- stale_rows = db.conn().execute( f""" SELECT b.rfc_slug, b.branch_name, b.closed_at, b.last_commit_at FROM cached_branches b WHERE b.state = 'closed' AND b.pinned = 0 AND b.branch_name != 'main' AND COALESCE(b.closed_at, b.last_commit_at, b.created_at) <= ? """, (delete_cutoff,), ).fetchall() for r in stale_rows: ok = await _delete_branch_via_bot( config=config, bot=bot, slug=r["rfc_slug"], branch=r["branch_name"], action_kind="delete_stale_branch", reason="90d closed", ) if ok: counters["deleted_stale"] += 1 # ---- 30-day close: idle open branches ---- # # §11.5: a branch with no associated PR auto-closes at 30 days from # last commit. The query joins against cached_prs to exclude # branches that have any open PR (those stay open) or any merged PR # (those are handled by the post-merge timer below). Pinned branches # (§12) skip the close. idle_rows = db.conn().execute( f""" SELECT b.rfc_slug, b.branch_name FROM cached_branches b WHERE b.state = 'open' AND b.pinned = 0 AND b.branch_name != 'main' AND COALESCE(b.last_commit_at, b.created_at) <= ? AND NOT EXISTS ( SELECT 1 FROM cached_prs p WHERE p.rfc_slug = b.rfc_slug AND p.head_branch = b.branch_name AND p.state IN ('open', 'merged') ) """, (close_cutoff,), ).fetchall() for r in idle_rows: _close_branch(r["rfc_slug"], r["branch_name"], config.gitea_bot_user, reason="30d idle") counters["closed_idle"] += 1 # ---- 30-day "settle": post-merge branches still flagged open ---- # # §10.7: after merge, the branch enters a closed state per §12. In # practice the cached_branches row may still read state='open' # immediately after the merge (the meta-repo refresh doesn't flip # it). At the 30-day mark the hygiene sweep formalizes the closure # so the rest of the app reads "this branch is sealed." Pinned # branches retain open state. post_merge_close = db.conn().execute( f""" SELECT DISTINCT b.rfc_slug, b.branch_name FROM cached_branches b JOIN cached_prs p ON p.rfc_slug = b.rfc_slug AND p.head_branch = b.branch_name WHERE b.state = 'open' AND b.pinned = 0 AND p.state = 'merged' AND COALESCE(p.merged_at, '') != '' AND p.merged_at <= ? """, (close_cutoff,), ).fetchall() for r in post_merge_close: _close_branch(r["rfc_slug"], r["branch_name"], config.gitea_bot_user, reason="30d post-merge") counters["closed_post_merge"] += 1 return counters # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _close_branch(slug: str, branch: str, bot_login: str, *, reason: str) -> None: """Flip the cached_branches row to state='closed' and write the audit row. No Gitea call — the branch stays present in Gitea until the 90-day mark. The `On-behalf-of` shape mirrors the bot's `delete_branch` system path: actor_user_id=NULL, on_behalf_of=bot.""" db.conn().execute( """ UPDATE cached_branches SET state = 'closed', closed_at = COALESCE(closed_at, datetime('now')) WHERE rfc_slug = ? AND branch_name = ? AND state = 'open' """, (slug, branch), ) db.conn().execute( """ INSERT INTO actions (actor_user_id, on_behalf_of, action_kind, rfc_slug, branch_name, details) VALUES (NULL, ?, 'close_idle_branch', ?, ?, ?) """, (bot_login, slug, branch, _json_details({"reason": reason})), ) async def _delete_branch_via_bot( *, config: Config, bot: Bot, slug: str, branch: str, action_kind: str, reason: str, ) -> bool: """Call `bot.delete_branch` with the system actor. Resolves the `(org, repo)` pair from the slug: super-draft edit branches and graduation branches live on the meta repo; active-RFC branches live on the per-RFC repo named by `cached_rfcs.repo`. Returns True on a clean delete; False if the rfc row is missing (we leave the branch row in place — a subsequent reconciler sweep will reconcile or the operator can intervene).""" rfc = db.conn().execute( "SELECT state, repo FROM cached_rfcs WHERE slug = ?", (slug,) ).fetchone() if rfc is None: log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch) return False if rfc["state"] == "super-draft": owner, repo = config.gitea_org, config.meta_repo elif rfc["state"] == "active" and rfc["repo"] and "/" in rfc["repo"]: owner, repo = rfc["repo"].split("/", 1) else: log.warning("hygiene: cannot resolve repo for %s state=%s", slug, rfc["state"]) return False try: await bot.delete_branch( None, owner=owner, repo=repo, branch=branch, slug=slug, action_kind=action_kind, reason=reason, bot_login=config.gitea_bot_user, ) except Exception: log.exception("hygiene: bot.delete_branch failed for %s/%s", slug, branch) return False db.conn().execute( """ UPDATE cached_branches SET state = 'deleted' WHERE rfc_slug = ? AND branch_name = ? """, (slug, branch), ) return True def _json_details(payload: dict) -> str: import json return json.dumps(payload)