3f12e4140c
Two related fixes surfaced by the 2026-05-17 run-5 live observation: 1. **Cycle-cap signature now includes total_reviews count** so the reviewer's COMMENT-only path actually moves the signature. Before: the cap signature was ``sha + (approvals + has_active_RC + has_unaddressed_RC)`` — all three boolean axes ignore COMMENT reviews entirely. After: ``+ total_reviews`` term bumps on EVERY review submission. The ``data_complete=False → COMMENT downgrade`` path the reviewer takes in low-context cycles now reflects in the signature, so the cap stops firing falsely. Without this, run-5 observed 4 fresh PRs hitting count=5 in ~10 minutes — the reviewer was successfully posting reviews every cycle but the cap saw "no change" because COMMENT-only reviews don't bump approvals_count, has_active_RC, or has_unaddressed_RC. 2. **Implementer now sees ALL reviewer feedback**, not just active REQUEST_CHANGES. Before: ``fetch_pr_fix_context`` passed ``include_active_reviews=False`` so failing-CI PRs reached the implementer with zero reviewer data. ``fetch_request_changes_pr_context`` only included active RC reviews — COMMENT-only feedback was invisible in both code paths. After: ``fetch_pr_fix_context`` also includes reviews, AND the fetcher now partitions reviews into TWO buckets — the existing ``request_changes_reviews`` (active blocking RC, unchanged semantic) and a new ``comment_reviews`` field carrying every non-dismissed non-RC review (COMMENT / APPROVE). Both are persisted in the PR-context sentinel and exposed via ``implementer_pr_context.py``'s ``comment_reviews`` field. New prompt section ``## Pre-fetched reviewer comments and approvals`` renders the comment_reviews bucket with author / event / commit / body / inline comments + a postscript marking them as ADVISORY (not blocking, unlike the existing RC section). This closes the architectural gap where the reviewer and implementer pools could work in silos on the same PR — the reviewer's substantive prose feedback now reaches the implementer regardless of which work-group routed it. Files touched: - tools/_pr_classification_cache.py — total_reviews in classify_pr + reactivity composite; docstring updated. - tools/_implementer_prefetch.py — new comment_reviews + comment_reviews_completed fields; fetcher partitions reviews once; fetch_pr_fix_context now includes reviews. - tools/_implementer_prompt.py — _build_comment_reviews_section; wired into prompt assembly between RC and PR-comments sections. - tools/_pr_context_sentinel.py — comment_reviews in _to_dict. - tools/implementer_pr_context.py — comment_reviews accessor for the worker's handoff read path. - tests/auto_agents/test_pr_context_sentinel.py — expected_value_keys + fixture + round-trip test updated. - .opencode/agents/estimator-implementation.md — restored canonical section header levels (####) for downstream test compatibility after R3.1 rewrite. Full auto_agents suite: 2303 passing (+12 from this and adjacent work, none broken). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
916 lines
36 KiB
Python
916 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}; "
|
||
f"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"
|
||
f"?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",
|
||
)
|