"""Delta-cached PR enumeration for the reviewer dispatcher. Replaces the 5 per-cycle ``list_prs_*.ts`` subprocess calls (the flaky path that timed out at 120 s in runs 14 / 15 / 16) with native Python that: - Fetches the open-PR list ONCE per cycle, sorted by ``updated_at`` descending. - For each PR, checks the on-disk cache (``PipelineCache.pr_classifications``): if ``head_sha`` matches, ``updated_at`` hasn't advanced, schema version matches, and last-checked is within TTL → reuse the cached classification with ZERO per-PR API calls. - Re-classifies (fetching CI status + reviews + commits) only for PRs that actually changed since the last check. - Applies the 5 reviewer-filter predicates to all classifications. See ``.drew/planning/fix list_prs_by_filter.md`` for the full plan, including phased migration (Phase 1 ships this module + the MCP tool with the dispatcher unchanged; Phase 2 cuts the dispatcher over behind a feature flag). Cache hit predicate (all four must hold): cached.head_sha == pr.head.sha cached.updated_at >= pr.updated_at cached.classification_schema == current now - cached.last_checked_at < ttl_seconds The schema_version axis is the lever for invalidating the entire cache en masse when a new classification axis is added (e.g. adding ``has_unmerged_dependency`` would bump from v1 → v2 and all v1 rows would cache-miss-and-refetch on first read). This module is pure logic over the cache + the existing ``_review_fetch`` helpers — no agent-side I/O, no LLM calls. """ from __future__ import annotations import datetime as _dt import json import logging import os import sys from datetime import datetime, timedelta, timezone from pathlib import Path from types import SimpleNamespace from typing import Any sys.path.insert(0, str(Path(__file__).resolve().parent)) from _loader import load_sibling # noqa: E402 _claim_runtime = load_sibling("_claim_runtime", "_claim_runtime.py") _review_fetch = load_sibling("_review_fetch", "_review_fetch.py") _pipeline_cache = load_sibling("_pipeline_cache", "_pipeline_cache.py") _backoff = load_sibling("_backoff", "_backoff.py") _pr_state_cache = load_sibling("_pr_state_cache", "_pr_state_cache.py") _cycle_cap = load_sibling("_cycle_cap", "_cycle_cap.py") _logger = logging.getLogger(__name__) # The 5 reviewer-side filter names. Lifted verbatim from # ``dispatch_review._make_work_group`` calls so a typo here surfaces # as a clean ValueError, not a silent no-match. FILTER_NAMES = ( "addressed_changes_ci_passing", "addressed_changes_ci_failing", "no_active_review_ci_passing", "no_active_review_ci_failing", "missing_ci_checks", ) DEFAULT_TTL_SECONDS = 300 # Forgejo /pulls flakiness resilience (R1, 2026-05-16). Live tests # saw the ``/repos/{owner}/{repo}/pulls?state=open&...`` endpoint # time out at the default 30 s request_timeout_s on roughly 1 in 5 # cycles (4 timeouts observed in run-18 alone). Each timeout # triggered a 30 s wait followed by a fallback to the legacy # ``npx --yes tsx list_prs_*.ts`` script that ALSO times out at # 120 s in npx-cold-start conditions, costing 30-150 s per affected # cycle. Two cheap fixes: # # - **Shorter per-call timeout** (default 8 s) for the listing # fetch. Forgejo serves a clean list in <500 ms on the happy # path; 8 s is plenty of headroom while bounding the worst case # so we fall back fast. # - **Last-good listing cache + exponential backoff** so the next # N cycles inside the backoff window short-circuit to the cached # listing instead of paying the timeout again. # 30s default chosen after run-21 probe (2026-05-16): Forgejo's # ``/pulls`` endpoint takes ~24s on a cold cache because the response # inlines full ``head``/``base`` repo+owner metadata (90 KB of 127 KB # total for 8 PRs). The original 8s default fail-fast caused the # dispatcher to time out before the cold-cache rebuild completed, # triggering backoff while Forgejo's already-in-flight rebuild ran # to completion server-side. Bumping to 30s lets a single attempt # succeed; subsequent attempts within Forgejo's hot-cache window # return in <1s. _LIST_TIMEOUT_S_DEFAULT = 30 _LIST_TIMEOUT_ENV = "REVIEW_DISPATCHER_PR_LIST_TIMEOUT_S" _LIST_CACHE_DIR_DEFAULT = "/tmp/cleveragents-pr-list-cache" _LIST_CACHE_DIR_ENV = "REVIEW_DISPATCHER_PR_LIST_CACHE_DIR" _LIST_BACKOFF = _backoff.Backoff( base_env="REVIEW_DISPATCHER_PR_LIST_BACKOFF_BASE_S", max_env="REVIEW_DISPATCHER_PR_LIST_BACKOFF_MAX_S", base_default=60, max_default=1800, ) _LIST_DISABLE_ENV = "REVIEW_DISPATCHER_PR_LIST_CACHE_DISABLE" _LIST_CACHE_SCHEMA_VERSION = 1 # Label names that mark a PR as claimed by another agent. Mirrors # ``list_prs.ts``'s CLAIM_LABELS set. Coordinated with # ``tools/setup_auto_labels.py`` and the worker-side ``claim_pr.ts``; # mutating this set is a breaking change against the 4 agents that # read these labels. CLAIM_LABELS = frozenset( ( "auto/claimed-merge", "auto/claimed-implementer", "auto/claimed-reviewer", ) ) # When this label is present on a PR, every reviewer filter # unconditionally excludes it — the iteration-cap mechanism # (tools/_cycle_cap.py) applies the label after N consecutive # no-progress cycles. Operator must remove the label manually # after investigation to re-enable automated work. EXCLUDED_LABELS = frozenset(("auto/needs-human-triage",)) def refresh_then_filter( cfg: Any, filter_name: str, ttl_seconds: int = DEFAULT_TTL_SECONDS, cache: Any = None, ) -> list[dict[str, Any]]: """Return open PRs matching ``filter_name``, populating the cache as needed. Order is the order Forgejo returned (sorted by ``updated_at`` desc — most-recently-touched PRs first), so the most-actionable PRs surface first in the dispatcher's iteration. Pass ``cache`` to share a connection (the dispatcher should open one per cycle to avoid SQLite open/close overhead); omit it for standalone calls (e.g. from the MCP tool) and the function will open/close its own. """ if filter_name not in FILTER_NAMES: raise ValueError(f"unknown filter {filter_name!r}; valid: {list(FILTER_NAMES)}") own_cache = cache is None if own_cache: cache = _pipeline_cache.PipelineCache.open() try: prs = _list_open_prs_sorted_by_updated(cfg) results: list[dict[str, Any]] = [] for pr in prs: try: classification = _ensure_classified( cache, cfg, pr, ttl_seconds, ) except Exception as exc: # noqa: BLE001 # Per-PR classification failure (transient API blip # on this specific PR's reviews/CI) does NOT abort the # whole enumeration — log + skip the PR. Better to # return a slightly-incomplete list than to fail the # whole reviewer cycle on one bad PR. _logger.warning( "classify PR #%s failed; skipping for this cycle: %s", pr.get("number"), exc, ) continue if _evaluate_filter(filter_name, classification): # Iteration cap: if this filter has matched the same # (head_sha, comment_count) signature for N consecutive # cycles, apply the triage label + exclude this cycle. # Next cycle's filter pass will see the label and skip # via ``is_excluded``. Counter is per-(role, PR) so # the implementer's loop on the same PR has its own # independent budget. if _cap_and_label(cfg, pr, classification): continue results.append(_project_pr(pr, classification)) return results finally: if own_cache: cache.close() def _cap_and_label( cfg: Any, pr: dict[str, Any], classification: dict[str, Any], ) -> bool: """Bump the per-PR no-progress counter for the reviewer role. If the counter exceeds the cap, apply the ``auto/needs-human-triage`` label and return True to signal the caller to skip this PR in the current cycle's result list. Signature = ``(head_sha, reactivity)``. ``reactivity`` combines several reviewer-visible "did anyone react" signals: - ``approvals_count`` — bumps on APPROVE. - ``has_active_request_changes`` (0/1) — bumps on REQUEST_CHANGES. - ``has_unaddressed_request_changes`` (0/1) — bumps when an active RC review remains uncleared. - ``total_reviews`` — count of ALL review objects on the PR. Critical signal: bumps on EVERY review submission including COMMENT-only. Without this term the cap was blind to the ``data_complete=False → COMMENT downgrade`` path the reviewer takes in low-context cycles (run-5 incident, 2026-05-17 — the reviewer kept submitting COMMENT reviews but the cap saw the signature unchanged and fired falsely). Disabled via ``CYCLE_CAP_DISABLE=1`` — emergency rollback if the cap triggers false positives in prod. """ if _cycle_cap.is_disabled(): return False pr_number = int(pr.get("number") or 0) if pr_number <= 0: return False # Fast-path: if the PR already carries the triage label, just # exclude — don't bump the counter, don't re-apply. This handles # the window after a cap-trigger where the label HAS been applied # but the cached classification row hasn't refreshed (label apply # doesn't bump pr.updated_at, so the classification cache stays # "fresh" by TTL until the head SHA or updated_at changes). # Without this, every subsequent cycle re-fires the cap and # re-applies the label. raw_labels = pr.get("labels") or [] pr_label_names = [(l.get("name") or "") for l in raw_labels if isinstance(l, dict)] if _cycle_cap.TRIAGE_LABEL in pr_label_names: return True head_sha = str(classification.get("head_sha") or "") # Reactivity composite: encode every reviewer-visible activity # axis so a change in ANY flips the signature. ``total_reviews`` # is the critical term — bumps on EVERY review submission # (including COMMENT-only, which the boolean flags above ignore). # See ``_apply_iteration_cap`` docstring for the run-5 incident # this term defends against. reactivity = ( int(classification.get("approvals_count") or 0) + (1 if classification.get("has_active_request_changes") else 0) + (1 if classification.get("has_unaddressed_request_changes") else 0) + int(classification.get("total_reviews") or 0) ) signature = _cycle_cap.compute_signature(head_sha, reactivity) state = _cycle_cap.record_pickup( "review", owner=cfg.owner, repo=cfg.repo, pr_number=pr_number, signature=signature, ) if not _cycle_cap.should_skip(state): return False # At the cap: apply the triage label so future cycles see it via # ``is_excluded`` and skip without re-checking. Best-effort — a # failed label-add still causes the current cycle to skip (return # True); the next cycle will re-attempt the add because the # signature stays at the cap count. try: applied = _claim_runtime._add_label( pr_number, _cycle_cap.TRIAGE_LABEL, cfg, ) _logger.warning( "PR #%s hit reviewer iteration cap (count=%s) — applied " "%s label (api_ok=%s); manual triage required", pr_number, state.get("count"), _cycle_cap.TRIAGE_LABEL, applied, ) except Exception as exc: # noqa: BLE001 _logger.warning( "PR #%s hit reviewer iteration cap (count=%s) but label " "apply failed: %s — will retry next cycle", pr_number, state.get("count"), exc, ) return True def _list_timeout_s() -> int: raw = os.environ.get(_LIST_TIMEOUT_ENV) if raw: try: return max(1, int(raw)) except ValueError: pass return _LIST_TIMEOUT_S_DEFAULT def _list_cache_dir() -> Path: return Path(os.environ.get(_LIST_CACHE_DIR_ENV) or _LIST_CACHE_DIR_DEFAULT) def _list_cache_path(cfg: Any) -> Path: """Per-(owner, repo) cache file. We sanitize defensively (the same path-traversal check the ci-logs cache uses) so a malformed owner/repo couldn't be persuaded to write outside the cache dir.""" import re as _re owner = _re.sub(r"[^a-zA-Z0-9_.-]", "", str(getattr(cfg, "owner", "")))[:64] repo = _re.sub(r"[^a-zA-Z0-9_.-]", "", str(getattr(cfg, "repo", "")))[:64] return _list_cache_dir() / f"{owner or 'INVALID'}.{repo or 'INVALID'}.json" def _list_cache_disabled() -> bool: raw = os.environ.get(_LIST_DISABLE_ENV, "").strip().lower() return raw in {"1", "true", "yes", "on"} # Thin wrappers preserved for the existing test suite + any direct # imports; delegate to the shared :class:`_backoff.Backoff`. def _list_compute_next_attempt_after( failures: int, now_dt: _dt.datetime, ) -> str | None: return _LIST_BACKOFF.next_attempt_after(failures, now_dt) def _list_backoff_active( cached: dict[str, Any] | None, now_dt: _dt.datetime, ) -> bool: return _LIST_BACKOFF.is_active(cached, now_dt) def _read_list_cache(cfg: Any) -> dict[str, Any] | None: path = _list_cache_path(cfg) if not path.exists(): return None try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError) as exc: _logger.warning( "PR-list cache read failed for %s: %s", path, exc, ) return None if not isinstance(payload, dict): return None if payload.get("schema_version") != _LIST_CACHE_SCHEMA_VERSION: return None if not isinstance(payload.get("prs"), list): return None return payload def _write_list_cache(cfg: Any, payload: dict[str, Any]) -> None: path = _list_cache_path(cfg) tmp = path.with_suffix(path.suffix + ".tmp") try: path.parent.mkdir(parents=True, exist_ok=True) tmp.write_text( json.dumps(payload, indent=2, default=str), encoding="utf-8", ) tmp.replace(path) except (OSError, TypeError, ValueError) as exc: _logger.warning( "PR-list cache write failed for %s: %s", path, exc, ) try: tmp.unlink(missing_ok=True) except OSError: pass def _cfg_with_short_timeout(cfg: Any) -> Any: """Shallow shadow of cfg with a shorter ``request_timeout_s`` for the /pulls listing only. Keeps the rest of the dispatcher's call surface on the standard (longer) timeout. Returns the original cfg unchanged when no shortening is in effect. Uses ``copy.copy`` rather than a ``dir(cfg)`` walk: the latter triggered every descriptor / property getter for side effects we don't want, and was slower than the stdlib copy anyway.""" import copy as _copy short = _list_timeout_s() current = int(getattr(cfg, "request_timeout_s", 30) or 30) if short >= current: return cfg shadow = _copy.copy(cfg) try: shadow.request_timeout_s = short except (AttributeError, TypeError): # AttributeError: slotted shape with no __dict__. # TypeError: frozen dataclass raises dataclasses.FrozenInstanceError # which subclasses AttributeError on 3.13 but TypeError on # older builds — catch the union for safety. Fall back to # the dir-walk reconstruction for both cases. attrs = { k: getattr(cfg, k) for k in dir(cfg) if not k.startswith("_") and not callable(getattr(cfg, k, None)) } attrs["request_timeout_s"] = short shadow = SimpleNamespace(**attrs) return shadow _WARMER_PREFER_ENV = "PR_STATE_WARMER_PREFER" # How old the warmer's latest write can get before we treat the cache # as stale and fall through to a live fetch. Default 5 min ≈ 10× the # warmer's 30s interval — generous enough to absorb a slow warmer # cycle without false-positive staleness, tight enough to catch a # dead warmer process within a couple of dispatcher cycles. _WARMER_STALE_AFTER_S_DEFAULT = 300 _WARMER_STALE_AFTER_S_ENV = "PR_STATE_WARMER_STALE_AFTER_S" # Floor below which env-provided values are ignored as obvious config # errors. Production should never let an operator set this below # the warmer's interval (config drift would make every cycle fall # through to a live fetch). Tests that need sub-floor values to # drive the staleness gate end-to-end monkeypatch # ``_WARMER_STALE_FLOOR_S`` to 1. _WARMER_STALE_FLOOR_S = 30 def _warmer_preferred() -> bool: """Whether to read from :mod:`_pr_state_cache` before falling back to a live Forgejo fetch. Default ON; ``=0`` rolls back to the pre-warmer code path.""" raw = os.environ.get(_WARMER_PREFER_ENV) if raw is None: return True return raw.strip().lower() in {"1", "true", "yes", "on"} def _warmer_stale_after_s() -> int: """Staleness threshold in seconds. Default 300s (5 min ≈ 10× the warmer's 30s interval). Floored at ``_WARMER_STALE_FLOOR_S`` (30s in prod) so operator config drift can't accidentally produce sub-interval thresholds that fall through to live fetch every cycle. Tests that need sub-floor values to drive the gate end-to-end monkeypatch the floor symbol directly.""" raw = os.environ.get(_WARMER_STALE_AFTER_S_ENV) if raw: try: return max(_WARMER_STALE_FLOOR_S, int(raw)) except ValueError: pass return _WARMER_STALE_AFTER_S_DEFAULT def _warmer_cache_fresh(latest_at: str | None) -> bool: """True iff ``latest_at`` (ISO-8601) is recent enough to trust. None / unparseable → False (treat as stale, fall through). A naive ``latest_at`` (no tz suffix — e.g. a test that monkeypatched ``_now`` or a future schema-drift) is normalized to UTC before the aware-vs-aware comparison so this never raises ``TypeError`` into the dispatcher cycle. """ if not latest_at: return False try: parsed = _dt.datetime.fromisoformat(str(latest_at)) except (ValueError, TypeError): return False if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=_dt.timezone.utc) deadline = parsed + _dt.timedelta(seconds=_warmer_stale_after_s()) return _dt.datetime.now(_dt.timezone.utc) < deadline def _list_open_prs_sorted_by_updated(cfg: Any) -> list[dict[str, Any]]: """List open PRs sorted by most-recently-updated, with three layered sources (preferred → fallback): 1. **Warmer cache** (:mod:`_pr_state_cache`) — populated by the ``pr_state_warmer`` sidecar every ~30s. Local SQLite read, ~0ms, always paginated across all open PRs, never blocks on Forgejo flakes. Default path when warmer is running. 2. **Last-good list cache** — populated by this module on a successful live fetch. Stored at ``/tmp/cleveragents-pr-list-cache``. Used when the warmer's cache is empty (warmer not running yet / disabled) AND when in active backoff window. 3. **Live Forgejo fetch** — single-page ``/pulls?state=open&sort=newest&limit=50&page=1`` call. Used when both caches are empty. Has the 50-PR hard cap and cold-cache flakiness this module was originally built to compensate for; the warmer is the real fix. ``PR_STATE_WARMER_PREFER=0`` skips layer 1 for rollback. """ # Layer 1: warmer cache (preferred). If the warmer has populated # the local SQLite at any point, prefer it — it's always fresher # than the request-time fallback below (30s poll cadence beats # 120s dispatcher cycle) AND it doesn't block on Forgejo. # # Staleness check: if the latest warmer write is older than # ``PR_STATE_WARMER_STALE_AFTER_S`` (default 5 min ≈ 10× the # default 30s interval), the warmer process is likely dead and # the cache is stale — fall through to the live path so the # dispatcher doesn't blindly serve hour-old data. if _warmer_preferred() and not _pr_state_cache.is_disabled(): try: warmer_prs = _pr_state_cache.list_open_prs( owner=cfg.owner, repo=cfg.repo, ) latest_at = _pr_state_cache.latest_write_at( owner=cfg.owner, repo=cfg.repo, ) except _pr_state_cache.PRStateCacheError as exc: _logger.warning("warmer-cache read failed; falling through: %s", exc) warmer_prs = [] latest_at = None # Freshness is keyed on ``latest_at`` (the warmer's heartbeat), # NOT on whether ``warmer_prs`` is non-empty. A repo with zero # open PRs and a healthy warmer ``latest_at`` IS fresh — return # the empty list directly instead of falling through to live # fetch every cycle. fresh = bool(latest_at) and _warmer_cache_fresh(latest_at) if fresh: return warmer_prs if latest_at and not fresh: _logger.warning( "warmer cache for %s/%s is stale (latest write %s); " "warmer may be dead — falling through to live fetch", cfg.owner, cfg.repo, latest_at, ) # Cold warmer cache (no rows ever written): fall through to the # live path below — it doubles as the bootstrap for cold starts. path = f"/repos/{cfg.owner}/{cfg.repo}/pulls?state=open&sort=newest&limit=50&page=1" if _list_cache_disabled(): return _do_live_pr_list_fetch(cfg, path) cached = _read_list_cache(cfg) now_dt = _dt.datetime.now(_dt.timezone.utc) if cached is not None and _list_backoff_active(cached, now_dt): _logger.info( "PR-list cache for %s/%s in backoff " "(failures=%s, next_attempt_after=%s); serving cached list " "(count=%s)", cfg.owner, cfg.repo, cached.get("consecutive_failures", 0), cached.get("next_attempt_after"), len(cached.get("prs") or []), ) return list(cached.get("prs") or []) short_cfg = _cfg_with_short_timeout(cfg) try: live = _do_live_pr_list_fetch(short_cfg, path) except Exception as exc: # noqa: BLE001 # Live fetch failed (timeout / network). Bump failures + serve # cached list if we have one — caller still gets actionable # data. If no cache exists, re-raise so the dispatcher's # outer exception handler can fall back to the legacy TS # path (slow but a different code path that occasionally # succeeds when /pulls is flaky). prior_failures = int((cached or {}).get("consecutive_failures") or 0) next_failures = prior_failures + 1 payload = { "schema_version": _LIST_CACHE_SCHEMA_VERSION, "fetched_at": _now_iso(), "prs": list((cached or {}).get("prs") or []), "consecutive_failures": next_failures, "next_attempt_after": _list_compute_next_attempt_after( next_failures, now_dt, ), "last_error": f"{type(exc).__name__}: {exc}", } _write_list_cache(cfg, payload) if cached is None: _logger.warning( "PR-list live fetch failed for %s/%s with no cached " "fallback; re-raising for legacy TS fallback: %s", cfg.owner, cfg.repo, exc, ) raise _logger.warning( "PR-list live fetch failed for %s/%s (consecutive_failures=%s); " "serving cached list (count=%s); next attempt deferred " "until %s", cfg.owner, cfg.repo, next_failures, len(payload["prs"]), payload["next_attempt_after"], ) return list(payload["prs"]) # Live success — persist (full overwrite) and clear backoff. _write_list_cache( cfg, { "schema_version": _LIST_CACHE_SCHEMA_VERSION, "fetched_at": _now_iso(), "prs": live, "consecutive_failures": 0, "next_attempt_after": None, }, ) return live def _now_iso() -> str: return _dt.datetime.now(_dt.timezone.utc).isoformat() def _do_live_pr_list_fetch(cfg: Any, path: str) -> list[dict[str, Any]]: """The raw Forgejo /pulls listing fetch — same shape as the pre-R1 code. Lifted out of ``_list_open_prs_sorted_by_updated`` so the cache wrapper can call it once on the happy path and once for the disabled-mode bypass without duplicating logic.""" resp = _claim_runtime.idempotent_get(path, cfg) status = int(resp.get("status") or 0) if status != 200: _logger.warning( "PR list fetch returned status=%s; returning empty list", status, ) return [] body = resp.get("body") if not isinstance(body, list): _logger.warning( "PR list fetch returned non-list body (type=%s); returning empty list", type(body).__name__, ) return [] return body def _ensure_classified( cache: Any, cfg: Any, pr: dict[str, Any], ttl_seconds: int, ) -> dict[str, Any]: """Cache-aware classification: return the cached row when fresh, else re-classify and write back.""" pr_number = int(pr.get("number") or 0) cached = cache.get_pr_classification(pr_number) if cached and _is_cache_fresh(cached, pr, ttl_seconds): return cached fresh = _classify_pr(cfg, pr) cache.upsert_pr_classification(fresh) return fresh def _is_cache_fresh( cached: dict[str, Any], pr: dict[str, Any], ttl_seconds: int, ) -> bool: """Four-axis freshness check (per the plan + module docstring).""" head_sha = (pr.get("head") or {}).get("sha") or "" pr_updated = pr.get("updated_at") or "" if cached.get("head_sha") != head_sha: return False # ``>=`` because the cache may have been refreshed AFTER the PR's # updated_at (e.g. two cycles back-to-back). Only when the PR's # updated_at moves strictly past the cache's recorded value do # we re-classify. if (cached.get("updated_at") or "") < pr_updated: return False expected_version = _pipeline_cache.PipelineCache.PR_CLASSIFICATION_SCHEMA_VERSION if cached.get("classification_schema_version") != expected_version: return False last = cached.get("last_checked_at") or "" if not last: return False try: last_dt = datetime.fromisoformat(last.replace("Z", "+00:00")) except (TypeError, ValueError): return False age = datetime.now(timezone.utc) - last_dt if age > timedelta(seconds=ttl_seconds): return False return True def _classify_pr(cfg: Any, pr: dict[str, Any]) -> dict[str, Any]: """Per-PR fetch + classification. Returns a dict with all the schema fields populated.""" pr_number = int(pr.get("number") or 0) head_sha = (pr.get("head") or {}).get("sha") or "" ci_status = _classify_ci_status(cfg, head_sha) reviews, _completed = _review_fetch.fetch_existing_reviews(cfg, pr_number) approvals_count = _count_active_approvals(reviews) has_active_rc = _review_fetch.count_active_request_changes(reviews) > 0 has_unaddressed_rc = _has_unaddressed_request_changes( cfg, pr_number, reviews, ) # Total reviews count is the cycle-cap's "did the reviewer DO # something" signal — bumps on EVERY review submission # (APPROVE / REQUEST_CHANGES / COMMENT), unlike the boolean # `has_active_*` flags which are blind to COMMENT-only reviews. # Without this, the data_complete=False → COMMENT downgrade # path produces reviews that flip no flags and the cap fires # incorrectly (2026-05-17 run-5 incident). total_reviews = len(reviews) if isinstance(reviews, list) else 0 labels = [(l.get("name") or "") for l in (pr.get("labels") or [])] is_claimed = any(name in CLAIM_LABELS for name in labels) # Iteration-cap exclusion: ``auto/needs-human-triage`` (set by # ``_cycle_cap`` after N no-progress cycles) drops the PR out of # every reviewer filter until an operator removes the label. is_excluded = any(name in EXCLUDED_LABELS for name in labels) mergeable = pr.get("mergeable") # bool or None # stale_state: not used by any of the 5 reviewer filters today, # so we punt — set to "not_stale" without fetching the base branch. # When a future filter needs real stale-state, add a one-call-per- # base-ref lookup (typically just origin/master once per cycle). stale_state = "not_stale" return { "pr_number": pr_number, "head_sha": head_sha, "updated_at": pr.get("updated_at") or "", "last_checked_at": datetime.now(timezone.utc).isoformat(), "ci_status": ci_status, "approvals_count": approvals_count, "has_active_request_changes": has_active_rc, "has_unaddressed_request_changes": has_unaddressed_rc, "total_reviews": total_reviews, "is_claimed": is_claimed, "is_excluded": is_excluded, "is_mergeable": mergeable, "stale_state": stale_state, "labels_json": json.dumps(labels), "classification_schema_version": ( _pipeline_cache.PipelineCache.PR_CLASSIFICATION_SCHEMA_VERSION ), } def _classify_ci_status(cfg: Any, head_sha: str) -> str: """Map Forgejo's combined-status state into the 4-value classification ``list_prs.ts`` uses. Match its mapping exactly so the Python output is parity with the TS output.""" if not head_sha: return "unknown" status = _review_fetch.fetch_ci_status(cfg, head_sha) if not status: return "unknown" state = (status.get("state") or "").lower() if state == "success": return "passing" # list_prs.ts treats failure/error/warning all as "failing". if state in ("failure", "error", "warning"): return "failing" if state == "pending": return "pending" # Empty string or anything else is "no CI checks reported yet". return "unknown" def _count_active_approvals(reviews: list[dict[str, Any]]) -> int: """Count non-dismissed APPROVE reviews using one-per-author semantics (only the author's latest review counts). Matches ``list_prs.ts``'s approvals_count: a reviewer who first APPROVEs then later REQUEST_CHANGES gives zero approvals (the APPROVE is superseded). Stale reviews (those marked ``stale`` after a new commit) still count if not dismissed — that's the Forgejo default and matches the TS semantic. """ latest_per_user: dict[str, dict[str, Any]] = {} for r in reviews: if not isinstance(r, dict): continue user_obj = r.get("user") login = (user_obj.get("login") if isinstance(user_obj, dict) else None) or "" submitted_at = r.get("submitted_at") or "" current = latest_per_user.get(login) if current is None or submitted_at > (current.get("submitted_at") or ""): latest_per_user[login] = r count = 0 for r in latest_per_user.values(): if r.get("dismissed"): continue if (r.get("state") or "").upper() == "APPROVED": count += 1 return count def _has_unaddressed_request_changes( cfg: Any, pr_number: int, reviews: list[dict[str, Any]], ) -> bool: """True iff there's at least one active REQUEST_CHANGES review that has NOT been followed by a new commit. Pulls the PR's commit list to compare timestamps against the latest active RC review. ``list_prs.ts`` uses the same shape. The commit fetch is the most expensive part of classification (PR commit list can be long), so the cache-hit path SKIPS this entirely. """ rc_timestamps: list[str] = [] for r in reviews: if not isinstance(r, dict): continue if r.get("dismissed"): continue if (r.get("state") or "").upper() != "REQUEST_CHANGES": continue ts = r.get("submitted_at") or "" if ts: rc_timestamps.append(ts) if not rc_timestamps: return False latest_rc = max(rc_timestamps) commits, _completed = _review_fetch.fetch_pr_commits(cfg, pr_number) for c in commits: if not isinstance(c, dict): continue commit = c.get("commit") or {} committer = commit.get("committer") if isinstance(commit, dict) else {} commit_ts = (committer.get("date") if isinstance(committer, dict) else "") or "" if commit_ts > latest_rc: return False return True def _evaluate_filter(filter_name: str, row: dict[str, Any]) -> bool: """Apply the named filter predicate to a classification row. Predicates are mechanical translations of the 5 TS wrapper scripts' hard-coded args in ``.opencode/skills/auto-agents-system/scripts/list_prs_*.ts``. Verified against the scripts' comment blocks 2026-05-16. """ # Iteration-cap exclusion: a PR carrying ``auto/needs-human-triage`` # drops out of every reviewer filter unconditionally. Operator # removes the label after investigation to re-enable automated # work. Older cached classification rows predating the # ``is_excluded`` axis read it as falsy via ``.get`` default. if bool(row.get("is_excluded")): return False if filter_name == "addressed_changes_ci_passing": return ( row["ci_status"] == "passing" and row["approvals_count"] == 0 and bool(row["has_active_request_changes"]) and not bool(row["has_unaddressed_request_changes"]) and not bool(row["is_claimed"]) ) if filter_name == "addressed_changes_ci_failing": return ( row["ci_status"] == "failing" and row["approvals_count"] == 0 and bool(row["has_active_request_changes"]) and not bool(row["has_unaddressed_request_changes"]) and not bool(row["is_claimed"]) ) if filter_name == "no_active_review_ci_passing": return ( row["ci_status"] == "passing" and row["approvals_count"] == 0 and not bool(row["has_active_request_changes"]) and not bool(row["is_claimed"]) ) if filter_name == "no_active_review_ci_failing": return ( row["ci_status"] == "failing" and row["approvals_count"] == 0 and not bool(row["has_active_request_changes"]) and not bool(row["is_claimed"]) ) if filter_name == "missing_ci_checks": return ( row["ci_status"] == "unknown" and row["approvals_count"] == 0 and not bool(row["has_unaddressed_request_changes"]) and not bool(row["is_claimed"]) ) # Unreachable — refresh_then_filter validates filter_name first. raise ValueError(f"unknown filter {filter_name!r}") def _project_pr( pr: dict[str, Any], cls: dict[str, Any], ) -> dict[str, Any]: """Trim the Forgejo PR object to the fields downstream callers actually use, plus the classification flags. Matches the shape the TS scripts emitted so the dispatcher cutover (Phase 2) is a drop-in.""" return { "number": pr.get("number"), "title": pr.get("title"), "head_sha": cls["head_sha"], "head_ref": (pr.get("head") or {}).get("ref"), "base_ref": (pr.get("base") or {}).get("ref"), "updated_at": pr.get("updated_at"), "labels": [(l.get("name") or "") for l in (pr.get("labels") or [])], "ci_status": cls["ci_status"], "approvals_count": cls["approvals_count"], "has_active_request_changes": bool(cls["has_active_request_changes"]), "has_unaddressed_request_changes": bool(cls["has_unaddressed_request_changes"]), "is_claimed": bool(cls["is_claimed"]), } __all__ = ( "FILTER_NAMES", "DEFAULT_TTL_SECONDS", "CLAIM_LABELS", "refresh_then_filter", )