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