"""Discovery — populate DISCOVERED workflows from Forgejo's open PRs/issues. The master polls Forgejo for open PRs + open issues every ``CONTROLLER_DISCOVERY_INTERVAL_S`` (default 30s). New entities (no existing workflow row) get a fresh ``Workflow(current_state='DISCOVERED')``. Existing entities are left alone — their state machine is already running. Forgejo HTTP is parameterized via two callbacks so tests can inject synthetic responses. Production wires the callbacks to existing helpers in ``tools/_review_fetch.py`` (Phase 1d-3c follow-up — this commit ships the controller-side logic). """ from __future__ import annotations import logging from collections.abc import Callable from dataclasses import dataclass, field from datetime import datetime, timezone from sqlalchemy import text from sqlalchemy.engine import Engine from ..db.session import session_scope logger = logging.getLogger(__name__) # Each callback returns a list of dicts. Required keys per entry: # - PRs: {"number": int, "title": str} # - Issues: {"number": int, "title": str} # Extra keys are ignored by discovery; the prefetch step (separate # module) pulls full details later. ListPRsCallback = Callable[[str, str], list[dict]] ListIssuesCallback = Callable[[str, str], list[dict]] @dataclass class DiscoveredEntity: """One entity discovered by a sweep.""" workflow_id: int kind: str # 'pr' or 'issue' entity_number: int @dataclass class DiscoveryReport: """Per-sweep summary.""" prs_seen: int = 0 issues_seen: int = 0 new_workflows: int = 0 existing_skipped: int = 0 new_entities: list[DiscoveredEntity] = field(default_factory=list) def run_discovery( engine: Engine, *, owner: str, repo: str, list_prs: ListPRsCallback, list_issues: ListIssuesCallback | None = None, ) -> DiscoveryReport: """One discovery sweep against Forgejo. Idempotent — if an entity already has a workflow row, this is a no-op for it. Safe to run every tick. """ report = DiscoveryReport() # Fetch from Forgejo (or test fake). try: prs = list_prs(owner, repo) or [] except Exception: logger.exception("discovery: list_prs callback raised; skipping PRs") prs = [] try: issues = list_issues(owner, repo) if list_issues else [] except Exception: logger.exception("discovery: list_issues callback raised; skipping issues") issues = [] report.prs_seen = len(prs) report.issues_seen = len(issues) if not prs and not issues: return report with session_scope(engine) as session: # Build a set of (kind, entity_number) we already track. existing = session.execute( text( "SELECT kind, entity_number FROM workflows " "WHERE owner = :owner AND repo = :repo" ), {"owner": owner, "repo": repo}, ).all() existing_set = {(r.kind, r.entity_number) for r in existing} now = datetime.now(timezone.utc) for pr in prs: number = _coerce_int(pr.get("number")) if number is None: logger.warning("discovery: PR with non-int number skipped: %r", pr) continue if ("pr", number) in existing_set: report.existing_skipped += 1 continue wf_id = _insert_discovered( session, owner, repo, kind="pr", entity_number=number, now=now, ) report.new_workflows += 1 report.new_entities.append(DiscoveredEntity( workflow_id=wf_id, kind="pr", entity_number=number, )) for issue in issues: number = _coerce_int(issue.get("number")) if number is None: logger.warning("discovery: issue with non-int number skipped: %r", issue) continue if ("issue", number) in existing_set: report.existing_skipped += 1 continue wf_id = _insert_discovered( session, owner, repo, kind="issue", entity_number=number, now=now, ) report.new_workflows += 1 report.new_entities.append(DiscoveredEntity( workflow_id=wf_id, kind="issue", entity_number=number, )) if report.new_workflows: logger.info( "discovery: %d new workflow(s) (PRs=%d, issues=%d); %d existing skipped", report.new_workflows, report.prs_seen, report.issues_seen, report.existing_skipped, ) return report # ─── helpers ────────────────────────────────────────────────────────── def _coerce_int(value) -> int | None: if isinstance(value, bool): # True is an int subclass; reject it return None if isinstance(value, int): return value if isinstance(value, str) and value.isdigit(): return int(value) return None def _insert_discovered( session, owner: str, repo: str, *, kind: str, entity_number: int, now: datetime, ) -> int: """Insert a fresh DISCOVERED workflow and return its id. Uses raw SQL (not the ORM) for symmetry with the rest of the master module + because lastrowid is the simplest way to get the autoincremented PK from a text() insert. """ result = session.execute( text( "INSERT INTO workflows " "(kind, owner, repo, entity_number, current_state, " " started_at, last_transition_at, entered_state_at, " " max_attempts, merging_retry_count) " "VALUES (:kind, :owner, :repo, :entity_number, 'DISCOVERED', " " :now, :now, :now, 6, 0)" ), { "kind": kind, "owner": owner, "repo": repo, "entity_number": entity_number, "now": now, }, ) wf_id = result.lastrowid # Insert a controller_events row. session.execute( text( "INSERT INTO controller_events " "(workflow_id, ts, event_type, to_state, payload, " " forgejo_write_pending, replay_attempts) " "VALUES (:wf_id, :ts, 'discovered', 'DISCOVERED', " " :payload, 0, 0)" ), { "wf_id": wf_id, "ts": now, "payload": _discovery_payload(kind, entity_number, owner, repo), }, ) return wf_id def _discovery_payload(kind: str, entity_number: int, owner: str, repo: str) -> str: import json return json.dumps({ "kind": kind, "entity_number": entity_number, "owner": owner, "repo": repo, "source": "discovery_poll", }) __all__ = [ "DiscoveredEntity", "DiscoveryReport", "ListIssuesCallback", "ListPRsCallback", "run_discovery", ]