e8045d4b9b
Discovery polls Forgejo for open PRs/issues and inserts a fresh DISCOVERED workflow for any entity the controller hasn't seen yet. Same callback-injection pattern as the scheduler's prefetch: tests provide synthetic ListPRsCallback / ListIssuesCallback; production wires them to the existing _review_fetch helpers in a follow-up. tools/controller/master/discovery.py: - run_discovery(engine, owner, repo, list_prs, list_issues=None) — idempotent. Skips existing entities via a one-shot SELECT(kind, entity_number) WHERE owner+repo membership check. Inserts a DISCOVERED workflow + a controller_events 'discovered' row per new entity. Per-callback try/except: a PR-list failure doesn't block issue discovery, and vice versa. - Defensive coerce_int: rejects bools (True is an int subclass — bug class to avoid). Accepts string digits. - Returns DiscoveryReport(prs_seen, issues_seen, new_workflows, existing_skipped, new_entities[]). 12 new tests: - basics (empty, PRs only, issues only, both PRs+issues) - idempotency (2nd sweep skips; PR #42 + issue #42 coexist; other (owner, repo) isolated) - malformed input (non-int + bool skipped; string digit accepted) - callback failures (PR raises → still process issues; issues callback optional) - event row creation per new workflow Plus a worker test deflake: test_stolen_lock_between_agent_return_and_write was asserting the specific 'lost-lock-at-write' outcome, but under full-suite CPU contention the heartbeat thread can fire between the agent's return and _write_outcome — taking the 'lost-lock' (via lost_lock_event) path instead. Both are valid for this scenario; loosened the assertion to accept either. Total: 310 controller tests; full auto_agents suite 2672 pass.
216 lines
6.8 KiB
Python
216 lines
6.8 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
|
|
|
|
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",
|
|
]
|