Files
cleveragents-core/tools/controller/master/discovery.py
T
drew 0bc734c020 style: ruff format the controller-state-machine branch (288 files)
Applies `ruff format` to the accumulated formatting debt on this branch.
Formatting-only — no behavioral changes. Required for CI/lint's format
gate (`nox -s format -- --check`), which the branch was failing on 288
tracked files that drifted from ruff's canonical style.

In-progress WIP files are intentionally excluded so this commit stays a
clean formatting-only diff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:09:17 -04:00

282 lines
8.5 KiB
Python

"""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
from .label_gate import (
filter_by_opt_in_label as _filter_by_opt_in_label,
opt_in_label_name as _opt_in_label_name,
)
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
label_filtered_out: 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,
opt_in_label: str | None = None,
require_opt_in_label: bool = False,
) -> 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.
Phase 1k: when ``require_opt_in_label=True`` (production default
set in ``__main__.py``), only PRs/issues carrying the opt-in label
are discovered. The label name defaults to
``CONTROLLER_OPT_IN_LABEL`` env (or ``controller-managed``). The
function default is False so tests + the legacy "manage everything"
deployment mode work without changes.
"""
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 require_opt_in_label:
label = opt_in_label if opt_in_label is not None else _opt_in_label_name()
kept_prs = _filter_by_opt_in_label(prs, label)
kept_issues = _filter_by_opt_in_label(issues, label)
report.label_filtered_out = (len(prs) - len(kept_prs)) + (
len(issues) - len(kept_issues)
)
if report.label_filtered_out:
logger.info(
"discovery: %d entit(ies) lacked opt-in label %r; skipping",
report.label_filtered_out,
label,
)
prs = kept_prs
issues = kept_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",
]