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>
935 lines
36 KiB
Python
935 lines
36 KiB
Python
"""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",
|
||
)
|