diff --git a/iris/cli.py b/iris/cli.py index df7dded..9416140 100644 --- a/iris/cli.py +++ b/iris/cli.py @@ -13,7 +13,7 @@ from iris.platform.telemetry import span, record_metric, record_counter, record_duration, flush from iris.ingestion import window_cache from iris.ingestion.git_reader import read_commits -from iris.ingestion.github_reader import read_pull_requests +from iris.ingestion.github_reader import read_pull_requests_with_fallback from iris.metrics.aggregator import aggregate from iris.models.context import AnalysisContext from iris.reports.narrative import generate_narrative @@ -496,10 +496,8 @@ def _tick(label: str) -> None: # Step 2: Fetch pull requests (optional, graceful fallback) print(s["cli_reading_prs"], end=" ", flush=True) with span("ingestion.pull_requests", {"repo": repo_name}): - try: - prs = read_pull_requests(repo, days=args.days) - except Exception: - prs = [] + pr_fetch = read_pull_requests_with_fallback(repo, days=args.days) + prs = pr_fetch.prs if prs: print(s["cli_prs_found"].format(count=len(prs))) else: diff --git a/iris/ingestion/github_reader.py b/iris/ingestion/github_reader.py index 0432caa..e3d6df6 100644 --- a/iris/ingestion/github_reader.py +++ b/iris/ingestion/github_reader.py @@ -18,12 +18,23 @@ import re import shutil import subprocess +import time from iris.shell import git_env from datetime import datetime, timedelta, timezone from iris.ingestion import window_cache -from iris.models.pull_request import CommitRef, PRReview, PRState, PullRequest +from iris.models.pull_request import ( + DEGRADED_BASIC, + DEGRADED_ENRICHMENT, + DEGRADED_FETCH, + DEGRADED_REVIEWS, + CommitRef, + PRReview, + PRState, + PullRequest, + PullRequestFetch, +) def detect_github_remote(repo_path: str) -> str | None: @@ -95,7 +106,7 @@ def is_gh_available() -> bool: _BATCH_SIZE = 500 -def _fetch_prs(nwo: str, limit: int, gh_state: str) -> list[dict]: +def _fetch_prs(nwo: str, limit: int, gh_state: str) -> tuple[list[dict], set[str]]: """Fetch PRs in a given gh state. For limits at or below `_BATCH_SIZE`, attempts a one-shot fetch with @@ -116,19 +127,26 @@ def _fetch_prs(nwo: str, limit: int, gh_state: str) -> list[dict]: Both secondary passes are best-effort — if either fails the PRs come back with the respective field empty, but the rest of the metadata is still usable. + + Returns ``(prs, degraded)``. ``degraded`` names the passes that failed — + `DEGRADED_BASIC`, `DEGRADED_ENRICHMENT`, `DEGRADED_REVIEWS` — and is empty + when the one-shot fetch or all three passes succeeded. """ if limit <= _BATCH_SIZE: result = _gh_pr_list(nwo, _PR_FIELDS_FULL, limit, gh_state) if result is not None: - return result + return result, set() prs = _gh_pr_list(nwo, _PR_FIELDS_BASIC, limit, gh_state) if prs is None: - return [] + return [], {DEGRADED_BASIC} - enrichment_by_pr = _fetch_pr_enrichment_graphql( + degraded: set[str] = set() + enrichment_by_pr, enrichment_complete = _fetch_pr_enrichment_graphql( nwo, gh_state, min(limit, _BATCH_SIZE), ) + if not enrichment_complete: + degraded.add(DEGRADED_ENRICHMENT) for pr in prs: enrich = enrichment_by_pr.get(pr["number"], {}) pr["commits"] = enrich.get("commits", []) @@ -141,12 +159,14 @@ def _fetch_prs(nwo: str, limit: int, gh_state: str) -> list[dict]: pr["mergeCommit"] = merge_commit reviews_prs = _gh_pr_list(nwo, "number,reviews", min(limit, _BATCH_SIZE), gh_state) - if reviews_prs: + if reviews_prs is None: + degraded.add(DEGRADED_REVIEWS) + elif reviews_prs: reviews_by_number = {pr["number"]: pr.get("reviews", []) for pr in reviews_prs} for pr in prs: pr["reviews"] = reviews_by_number.get(pr["number"], []) - return prs + return prs, degraded # Max PRs per GraphQL page. GitHub's REST/GraphQL API rejects `first:` @@ -184,11 +204,33 @@ def _fetch_prs(nwo: str, limit: int, gh_state: str) -> list[dict]: } +# One retry for the enrichment query, after this pause. GitHub answers that +# query with intermittent 504s (see `_BATCH_SIZE`); a single retry makes a +# degraded run rarer without hiding the ones that still fail. +_ENRICHMENT_RETRY_DELAY_S = 2.0 + + +def _run_gh_with_one_retry(args: list[str]) -> subprocess.CompletedProcess | None: + """Run a gh command; on failure wait once and retry. None if both fail.""" + for attempt in (1, 2): + try: + return subprocess.run( + args, capture_output=True, text=True, check=True, + env=git_env(), + ) + except FileNotFoundError: + return None + except subprocess.CalledProcessError: + if attempt == 1: + time.sleep(_ENRICHMENT_RETRY_DELAY_S) + return None + + def _fetch_pr_enrichment_graphql( nwo: str, gh_state: str, max_prs: int, -) -> dict[int, dict]: +) -> tuple[dict[int, dict], bool]: """Map PR number → enrichment dict via paginated GraphQL. Why this exists: `gh pr list --json commits` is unusable on busy @@ -206,16 +248,19 @@ def _fetch_pr_enrichment_graphql( older entries — documented limitation that the caller should be aware of. - Best-effort: returns whatever it has collected so far on any error. + Returns ``(by_pr, complete)``. ``complete`` is False when the pass stopped + on an error — a page that failed even after one retry, unparseable + output, a response without data — so ``by_pr`` holds only what earlier + pages collected. Reaching ``max_prs`` or the last page is a normal end. """ try: owner, name = nwo.split("/", 1) except ValueError: - return {} + return {}, False graphql_state = _GH_STATE_TO_GRAPHQL.get(gh_state) if graphql_state is None: - return {} + return {}, False by_pr: dict[int, dict] = {} end_cursor: str | None = None @@ -231,18 +276,14 @@ def _fetch_pr_enrichment_graphql( if end_cursor: args.extend(["-F", f"cursor={end_cursor}"]) - try: - result = subprocess.run( - args, capture_output=True, text=True, check=True, - env=git_env(), - ) - except (subprocess.CalledProcessError, FileNotFoundError): - return by_pr + result = _run_gh_with_one_retry(args) + if result is None: + return by_pr, False try: data = json.loads(result.stdout) except json.JSONDecodeError: - return by_pr + return by_pr, False page = ( data.get("data", {}) @@ -251,7 +292,7 @@ def _fetch_pr_enrichment_graphql( if data.get("data") else None ) if not page: - return by_pr + return by_pr, False for node in page.get("nodes", []): number = node.get("number") @@ -274,16 +315,17 @@ def _fetch_pr_enrichment_graphql( "mergeCommit": node.get("mergeCommit"), } if len(by_pr) >= max_prs: - return by_pr + return by_pr, True info = page.get("pageInfo", {}) or {} if not info.get("hasNextPage"): - return by_pr + return by_pr, True end_cursor = info.get("endCursor") if not end_cursor: - return by_pr + # More pages exist but there is no cursor to reach them. + return by_pr, False - return by_pr + return by_pr, True def _gh_pr_list(nwo: str, fields: str, limit: int, gh_state: str) -> list[dict] | None: @@ -314,7 +356,7 @@ def _gh_pr_list(nwo: str, fields: str, limit: int, gh_state: str) -> list[dict] return None -def read_pull_requests(repo_path: str, days: int) -> list[PullRequest]: +def read_pull_requests(repo_path: str, days: int) -> PullRequestFetch: """Read pull requests from GitHub via gh CLI. Fetches PRs in all three lifecycle states (merged, closed-without-merge, @@ -332,8 +374,9 @@ def read_pull_requests(repo_path: str, days: int) -> list[PullRequest]: days: Number of days to look back from now. Returns: - List of PullRequest objects with state populated. Returns an empty - list if gh is unavailable or the repo has no GitHub remote. + A `PullRequestFetch`: the PRs, with state populated, and the read + steps that degraded on the way. The PR list is empty and nothing is + degraded when gh is unavailable or the repo has no GitHub remote. """ return window_cache.pull_requests( repo_path, @@ -345,13 +388,26 @@ def read_pull_requests(repo_path: str, days: int) -> list[PullRequest]: ) -def _read_pull_requests_uncached(repo_path: str, days: int) -> list[PullRequest]: +def read_pull_requests_with_fallback(repo_path: str, days: int) -> PullRequestFetch: + """Read PRs, turning any unexpected error into an empty, flagged fetch. + + This is the graceful fallback both analysis entry points used to inline as + ``except Exception: prs = []``. The run still goes on without PRs, but the + fetch now comes back marked `DEGRADED_FETCH`, so the report says so. + """ + try: + return read_pull_requests(repo_path, days) + except Exception: + return PullRequestFetch(prs=[], degraded=(DEGRADED_FETCH,)) + + +def _read_pull_requests_uncached(repo_path: str, days: int) -> PullRequestFetch: if not is_gh_available(): - return [] + return PullRequestFetch(prs=[]) nwo = detect_github_remote(repo_path) if not nwo: - return [] + return PullRequestFetch(prs=[]) since = datetime.now(timezone.utc) - timedelta(days=days) @@ -364,11 +420,14 @@ def _read_pull_requests_uncached(repo_path: str, days: int) -> list[PullRequest] # open — still open # closed — closed without merging (NOT including merged) # merged — merged - merged_raw = _fetch_prs(nwo, fetch_limit, "merged") - closed_raw = _fetch_prs(nwo, fetch_limit, "closed") - open_raw = _fetch_prs(nwo, fetch_limit, "open") + merged_raw, merged_degraded = _fetch_prs(nwo, fetch_limit, "merged") + closed_raw, closed_degraded = _fetch_prs(nwo, fetch_limit, "closed") + open_raw, open_degraded = _fetch_prs(nwo, fetch_limit, "open") - return _parse_pull_requests(merged_raw + closed_raw + open_raw, since) + return PullRequestFetch( + prs=_parse_pull_requests(merged_raw + closed_raw + open_raw, since), + degraded=tuple(sorted(merged_degraded | closed_degraded | open_degraded)), + ) def read_single_pr(repo_path: str, pr_number: int) -> PullRequest | None: diff --git a/iris/ingestion/window_cache.py b/iris/ingestion/window_cache.py index 7fa30e5..57617dc 100644 --- a/iris/ingestion/window_cache.py +++ b/iris/ingestion/window_cache.py @@ -25,9 +25,11 @@ from datetime import datetime, timedelta, timezone from typing import Callable +from iris.models.pull_request import PullRequestFetch + _enabled = False # repo_path -> (width_days, prs) — `prs` were fetched for a `width_days` window. -_prs: dict[str, tuple[int, list]] = {} +_prs: dict[str, tuple[int, PullRequestFetch]] = {} def enable() -> None: @@ -46,9 +48,9 @@ def reset() -> None: def pull_requests( repo_path: str, days: int, - load: Callable[[], list], + load: Callable[[], PullRequestFetch], keep: Callable[[object, datetime], bool], -) -> list: +) -> PullRequestFetch: """Return PRs for `days`, slicing a cached wider fetch when possible. Args: @@ -64,7 +66,13 @@ def pull_requests( cached = _prs.get(repo_path) if cached is not None and days <= cached[0]: since = datetime.now(timezone.utc) - timedelta(days=days) - return [pr for pr in cached[1] if keep(pr, since)] + fetch = cached[1] + # A narrower window inherits the wide fetch's degradation: the PRs it is + # sliced from are the ones that came back incomplete. + return PullRequestFetch( + prs=[pr for pr in fetch.prs if keep(pr, since)], + degraded=fetch.degraded, + ) fresh = load() _prs[repo_path] = (days, fresh) diff --git a/iris/models/pull_request.py b/iris/models/pull_request.py index e9003d3..98b7a95 100644 --- a/iris/models/pull_request.py +++ b/iris/models/pull_request.py @@ -75,3 +75,25 @@ class PullRequest: # Consumed by Merge Strategy detection (see merge_strategy_detector.py). merge_commit_sha: str | None = None merge_commit_parent_count: int | None = None + + +# Steps of the PR read that can fail and fall back to partial data. These are +# the names `PullRequestFetch.degraded` reports, in the JSON and in the report. +DEGRADED_BASIC = "basic" # `gh pr list` for a state failed: its PRs are missing +DEGRADED_ENRICHMENT = "enrichment" # GraphQL pass failed: commit refs and merge parents missing +DEGRADED_REVIEWS = "reviews" # reviews pass failed: PRs carry no reviews +DEGRADED_FETCH = "fetch" # the whole read raised: no PRs at all + + +@dataclass(frozen=True) +class PullRequestFetch: + """Pull requests read from GitHub, and which read steps degraded on the way. + + ``degraded`` lists the steps that failed and fell back to partial data, + sorted and without repeats. It is empty when every step succeeded, and + also when PRs are absent *by design* (no ``gh`` on the machine, no GitHub + remote): Iris works without PRs, and that is not a degradation. + """ + + prs: list[PullRequest] + degraded: tuple[str, ...] = () diff --git a/iris/org_runner.py b/iris/org_runner.py index b1265f7..96ebcd1 100644 --- a/iris/org_runner.py +++ b/iris/org_runner.py @@ -11,7 +11,7 @@ ) from iris.i18n import get_strings from iris.ingestion.git_reader import read_commits -from iris.ingestion.github_reader import read_pull_requests +from iris.ingestion.github_reader import read_pull_requests_with_fallback from iris.metrics.aggregator import aggregate from iris.models.context import AnalysisContext from iris.models.org import OrgResult, RepoResult @@ -80,11 +80,9 @@ def analyze_single_repo( if not commits: return None, "", "" - # Step 2: Fetch PRs (graceful fallback) - try: - prs = read_pull_requests(repo_path, days=days) - except Exception: - prs = [] + # Step 2: Fetch PRs (graceful fallback, flagged when it falls back) + pr_fetch = read_pull_requests_with_fallback(repo_path, days=days) + prs = pr_fetch.prs # Step 3+4: Aggregate metrics metrics = aggregate(commits, churn_days=churn_days, prs=prs or None) diff --git a/tests/test_pr_fetch_degradation.py b/tests/test_pr_fetch_degradation.py new file mode 100644 index 0000000..bcc49a9 --- /dev/null +++ b/tests/test_pr_fetch_degradation.py @@ -0,0 +1,259 @@ +"""Tests for the PR-read degradation signal (issue #236). + +When a step of the GitHub PR read fails, the engine used to fall back to +partial data without saying so: two runs on the same commit could disagree on +merge strategy and flow metrics with nothing in the output explaining why. +These tests pin the signal from where it starts to where it is reported. + +Runnable as: `python -m pytest tests/test_pr_fetch_degradation.py -v` +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from iris.ingestion import github_reader, window_cache +from iris.models.pull_request import DEGRADED_FETCH, PullRequestFetch + + +def setup_function(): + window_cache.reset() + + +def teardown_function(): + window_cache.reset() + + +# --- absence by design is not degradation ---------------------------------- + + +def test_no_gh_is_absence_not_degradation(monkeypatch): + monkeypatch.setattr(github_reader, "is_gh_available", lambda: False) + + fetch = github_reader.read_pull_requests("/any/repo", days=30) + + assert fetch == PullRequestFetch(prs=[], degraded=()) + + +def test_no_github_remote_is_absence_not_degradation(monkeypatch): + monkeypatch.setattr(github_reader, "is_gh_available", lambda: True) + monkeypatch.setattr(github_reader, "detect_github_remote", lambda _path: None) + + fetch = github_reader.read_pull_requests("/any/repo", days=30) + + assert fetch == PullRequestFetch(prs=[], degraded=()) + + +# --- the whole read failing ------------------------------------------------ + + +def test_fallback_marks_the_fetch_step_when_the_read_raises(monkeypatch): + def broken_read(_repo_path, _days): + raise RuntimeError("unexpected gh output") + + monkeypatch.setattr(github_reader, "_read_pull_requests_uncached", broken_read) + + fetch = github_reader.read_pull_requests_with_fallback("/any/repo", days=30) + + assert fetch == PullRequestFetch(prs=[], degraded=(DEGRADED_FETCH,)) + + +# --- the read steps, with a fake gh ---------------------------------------- +# +# The reader calls `subprocess.run(..., check=True)` and relies on the +# `CalledProcessError` a failing gh raises, so the fake raises it too — +# returning a non-zero code would never reach the failure path. + +import json +import subprocess +from types import SimpleNamespace + + +def _ok(payload) -> SimpleNamespace: + return SimpleNamespace(returncode=0, stdout=json.dumps(payload), stderr="") + + +def _graphql_page(numbers, *, has_next=False, cursor=None) -> dict: + return {"data": {"repository": {"pullRequests": { + "pageInfo": {"endCursor": cursor, "hasNextPage": has_next}, + "nodes": [ + { + "number": n, + "mergeCommit": {"oid": f"merge{n}", "parents": {"totalCount": 2}}, + "commits": {"nodes": [{"commit": { + "oid": f"commit{n}", + "messageHeadline": f"change {n}", + "committedDate": "2026-09-01T00:00:00Z", + "authoredDate": "2026-09-01T00:00:00Z", + }}]}, + } + for n in numbers + ], + }}}} + + +def _fake_gh(monkeypatch, *, full=None, basic=None, graphql=(), reviews=None): + """Route each gh call to a scripted answer. + + `full`, `basic` and `reviews` are a payload, or an exception to raise. + `graphql` is the sequence of answers for successive GraphQL calls. Returns + the list of pauses the retry slept, so a test can assert on them. + """ + graphql_answers = list(graphql) + sleeps: list[float] = [] + + def answer(spec, cmd): + if isinstance(spec, BaseException): + raise spec + if spec is None: + raise subprocess.CalledProcessError(1, cmd) + return _ok(spec) + + def fake_run(cmd, **kwargs): + if cmd[:3] == ["gh", "api", "graphql"]: + return answer(graphql_answers.pop(0), cmd) + fields = cmd[cmd.index("--json") + 1] + if fields == "number,reviews": + return answer(reviews, cmd) + if "commits" in fields: + return answer(full, cmd) + return answer(basic, cmd) + + monkeypatch.setattr(github_reader.subprocess, "run", fake_run) + monkeypatch.setattr(github_reader.time, "sleep", sleeps.append) + return sleeps + + +def _failure() -> subprocess.CalledProcessError: + return subprocess.CalledProcessError(1, ["gh"], stderr="HTTP 504") + + +def test_enrichment_completes_on_a_single_page(monkeypatch): + sleeps = _fake_gh(monkeypatch, graphql=[_graphql_page([1, 2])]) + + by_pr, complete = github_reader._fetch_pr_enrichment_graphql("acme/widgets", "merged", 500) + + assert sorted(by_pr) == [1, 2] + assert complete is True + assert sleeps == [] + + +def test_enrichment_retries_once_and_recovers(monkeypatch): + sleeps = _fake_gh(monkeypatch, graphql=[_failure(), _graphql_page([1])]) + + by_pr, complete = github_reader._fetch_pr_enrichment_graphql("acme/widgets", "merged", 500) + + assert sorted(by_pr) == [1] + assert complete is True + assert sleeps == [github_reader._ENRICHMENT_RETRY_DELAY_S] + + +def test_enrichment_that_fails_twice_is_incomplete(monkeypatch): + sleeps = _fake_gh(monkeypatch, graphql=[_failure(), _failure()]) + + by_pr, complete = github_reader._fetch_pr_enrichment_graphql("acme/widgets", "merged", 500) + + assert by_pr == {} + assert complete is False + assert sleeps == [github_reader._ENRICHMENT_RETRY_DELAY_S] + + +def test_enrichment_failing_on_a_later_page_keeps_what_it_has_but_is_incomplete(monkeypatch): + _fake_gh(monkeypatch, graphql=[ + _graphql_page([1, 2], has_next=True, cursor="page2"), + _failure(), + _failure(), + ]) + + by_pr, complete = github_reader._fetch_pr_enrichment_graphql("acme/widgets", "merged", 500) + + # The first page is real data and stays; the read is still incomplete. + assert sorted(by_pr) == [1, 2] + assert complete is False + + +def test_enrichment_response_without_data_is_incomplete(monkeypatch): + _fake_gh(monkeypatch, graphql=[{"errors": [{"message": "Something went wrong"}]}]) + + by_pr, complete = github_reader._fetch_pr_enrichment_graphql("acme/widgets", "merged", 500) + + assert by_pr == {} + assert complete is False + + +def test_one_shot_fetch_is_never_degraded(monkeypatch): + _fake_gh(monkeypatch, full=[{"number": 1}]) + + prs, degraded = github_reader._fetch_prs("acme/widgets", 500, "merged") + + assert prs == [{"number": 1}] + assert degraded == set() + + +def test_failed_basic_pass_drops_the_state_and_says_so(monkeypatch): + _fake_gh(monkeypatch, basic=None) + + # Above the one-shot limit, so the three-pass path runs. + prs, degraded = github_reader._fetch_prs("acme/widgets", 1000, "merged") + + assert prs == [] + assert degraded == {"basic"} + + +def test_failed_enrichment_is_reported(monkeypatch): + _fake_gh( + monkeypatch, + basic=[{"number": 1}], + graphql=[_failure(), _failure()], + reviews=[{"number": 1, "reviews": []}], + ) + + prs, degraded = github_reader._fetch_prs("acme/widgets", 1000, "merged") + + assert [pr["number"] for pr in prs] == [1] + assert prs[0]["commits"] == [] + assert degraded == {"enrichment"} + + +def test_failed_reviews_pass_is_reported(monkeypatch): + _fake_gh( + monkeypatch, + basic=[{"number": 1}], + graphql=[_graphql_page([1])], + reviews=None, + ) + + _prs, degraded = github_reader._fetch_prs("acme/widgets", 1000, "merged") + + assert degraded == {"reviews"} + + +def test_empty_reviews_list_is_not_a_failure(monkeypatch): + _fake_gh( + monkeypatch, + basic=[{"number": 1}], + graphql=[_graphql_page([1])], + reviews=[], + ) + + _prs, degraded = github_reader._fetch_prs("acme/widgets", 1000, "merged") + + assert degraded == set() + + +def test_degraded_steps_from_every_state_are_merged_and_sorted(monkeypatch): + per_state = { + "merged": ([], {"reviews", "enrichment"}), + "closed": ([], set()), + "open": ([], {"enrichment"}), + } + monkeypatch.setattr(github_reader, "is_gh_available", lambda: True) + monkeypatch.setattr(github_reader, "detect_github_remote", lambda _path: "acme/widgets") + monkeypatch.setattr( + github_reader, "_fetch_prs", lambda _nwo, _limit, state: per_state[state], + ) + + fetch = github_reader.read_pull_requests("/any/repo", days=30) + + assert fetch.degraded == ("enrichment", "reviews") diff --git a/tests/test_window_cache.py b/tests/test_window_cache.py index c60cc1c..1c9cef7 100644 --- a/tests/test_window_cache.py +++ b/tests/test_window_cache.py @@ -12,6 +12,7 @@ from iris.ingestion import window_cache from iris.ingestion.github_reader import _finished_before_window +from iris.models.pull_request import PullRequestFetch def setup_function(): @@ -40,7 +41,7 @@ def test_disabled_by_default_always_loads(): def load(): calls["n"] += 1 - return [_pr(1)] + return PullRequestFetch(prs=[_pr(1)]) window_cache.pull_requests("r", 30, load, _keep) window_cache.pull_requests("r", 30, load, _keep) @@ -53,18 +54,18 @@ def test_widest_first_then_slices_in_memory(): def load(): calls["n"] += 1 - return full + return PullRequestFetch(prs=full) window_cache.enable() # Widest window: cache miss → load, keeps everything within 90d. wide = window_cache.pull_requests("r", 90, load, _keep) - assert len(wide) == 4 + assert len(wide.prs) == 4 assert calls["n"] == 1 # Narrower window: cache hit → slice in memory, no extra load. narrow = window_cache.pull_requests("r", 7, load, _keep) - assert len(narrow) == 1 # only the PR from 3 days ago + assert len(narrow.prs) == 1 # only the PR from 3 days ago assert calls["n"] == 1 @@ -73,7 +74,7 @@ def test_request_wider_than_cache_reloads(): def load(): calls["n"] += 1 - return [_pr(5)] + return PullRequestFetch(prs=[_pr(5)]) window_cache.enable() window_cache.pull_requests("r", 30, load, _keep) @@ -86,7 +87,7 @@ def test_cache_is_per_repo(): def load(): calls["n"] += 1 - return [_pr(2)] + return PullRequestFetch(prs=[_pr(2)]) window_cache.enable() window_cache.pull_requests("repo-a", 90, load, _keep) @@ -99,7 +100,7 @@ def test_reset_clears_and_disables(): def load(): calls["n"] += 1 - return [_pr(1)] + return PullRequestFetch(prs=[_pr(1)]) window_cache.enable() window_cache.pull_requests("r", 90, load, _keep) @@ -108,6 +109,19 @@ def load(): assert calls["n"] == 2 +def test_a_narrower_window_keeps_the_wide_fetch_degradation(): + def load(): + return PullRequestFetch(prs=[_pr(80), _pr(3)], degraded=("enrichment",)) + + window_cache.enable() + window_cache.pull_requests("r", 90, load, _keep) + + # Served from the cache: the slice comes from the same incomplete fetch. + narrow = window_cache.pull_requests("r", 7, load, _keep) + assert len(narrow.prs) == 1 + assert narrow.degraded == ("enrichment",) + + # --- the overlap predicate (single source of truth for the filter) ---------