"""Shared helpers for reading ``controller_events`` rows. The controller stores ALL state-machine transitions in ``controller_events`` with ``event_type='transition'`` and the actual state-machine event name (e.g. ``'groom_verdict_defer'``, ``'estimator_abandon'``) in the JSON ``payload['event']`` field — see ``tick.py:451-462`` for the canonical writer. This is a load-bearing convention: any per-state side-effect tick that needs to find workflows by their most-recent transition must read it the same way. Phase 1's grooming side-effect tick (``grooming_side_effects.py``) hand-rolled the SQL: a window-function correlated subquery selecting the most-recent row per workflow + ``json_extract(payload, '$.event')``. Phase 2's estimator-abandon side-effect tick needs the same pattern; Phase 3 (reviewer abandon) will too. Rather than have each tick re-discover the convention (and the related bug class — Phase 1's first build of the grooming tick filtered on ``event_type`` literally and silently no-op'd because real transitions are written with ``event_type='transition'``), this module centralizes the read primitive. See ``.drew/regressions-plan.md`` "Phase 1 follow-up backlog" for the rationale + the Phase 2 prerequisite note. """ from __future__ import annotations from typing import Iterable from sqlalchemy import text from sqlalchemy.engine import Engine from sqlalchemy.orm import Session def _sm_event_sql(dialect: str, column_expr: str) -> str: """Dialect-aware SQL fragment that extracts ``payload['event']`` from a JSON column. - SQLite: ``json_extract(, '$.event')`` - PostgreSQL: `` ->> 'event'`` (works for both JSON and JSONB) Pulled out as a helper so the per-tick SELECTs stay readable AND the convention has exactly ONE place to grow if a future dialect (or a future payload-shape change) needs to be supported. Phase 1 shipped with hardcoded ``json_extract`` everywhere, which silently no-op'd in any PostgreSQL deployment — the test suite only ran against SQLite. Phase 2 fixes that bug AND prevents the next side-effect tick from re-introducing it. """ if dialect == "postgresql": return f"{column_expr} ->> 'event'" return f"json_extract({column_expr}, '$.event')" def _dialect_name(session: Session) -> str: """Resolve the bind's dialect name, defaulting to sqlite if the session isn't bound to an engine (e.g. some test setups).""" return session.bind.dialect.name if session.bind else "sqlite" def latest_transition_event( session: Session, workflow_id: int ) -> tuple[str | None, str | None]: """Return ``(event_type, sm_event_name)`` for the most recent ``controller_events`` row for ``workflow_id``. - ``event_type`` is the literal column value (e.g. ``'transition'``, ``'label-pause'``, ``'discovered'``, ``'lock-ttl-expired'``). - ``sm_event_name`` is the state-machine event name when ``event_type == 'transition'`` (read from ``payload['event']``); ``None`` for all other event_types (which don't carry an SM event name). Returns ``(None, None)`` when the workflow has no events at all. """ sm_event_expr = _sm_event_sql(_dialect_name(session), "payload") row = session.execute( text( f""" SELECT event_type, {sm_event_expr} AS sm_event FROM controller_events WHERE workflow_id = :wf_id ORDER BY ts DESC, event_id DESC LIMIT 1 """ ), {"wf_id": workflow_id}, ).first() if row is None: return (None, None) return (row.event_type, row.sm_event) def workflows_with_latest_transition_in( session: Session, sm_event_names: Iterable[str], ) -> list[tuple[int, str, str, int, str]]: """Return rows for every workflow whose MOST RECENT ``controller_events`` entry is a state-machine transition whose ``payload['event']`` matches one of ``sm_event_names``. Returns a list of ``(workflow_id, owner, repo, entity_number, sm_event_name)`` tuples. The (owner, repo, entity_number) tuple lets the caller drive Forgejo calls without an extra SELECT per workflow. Used by per-state side-effect ticks (grooming, estimator-abandon, reviewer-abandon) to find workflows whose state-machine just transitioned to a state requiring Forgejo writes. Idempotency: callers MUST gate on a separate "already-executed" flag (e.g. ``grooming_decisions.executed = 1``) — this helper only finds workflows in the right SM state; it does NOT distinguish "needs side-effect to run" from "side-effect already ran." See ``grooming_side_effects._process_one`` for the standard pattern. """ sm_event_names = list(sm_event_names) if not sm_event_names: return [] # Build the IN-clause placeholders. We can't use the SQLAlchemy # native ``in_`` here because the surrounding query is text-mode # for the window function; bind each name positionally. placeholders = ",".join(f":sm_event_{i}" for i in range(len(sm_event_names))) params: dict[str, object] = { f"sm_event_{i}": name for i, name in enumerate(sm_event_names) } sm_event_expr = _sm_event_sql(_dialect_name(session), "le.payload") rows = session.execute( text( f""" SELECT w.workflow_id, w.owner, w.repo, w.entity_number, {sm_event_expr} AS sm_event FROM workflows w INNER JOIN ( SELECT workflow_id, event_type, payload, ts, ROW_NUMBER() OVER ( PARTITION BY workflow_id ORDER BY ts DESC, event_id DESC ) AS rn FROM controller_events ) le ON le.workflow_id = w.workflow_id AND le.rn = 1 WHERE le.event_type = 'transition' AND {sm_event_expr} IN ({placeholders}) """ ), params, ).all() return [ (r.workflow_id, r.owner, r.repo, r.entity_number, r.sm_event) for r in rows ] __all__ = [ "latest_transition_event", "workflows_with_latest_transition_in", ]