0bc734c020
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>
282 lines
8.5 KiB
Python
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",
|
|
]
|