Files
cleveragents-core/tools/_pr_comments_cache.py
T
drew 0bc734c020 style: ruff format the controller-state-machine branch (288 files)
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>
2026-05-20 00:09:17 -04:00

721 lines
28 KiB
Python

"""Persistent on-disk cache for PR/issue comments with delta fetch.
What this is
------------
Live test 2026-05-13 showed every implementer cycle on PR #30
re-fetched 1340+ accumulated comments via the
``/repos/{owner}/{repo}/issues/{N}/comments`` endpoint, hit the
20-page pagination cap, truncated the most recent comments off the
end, and burned ~30s on the fetch alone. Across 5 attempts in a
single PR cycle that's ~150s of identical work plus stale data.
This module caches the comment list to disk and supports a delta
fetch via Forgejo's ``?since=<ISO timestamp>`` query parameter. The
common case becomes: 5-10 NEW comments since the last fetch (or
zero) rather than the full 1340.
Cache layout
------------
``/tmp/cleveragents-comment-cache/pr-{N}.json`` — per-PR JSON file.
Schema::
{
"schema_version": 1,
"pr_number": 30,
"fetched_at": "2026-05-13T10:00:00+00:00",
"since_cursor": "2026-05-13T09:59:00+00:00",
"comments": [
{"id": 1, "created_at": "...", "body": "...", ...},
...
],
"any_partial_fetch": false
}
``fetched_at`` is the wall-clock time of the last write — it drives
the staleness check only. ``since_cursor`` is the ``created_at`` of
the newest cached comment; it is what we pass as ``?since=`` on the
next delta fetch, so the delta backfills forward from where the
cache actually ends. A page-capped seed holds only the OLDEST N
comments, so its newest cached comment is NOT the newest on the PR —
using a wall-clock ``?since=`` would skip the un-fetched middle
forever. The list is append-only by id. ``any_partial_fetch`` is
True when the cache was seeded from a page-capped fetch and has not
yet been backfilled to completeness by a later delta.
Invalidation
------------
Comments edited or deleted upstream are NOT detected in v1. Two
defenses:
- ``CACHE_MAX_STALENESS_S`` (default 24h) — when the cache is older
than this, we discard it and do a full refetch. Bounds the
staleness window.
- Force-refresh via ``invalidate(pr_number)`` for operator-side
manual interventions.
Edits and deletions are rare in this codebase's PR workflow; the
v1 trade-off (skip detection, do periodic refresh) is intentional.
Concurrency
-----------
The cache file is written atomically (write-to-tmp + rename). Two
dispatchers reading the SAME PR's comments concurrently each
compute the same delta and the last writer wins — idempotent. A
modest amount of duplicate-fetch work is the cost; the alternative
(file locking) would serialise readers, which is worse.
The cache is shared between the reviewer and implementer
dispatchers because both fetch the same endpoint with the same
auth and both benefit equally from the cache.
Errors
------
Every code path is best-effort. Cache read failures fall through
to a full fetch. Delta fetch failures fall through to a full
fetch. Full fetch failures bubble up to the caller as ``(items,
completed=False)`` — same contract as ``_review_fetch._api_get_paginated``.
"""
from __future__ import annotations
import datetime as _dt
import json
import logging
import os
import sys
from pathlib import Path
from typing import Any
_TOOLS_DIR = str(Path(__file__).resolve().parent)
if _TOOLS_DIR not in sys.path:
sys.path.insert(0, _TOOLS_DIR)
from _loader import ( # noqa: E402 type: ignore[import-not-found]
load_sibling as _load_sibling,
)
_review_fetch = _load_sibling("_review_fetch", "_review_fetch.py")
_backoff = _load_sibling("_backoff", "_backoff.py")
_attempt_history = _load_sibling("_attempt_history", "_attempt_history.py")
_bot_logins = _load_sibling("_bot_logins", "_bot_logins.py")
_logger = logging.getLogger("pr_comments_cache")
SCHEMA_VERSION = 1
# Default cache directory. Override via env var for tests / archive
# sharing between dispatcher hosts.
_DEFAULT_CACHE_DIR = Path("/tmp/cleveragents-comment-cache")
_CACHE_DIR_ENV = "IMPLEMENTER_DISPATCHER_COMMENT_CACHE_DIR"
# 24 hours — when the cache is older than this we discard it and
# do a full refetch. Bounds the edit/delete staleness window
# without paying the full-fetch cost every cycle.
_DEFAULT_MAX_STALENESS_S = 86400
_STALENESS_ENV = "IMPLEMENTER_DISPATCHER_COMMENT_CACHE_MAX_STALENESS_S"
# Kill switch for tests + operators who want to bypass the cache
# entirely (e.g. while diagnosing a divergence between cached and
# upstream state).
_DISABLE_ENV = "IMPLEMENTER_DISPATCHER_COMMENT_CACHE_DISABLE"
# Filter bot status / claim / release / sentinel comments BEFORE
# writing to cache. Bot ``**Implementation Attempt**`` comments are
# KEPT — the prompt's attempt-history digest reads them. Human and
# reviewer comments are KEPT. The dropped count + by-author breakdown
# is stamped on the cache row as ``bot_filtered`` so the prompt
# section can surface a "N bot comments filtered" one-liner.
#
# Without this, PR #30's cache balloons to ~19,000+ comments (mostly
# claim/release/sentinel noise the worker never reads) and the
# 50-page paginator cap still clips the middle. After filtering, the
# typical cache fits well within any reasonable page budget.
#
# Disable for rollback / debugging via
# ``IMPLEMENTER_DISPATCHER_COMMENT_CACHE_FILTER_BOTS=0``.
_FILTER_BOTS_ENV = "IMPLEMENTER_DISPATCHER_COMMENT_CACHE_FILTER_BOTS"
def _filter_bots_enabled() -> bool:
raw = os.environ.get(_FILTER_BOTS_ENV)
if raw is None:
return True
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _filter_for_cache(
comments: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""Drop bot status/claim/release/sentinel comments; keep human
comments and bot ``**Implementation Attempt**`` markers.
Returns ``(kept, summary)`` where:
- ``kept`` is the filtered comment list (caller stores this).
- ``summary`` is ``{"count": N, "by_author": {login: N, ...}}``
for the dropped comments (caller stores this for the prompt
section header). Empty dict when filtering is disabled or
no comments were dropped.
"""
if not _filter_bots_enabled() or not comments:
return comments, {"count": 0, "by_author": {}}
bots = _bot_logins.bot_logins()
kept: list[dict[str, Any]] = []
by_author: dict[str, int] = {}
for c in comments:
if not isinstance(c, dict):
continue
# Keep ALL non-bot comments + bot attempt-marker comments.
if _attempt_history.is_bot_authored(
c, bots
) and not _attempt_history.is_attempt_comment(c):
user = c.get("user")
login = user.get("login") if isinstance(user, dict) else None
# Default to ``"unknown"`` so by_author keys are always
# strings — JSON serialisation of the cache row would
# otherwise emit ``null`` as a key (Python's json.dumps
# coerces None → "null"), and downstream prompt rendering
# treats the literal string "null" as a login.
by_author[login or "unknown"] = by_author.get(login or "unknown", 0) + 1
continue
kept.append(c)
summary = {
"count": sum(by_author.values()),
"by_author": by_author,
}
return kept, summary
# Exponential backoff for repeated live-delta failures (R1 +
# 2026-05-16). Live test on PR #29 saw the dispatcher hit the
# pagination cap ``max_pages=20`` six times in a single hour because
# every cycle re-attempted the comment fetch against the same flaky
# endpoint, each retry burning ~30s of the worker turn AND polluting
# the prompt with a fresh ``data_complete=False`` signal. Counter
# resets on the first successful delta — a transient outage doesn't
# permanently throttle the PR. Shared shape with the other cache
# modules via :class:`_backoff.Backoff`.
_BACKOFF = _backoff.Backoff(
base_env="IMPLEMENTER_DISPATCHER_COMMENT_CACHE_BACKOFF_BASE_S",
max_env="IMPLEMENTER_DISPATCHER_COMMENT_CACHE_BACKOFF_MAX_S",
base_default=60,
max_default=1800,
)
def cache_dir() -> Path:
"""Resolve the on-disk cache directory."""
return Path(os.environ.get(_CACHE_DIR_ENV) or str(_DEFAULT_CACHE_DIR))
def cache_path(pr_number: int) -> Path:
"""Per-PR cache file path."""
return cache_dir() / f"pr-{int(pr_number)}.json"
def _max_staleness_s() -> int:
raw = os.environ.get(_STALENESS_ENV)
if raw:
try:
return max(0, int(raw))
except ValueError:
pass
return _DEFAULT_MAX_STALENESS_S
def is_disabled() -> bool:
raw = os.environ.get(_DISABLE_ENV, "").strip().lower()
return raw in {"1", "true", "yes", "on"}
def _now() -> str:
return _dt.datetime.now(_dt.timezone.utc).isoformat()
# Thin module-level wrappers preserved so the test suite + any direct
# imports continue to work. Both delegate to the shared
# :class:`_backoff.Backoff` instance above.
def _compute_next_attempt_after(
failures: int,
now_dt: _dt.datetime,
) -> str | None:
return _BACKOFF.next_attempt_after(failures, now_dt)
def _backoff_active(cached: dict[str, Any] | None, now_dt: _dt.datetime) -> bool:
return _BACKOFF.is_active(cached, now_dt)
def _read_cache(pr_number: int) -> dict[str, Any] | None:
"""Load the cache file for ``pr_number``. Returns ``None`` on any
error (missing, malformed, schema-mismatch, stale)."""
target = cache_path(pr_number)
if not target.exists():
return None
try:
text = target.read_text(encoding="utf-8")
payload = json.loads(text)
except (OSError, ValueError) as exc:
_logger.warning(
"comment cache read failed for PR #%s at %s: %s",
pr_number,
target,
exc,
)
return None
if not isinstance(payload, dict):
return None
if payload.get("schema_version") != SCHEMA_VERSION:
return None
# Staleness check
try:
fetched_at = _dt.datetime.fromisoformat(payload.get("fetched_at", ""))
age_s = (_dt.datetime.now(_dt.timezone.utc) - fetched_at).total_seconds()
if age_s > _max_staleness_s():
_logger.info(
"comment cache for PR #%s is %.0fs stale (limit %ds); "
"discarding for full refetch",
pr_number,
age_s,
_max_staleness_s(),
)
return None
except (ValueError, TypeError):
# Bad timestamp → treat as no-cache
return None
if not isinstance(payload.get("comments"), list):
return None
# Lazy migration: legacy cache rows (pre-2026-05-17) sometimes
# wrote ``null`` as the by_author key when the comment's user
# field was missing. Normalize on read so downstream consumers
# never see a non-string key. Cheap (rewrites in-memory only;
# next write persists the canonical shape).
bf = payload.get("bot_filtered")
if isinstance(bf, dict):
raw_by_author = bf.get("by_author")
if isinstance(raw_by_author, dict) and any(
not isinstance(k, str) for k in raw_by_author
):
normalized: dict[str, int] = {}
for k, v in raw_by_author.items():
key = k if isinstance(k, str) and k else "unknown"
try:
value = int(v or 0)
except (TypeError, ValueError):
value = 0
normalized[key] = normalized.get(key, 0) + value
bf["by_author"] = normalized
return payload
def _write_cache(pr_number: int, payload: dict[str, Any]) -> None:
"""Atomic write of the cache file."""
target = cache_path(pr_number)
tmp = target.with_suffix(target.suffix + ".tmp")
try:
target.parent.mkdir(parents=True, exist_ok=True)
tmp.write_text(
json.dumps(payload, indent=2, default=str),
encoding="utf-8",
)
tmp.replace(target)
except (OSError, TypeError, ValueError) as exc:
_logger.warning(
"comment cache write failed for PR #%s at %s: %s",
pr_number,
target,
exc,
)
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
def _merge_comments(
cached: list[dict[str, Any]],
delta: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Append ``delta`` to ``cached``, deduplicated by comment ``id``.
Forgejo's ``since=`` query is timestamp-based; if a comment was
created at the boundary timestamp, the server MAY return it in
both the cached snapshot and the delta. Dedup by id is the
simplest defence.
Preserves the order of ``cached`` (oldest first) and appends
delta items (also oldest-first within delta) at the tail.
"""
seen_ids: set[Any] = set()
out: list[dict[str, Any]] = []
for c in cached:
cid = c.get("id")
if cid is not None and cid in seen_ids:
continue
if cid is not None:
seen_ids.add(cid)
out.append(c)
for c in delta:
cid = c.get("id")
if cid is not None and cid in seen_ids:
continue
if cid is not None:
seen_ids.add(cid)
out.append(c)
return out
def _normalize_since_cursor(cursor: str) -> str:
"""Strip fractional seconds from an ISO-8601 timestamp so the
cursor matches the wire format Forgejo accepts in ``?since=``.
Why: Forgejo (live-observed 2026-05-17 on PRs #25 + #28) returns
HTTP 422 when the ``since=`` parameter carries microsecond
precision (e.g. ``2026-05-17T04:48:23.067808+00:00``). The legacy
cache code stamped ``fetched_at`` with
``datetime.now(UTC).isoformat()`` — which includes microseconds —
and used that as the cursor for the next delta, so any cache row
written before the cursor switched to ``created_at`` (whole-second)
is poisoned for the lifetime of the cache. This helper sanitizes
on the way OUT so even legacy rows recover on the next cycle.
Accepts: ``Z`` / lowercase ``z`` (RFC-3339 allows both); ``+HH:MM``
/ ``-HH:MM`` offsets including non-zero (``+05:30``); naive
timestamps. Preserves the original timezone marker character so a
lowercase-``z`` input doesn't silently lose its UTC designator.
Conservative: input that doesn't look like an ISO timestamp
(no ``T`` separator) is returned untouched — no exception, no
truncation that could change semantics."""
if not cursor:
return cursor
t_pos = cursor.find("T")
if t_pos < 0:
# Not an ISO-8601 timestamp — leave alone, do NOT strip on
# the bare ``.`` (would mangle filenames, URLs, etc.).
return cursor
# ``+HH:MM`` / ``-HH:MM`` offset form: split at the tz sep, trim
# fraction in the date-time half, rejoin verbatim.
for tz_sep in ("+", "-"):
tz_pos = cursor.find(tz_sep, t_pos)
if tz_pos > t_pos:
dt_part = cursor[:tz_pos]
tz_part = cursor[tz_pos:]
dot = dt_part.find(".")
if dot >= 0:
dt_part = dt_part[:dot]
return dt_part + tz_part
# ``Z`` / ``z`` form: preserve the original case of the marker so
# legal RFC-3339 lowercase ``z`` inputs don't lose their tz suffix
# by falling through to the naive-stripper.
if cursor and cursor[-1] in ("Z", "z"):
marker = cursor[-1]
dt_part = cursor[:-1]
dot = dt_part.find(".")
if dot >= 0:
dt_part = dt_part[:dot]
return dt_part + marker
# Naive ISO form (no tz suffix, but has T): trim the fraction in
# the time component only. Bounded by t_pos so a bare "." in the
# date portion (impossible in valid ISO but defensive) is left
# alone.
dot = cursor.find(".", t_pos)
if dot >= 0:
return cursor[:dot]
return cursor
def _newest_cursor(comments: list[dict[str, Any]]) -> str:
"""Return the ``created_at`` of the newest (last) comment, for use
as the ``?since=`` delta cursor on the next fetch.
The comment list is oldest-first, so the last entry is the newest.
Returns "" when the list is empty or the newest entry has no
``created_at`` — the caller falls back to ``fetched_at``.
Why this and not ``fetched_at`` (wall-clock): a page-capped seed
holds only the OLDEST N comments, so the newest *cached* comment
is not the newest on the PR. ``?since=<newest cached created_at>``
backfills forward from where the cache actually ends;
``?since=<wall-clock>`` would skip the un-fetched middle forever.
"""
# Walk from the tail back, skipping non-dict / cursor-less entries.
# Why not just trust comments[-1]: ``_filter_for_cache`` skips
# non-dict entries silently, so the cached list and the raw
# response list can disagree on what "last" means. If Forgejo
# returns a malformed entry at the tail of a page (rare but
# observed in past Forgejo bugs), trusting the last element
# blindly would regress the cursor to "" and the next delta
# would re-paginate from page 1. The walk costs O(k) where k is
# the count of trailing malformed entries — typically 0 or 1.
for entry in reversed(comments):
if isinstance(entry, dict):
created_at = entry.get("created_at")
if created_at:
return str(created_at)
return ""
def get_pr_comments(
cfg: Any,
pr_number: int,
*,
owner: str | None = None,
repo: str | None = None,
) -> tuple[list[dict[str, Any]], bool]:
"""Get all comments for the PR, using the on-disk cache for the
bulk and fetching only the delta.
Returns ``(comments, completed)`` — same contract as
:func:`_review_fetch.fetch_pr_comments`. ``completed`` is True
when both the cache load AND the delta fetch succeeded
cleanly; False if either was partial.
``owner`` / ``repo`` override the values normally read off ``cfg``.
The shared comment fetch stack only uses ``cfg.owner`` / ``cfg.repo``
to build the request path; the HTTP transport layer
(``_claim_runtime.get``) needs just ``token`` / ``request_timeout_s``
/ ``api_retries``. Callers whose ``cfg`` is the narrower
``RuntimeContext`` (e.g. ``_claim_runtime``'s sweep, which uses
module-level ``REPO_OWNER`` / ``REPO_NAME`` constants) pass owner /
repo explicitly so they can share the cache without growing their
cfg shape (spec #6).
When the cache is disabled, missing, or stale, falls back to a
full fetch (same behaviour as the legacy code path) and seeds
the cache for next time.
Seed-on-truncation (R8-5): a full fetch that hits the pagination
ceiling (``truncated=True``) still seeds the cache — the clipped
data is valid, just incomplete — marked ``any_partial_fetch=True``.
The next run's delta fetch (``?since=<since_cursor>``) backfills
forward from the newest cached comment. Before this fix, a heavy
PR whose full fetch always truncated NEVER seeded the cache and
re-paginated from page 1 every single cycle. A genuinely-failed
fetch (network / 5xx / malformed, ``truncated=False``) still does
NOT seed — its data is unreliable.
"""
use_owner = owner or getattr(cfg, "owner", None)
use_repo = repo or getattr(cfg, "repo", None)
base_path = f"/repos/{use_owner}/{use_repo}/issues/{int(pr_number)}/comments"
if is_disabled():
# Path-based fetch (no cache layer). Uses the same paginator the
# cache layer uses, so cfg with only RuntimeContext attrs works.
items, ok = _review_fetch._api_get_paginated(cfg, base_path)
return items, ok
cached = _read_cache(pr_number)
now_dt = _dt.datetime.now(_dt.timezone.utc)
# Backoff short-circuit. If a previous cycle's live-delta failed
# and we're inside the deferred window, skip the live attempt
# entirely and serve whatever cached bulk we have. The caller
# sees ``completed=False`` so the dispatcher's prompt-time
# ``data_complete`` aggregate still flips, but we have NOT
# burned another ~30s on a guaranteed-to-fail pagination walk.
if cached is not None and _backoff_active(cached, now_dt):
_logger.info(
"comment cache for PR #%s in backoff (failures=%s, "
"next_attempt_after=%s); serving stale cache, skipping live "
"delta",
pr_number,
cached.get("consecutive_failures", 0),
cached.get("next_attempt_after"),
)
return list(cached.get("comments") or []), False
if cached is None:
# Cache miss → full fetch. Seed on a clean walk OR a page-cap
# truncation; skip seeding only on a genuine transient failure
# (its data is unreliable). ``since_cursor`` is the newest
# comment we actually have, so a truncated seed's next delta
# backfills forward instead of from a wall-clock ``?since=``.
raw_items, completed, truncated = _review_fetch._api_get_paginated(
cfg,
base_path,
return_truncation=True,
)
# Drop bot status/claim/release/sentinel noise before caching.
# ``since_cursor`` uses the RAW newest comment (including
# filtered ones) so the next delta's ``?since=`` resumes from
# the true tip, not from the most-recent kept entry.
items, filter_summary = _filter_for_cache(raw_items)
if completed or truncated:
failures = 0 if completed else 1
_write_cache(
pr_number,
{
"schema_version": SCHEMA_VERSION,
"pr_number": int(pr_number),
"fetched_at": _now(),
"since_cursor": _newest_cursor(raw_items),
"comments": items,
"any_partial_fetch": not completed,
"consecutive_failures": failures,
"next_attempt_after": _compute_next_attempt_after(
failures,
now_dt,
),
"bot_filtered": filter_summary,
},
)
return items, completed
# Cache hit → delta fetch forward from the newest cached comment.
# Fall back to ``fetched_at`` for caches written before the
# ``since_cursor`` field existed. Both candidates are normalized
# to drop microsecond precision because Forgejo returns HTTP 422
# on sub-second ``since=`` values (live-observed on PRs #25 + #28
# 2026-05-17). Legacy cache rows that still carry a microsecond
# ``fetched_at`` recover automatically on the next cycle.
#
# Cursor is URL-encoded via ``urllib.parse.quote`` before
# interpolation: the ``+`` in ``+00:00`` decodes to a space on
# Forgejo's query-string parser and causes a second 422 class
# (live-observed on PR #35 run-1 2026-05-17 — 6 consecutive 422s
# on the same clean ``+00:00`` cursor until the cache backed off
# for 30 min). ``safe=''`` quotes every non-alphanumeric char so
# ``+`` → ``%2B``, ``:`` → ``%3A``, etc.
from urllib.parse import quote as _url_quote
since = _normalize_since_cursor(
cached.get("since_cursor") or cached.get("fetched_at", "")
)
path = f"{base_path}?since={_url_quote(since, safe='')}" if since else base_path
raw_delta_items, delta_ok = _review_fetch._api_get_paginated(cfg, path)
# Filter the delta before merging. Accumulate the filtered count
# into the cache's running total so the prompt header reflects
# everything skipped across all cycles.
delta_items, delta_filter_summary = _filter_for_cache(raw_delta_items)
prior_bot_filtered = cached.get("bot_filtered") or {
"count": 0,
"by_author": {},
}
merged_by_author: dict[str, int] = dict(prior_bot_filtered.get("by_author") or {})
for login, n in (delta_filter_summary.get("by_author") or {}).items():
merged_by_author[login] = merged_by_author.get(login, 0) + n
merged_bot_filtered = {
"count": sum(merged_by_author.values()),
"by_author": merged_by_author,
}
if not delta_ok:
# Delta fetch was partial. Bump the failure counter and stamp
# the deferred-retry deadline so the NEXT cycle short-circuits
# to the stale-serve path above. Persist the merged set so the
# short-circuit serves the SAME view this branch returned to
# the caller, not the older subset — ``_merge_comments`` dedups
# by id, so the merged write is strictly additive (no risk of
# discarding cached entries). ``any_partial_fetch`` stays True
# because the walk did not reach the PR's end.
prior_failures = int(cached.get("consecutive_failures") or 0)
next_failures = prior_failures + 1
merged = _merge_comments(cached["comments"], delta_items)
_write_cache(
pr_number,
{
**cached,
"comments": merged,
# since_cursor uses RAW delta tail (not filtered) so the
# next ``?since=`` resumes from the true newest comment,
# not the most-recent kept entry.
"since_cursor": (
_newest_cursor(raw_delta_items)
if raw_delta_items
else cached.get("since_cursor", "")
),
"any_partial_fetch": True,
"consecutive_failures": next_failures,
"next_attempt_after": _compute_next_attempt_after(
next_failures,
now_dt,
),
"bot_filtered": merged_bot_filtered,
},
)
_logger.warning(
"comment cache live-delta failed for PR #%s "
"(consecutive_failures=%s); next attempt deferred until %s; "
"serving %s cached comments to caller",
pr_number,
next_failures,
_compute_next_attempt_after(next_failures, now_dt),
len(merged),
)
return merged, False
# Delta succeeded — merge, persist, return. A clean delta walks to
# the PR's end, so even a previously-truncated seed is now whole:
# ``any_partial_fetch`` clears AND failure-tracking resets.
merged = _merge_comments(cached["comments"], delta_items)
cached_payload = {
"schema_version": SCHEMA_VERSION,
"pr_number": int(pr_number),
"fetched_at": _now(),
"since_cursor": (
_newest_cursor(raw_delta_items)
if raw_delta_items
else cached.get("since_cursor", "")
),
"comments": merged,
"any_partial_fetch": False,
"consecutive_failures": 0,
"next_attempt_after": None,
"bot_filtered": merged_bot_filtered,
}
_write_cache(pr_number, cached_payload)
return merged, True
def get_filter_summary(pr_number: int) -> dict[str, Any] | None:
"""Read the ``bot_filtered`` summary for ``pr_number``'s cache
row. Returns ``None`` if the cache is missing, stale, or has no
filter summary (older cache file pre-dating the filter feature).
Shape: ``{"count": N, "by_author": {login: N, ...}}``.
Consumers (prompt builders) call this to surface a one-line
"N bot comments filtered" header. The summary is informational
only — the actual filtered comments are NOT in the cache, by
design (see ``_filter_for_cache`` rationale)."""
cached = _read_cache(pr_number)
if cached is None:
return None
summary = cached.get("bot_filtered")
if not isinstance(summary, dict):
return None
return summary
def invalidate(pr_number: int) -> None:
"""Force-remove the cache entry for a PR.
Used by:
- The dispatcher's post-session cleanup, optionally on hook
failures or other "this PR's state moved underneath us" cases.
- Operator manual interventions via a future
``tools/clear_comment_cache.py`` script.
Idempotent — missing entry is a no-op.
"""
target = cache_path(pr_number)
try:
target.unlink(missing_ok=True)
except OSError as exc:
_logger.warning(
"comment cache invalidate failed for PR #%s at %s: %s",
pr_number,
target,
exc,
)
__all__ = (
"SCHEMA_VERSION",
"cache_dir",
"cache_path",
"get_filter_summary",
"get_pr_comments",
"invalidate",
"is_disabled",
)