Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions iris/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
127 changes: 93 additions & 34 deletions iris/ingestion/github_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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", [])
Expand All @@ -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:`
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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", {})
Expand All @@ -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")
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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)

Expand All @@ -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:
Expand Down
16 changes: 12 additions & 4 deletions iris/ingestion/window_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions iris/models/pull_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...] = ()
10 changes: 4 additions & 6 deletions iris/org_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading