Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
cad1c6e
fix(engine): omit acceptance and staleness when enrichment failed
cpenaforte-clickbus Oct 7, 2026
3c21f06
fix(engine): treat null GraphQL fields as an incomplete enrichment
cpenaforte-clickbus Oct 7, 2026
2e7c674
fix(engine): pass the GraphQL cursor as a raw field
cpenaforte-clickbus Oct 7, 2026
e757dba
fix(engine): say when the PR read was incomplete on the console
cpenaforte-clickbus Oct 7, 2026
ff3cc9b
refactor(engine): build acceptance groups with one helper
cpenaforte-clickbus Oct 7, 2026
2300c81
fix(engine): skip the origin funnel when enrichment failed
cpenaforte-clickbus Oct 7, 2026
e4de8dc
fix(engine): skip the funnel whenever a degraded read lost acceptance
cpenaforte-clickbus Oct 7, 2026
daf1190
fix(engine): pass string GraphQL variables as raw fields
cpenaforte-clickbus Oct 7, 2026
7353448
fix(engine): skip null GraphQL nodes and commits
cpenaforte-clickbus Oct 7, 2026
874a51b
fix(engine): count all PR states in the console line
cpenaforte-clickbus Oct 7, 2026
0e0bf9d
fix(engine): treat a page without a nodes list as incomplete
cpenaforte-clickbus Oct 8, 2026
0ec130b
fix(engine): retry only transient gh errors, and the reviews pass too
cpenaforte-clickbus Oct 7, 2026
54c7a1d
fix(engine): gate degraded metrics on the PR state that feeds them
cpenaforte-clickbus Oct 7, 2026
bc19f21
fix(engine): omit what a failed PR list feeds, per state
cpenaforte-clickbus Oct 7, 2026
b8284db
fix(engine): anchor transient gh error detection
cpenaforte-clickbus Oct 7, 2026
2716615
docs(engine): align METRICS.md with per-state degradation
cpenaforte-clickbus Oct 7, 2026
8c3703d
fix(engine): name open-PR metrics in the PR-read caveat
cpenaforte-clickbus Oct 7, 2026
312f820
docs(engine): note flow_load omission when a PR list failed
cpenaforte-clickbus Oct 8, 2026
581095b
docs(engine): correct what the PR read covers and what it skews
cpenaforte-clickbus Oct 8, 2026
d0ae092
fix(engine): name PR lifecycle metrics in the PR-read caveat
cpenaforte-clickbus Oct 8, 2026
b935183
Merge pull request #291 from RocketBus/fix/pr-read-degradation-failed…
cpenaforte-clickbus Oct 8, 2026
3a9cdfd
Merge pull request #290 from RocketBus/fix/pr-read-degradation-per-state
cpenaforte-clickbus Oct 8, 2026
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
147 changes: 105 additions & 42 deletions docs/METRICS.md

Large diffs are not rendered by default.

20 changes: 17 additions & 3 deletions iris/analysis/origin_funnel.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
Each stage has a conversion rate. Comparing funnels across origins reveals
where AI-assisted code drops off relative to human code.

Limitation: PR→Merge stage is not measured (Iris only fetches merged PRs).
Future work could add closed/rejected PR data to fill this gap.
Limitation: PR→Merge stage is not measured. Iris reads merged, closed and open
PRs, but the "In PR" stage reads acceptance, which counts only commits in
merged PRs, and no stage follows PRs that never merge. The closed PRs already
read could fill this gap.
"""

# AGGREGATOR_OPT_OUT: consumes a finalized ReportMetrics (post-aggregation composition).
Expand Down Expand Up @@ -56,8 +58,20 @@ def calculate_origin_funnel(metrics: ReportMetrics) -> FunnelResult | None:
metrics: ReportMetrics with origin, acceptance, and durability data.

Returns:
FunnelResult with per-origin funnels, or None if no origin data.
FunnelResult with per-origin funnels, or None if no origin data, or
if the PR read was degraded and acceptance is absent: the "In PR"
stage reads acceptance, and every later conversion chains from it.
"""
# `pr_enrichment_degraded` is the union over PR states, so it alone cannot
# say whether the PRs acceptance reads were lost; acceptance itself can.
# The aggregator omits it when the merged PRs lost their enrichment, and a
# failed list or read leaves no PRs to match commits to: either way "In
# PR" would fall back to "every commit is in a PR". That default is kept
# only for PRs absent by design (no gh, no GitHub remote), where nothing
# is marked degraded.
if metrics.pr_enrichment_degraded and not metrics.acceptance_by_origin:
return None

origin_dist = metrics.commit_origin_distribution
if not origin_dist:
return None
Expand Down
14 changes: 12 additions & 2 deletions iris/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
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.pull_request import DEGRADED_BASIC, DEGRADED_FETCH
from iris.reports.narrative import generate_narrative
from iris.reports.writer import write_output

Expand Down Expand Up @@ -498,10 +499,15 @@ def _tick(label: str) -> None:
with span("ingestion.pull_requests", {"repo": repo_name}):
pr_fetch = read_pull_requests_with_fallback(repo, days=args.days)
prs = pr_fetch.prs
if prs:
steps = ", ".join(pr_fetch.degraded)
if prs and pr_fetch.degraded:
print(s["cli_prs_found_degraded"].format(count=len(prs), steps=steps))
elif prs:
print(s["cli_prs_found"].format(count=len(prs)))
elif DEGRADED_BASIC in pr_fetch.degraded or DEGRADED_FETCH in pr_fetch.degraded:
print(s["cli_prs_failed"].format(steps=steps))
elif pr_fetch.degraded:
print(s["cli_prs_failed"].format(steps=", ".join(pr_fetch.degraded)))
print(s["cli_prs_none_degraded"].format(steps=steps))
else:
print(s["cli_prs_skipped"])
_tick("pull requests fetched")
Expand All @@ -523,6 +529,7 @@ def _tick(label: str) -> None:
prs=prs or None,
external_data=external_data,
pr_fetch_degraded=pr_fetch.degraded,
pr_fetch_degraded_by_state=pr_fetch.degraded_by_state,
)
print(s["cli_classified"].format(count=len(commits)))
_tick("aggregate analysis done")
Expand Down Expand Up @@ -553,6 +560,7 @@ def _tick(label: str) -> None:
recent_metrics = aggregate(
recent_commits, churn_days=args.churn_days, prs=recent_prs,
pr_fetch_degraded=pr_fetch.degraded,
pr_fetch_degraded_by_state=pr_fetch.degraded_by_state,
)
trend = compute_trend_delta(
baseline=metrics,
Expand Down Expand Up @@ -686,10 +694,12 @@ def _tick(label: str) -> None:
pre_metrics = aggregate(
pre_commits, churn_days=args.churn_days, prs=pre_prs,
pr_fetch_degraded=pr_fetch.degraded,
pr_fetch_degraded_by_state=pr_fetch.degraded_by_state,
)
post_metrics = aggregate(
post_commits, churn_days=args.churn_days, prs=post_prs,
pr_fetch_degraded=pr_fetch.degraded,
pr_fetch_degraded_by_state=pr_fetch.degraded_by_state,
)

# Compute day spans from commit date ranges
Expand Down
27 changes: 17 additions & 10 deletions iris/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,11 @@
),
"pr_enrichment_degraded_caveat": (
"PR data is incomplete for this run — failed steps: {steps}. Merge "
"strategy is reported as unknown when enrichment failed, and flow, "
"review and in-PR metrics may be missing or skewed. A transient "
"GitHub error usually clears on the next run; if every run fails, "
"check that gh is authenticated (gh auth status)."
"strategy is reported as unknown when enrichment failed for merged "
"PRs, and PR lifecycle (counts, time to merge, cycle time), flow, "
"review, in-PR and open-PR metrics may be missing or skewed. A "
"transient GitHub error usually clears on the next run; if every run "
"fails, check that gh is authenticated (gh auth status)."
),
"system_disclaimer": (
"Engineering outcomes emerge from system dynamics, domain complexity, "
Expand Down Expand Up @@ -338,9 +339,11 @@

# CLI
"cli_reading_prs": "Fetching pull requests...",
"cli_prs_found": "{count} merged PRs found.",
"cli_prs_found": "{count} PRs found.",
"cli_prs_skipped": "skipped (no GitHub remote or gh CLI).",
"cli_prs_failed": "failed ({steps}) — continuing without PR data.",
"cli_prs_found_degraded": "{count} PRs found — PR read incomplete ({steps}).",
"cli_prs_none_degraded": "no PRs in the window — PR read incomplete ({steps}).",
"cli_classifying": "Classifying commits...",
"cli_classified": "{count} commits classified.",
"cli_non_code_repo": (
Expand Down Expand Up @@ -926,10 +929,12 @@
"pr_enrichment_degraded_caveat": (
"Os dados de PR estão incompletos nesta execução — passos com falha: "
"{steps}. A estratégia de merge sai como desconhecida quando o "
"enriquecimento falha, e métricas de flow, de review e de commits em "
"PR podem faltar ou sair distorcidas. Um erro transitório do GitHub "
"costuma sumir na próxima execução; se toda execução falhar, "
"verifique se o gh está autenticado (gh auth status)."
"enriquecimento dos PRs mergeados falha, e métricas de ciclo de vida "
"de PR (contagens, tempo até o merge, cycle time), de flow, de "
"review, de commits em PR e de PRs abertos podem faltar ou sair "
"distorcidas. Um erro transitório do GitHub costuma sumir na próxima "
"execução; se toda execução falhar, verifique se o gh está "
"autenticado (gh auth status)."
),
"system_disclaimer": (
"Resultados de engenharia emergem de dinâmicas sistêmicas, complexidade do domínio "
Expand Down Expand Up @@ -1231,9 +1236,11 @@

# CLI
"cli_reading_prs": "Buscando pull requests...",
"cli_prs_found": "{count} PRs merged encontrados.",
"cli_prs_found": "{count} PRs encontrados.",
"cli_prs_skipped": "pulado (sem remote GitHub ou gh CLI).",
"cli_prs_failed": "falhou ({steps}) — seguindo sem dados de PR.",
"cli_prs_found_degraded": "{count} PRs encontrados — leitura de PR incompleta ({steps}).",
"cli_prs_none_degraded": "nenhum PR na janela — leitura de PR incompleta ({steps}).",
"cli_classifying": "Classificando commits...",
"cli_classified": "{count} commits classificados.",
"cli_non_code_repo": (
Expand Down
161 changes: 115 additions & 46 deletions iris/ingestion/github_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,8 @@ def _fetch_prs(nwo: str, limit: int, gh_state: str) -> tuple[list[dict], set[str
3. Reviews via `gh pr list --json number,reviews` in one shot, also
capped at `_BATCH_SIZE`.

Both secondary passes are best-effort — if either fails the PRs come
Both secondary passes are best-effort, with one retry on a transient
failure (`_run_gh_with_one_retry`) — if either still fails the PRs come
back with the respective field empty, but the rest of the metadata
is still usable.

Expand Down Expand Up @@ -158,7 +159,9 @@ def _fetch_prs(nwo: str, limit: int, gh_state: str) -> tuple[list[dict], set[str
if merge_commit:
pr["mergeCommit"] = merge_commit

reviews_prs = _gh_pr_list(nwo, "number,reviews", min(limit, _BATCH_SIZE), gh_state)
reviews_prs = _gh_pr_list(
nwo, "number,reviews", min(limit, _BATCH_SIZE), gh_state, retry=True,
)
if reviews_prs is None:
degraded.add(DEGRADED_REVIEWS)
elif reviews_prs:
Expand Down Expand Up @@ -204,14 +207,50 @@ def _fetch_prs(nwo: str, limit: int, gh_state: str) -> tuple[list[dict], set[str
}


# 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
# One retry for the enrichment query and the reviews pass, after this pause.
# GitHub answers both with intermittent 504s (see `_BATCH_SIZE`); a single
# retry makes a degraded run rarer without hiding the ones that still fail.
_RETRY_DELAY_S = 2.0

# gh writes why it failed to stderr. A 5xx, a timeout (GitHub's GraphQL "may
# be the result of a timeout" error included) or a dropped connection can pass
# on a second try; anything else — a 4xx or other GraphQL error — fails the
# same way again, so retrying it would only add the pause to every run. Whole
# words only, so "thereof" is not an EOF.
_TRANSIENT_GH_ERROR = re.compile(
r"\bHTTP 5\d\d\b|\btime(?:d)? ?out\b|\bdeadline exceeded\b"
r"|\bconnection (?:reset|refused)\b|\bEOF\b",
re.IGNORECASE,
)


def _is_transient_gh_error(stderr: str, nwo: str) -> bool:
"""Whether gh's stderr reads as an error a second try could clear.

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."""
The repo's ``owner/name``, name and owner are blanked out first: gh echoes
them in its errors, and a repo named ``timeout-svc`` would otherwise make
any error about it look like a timeout.
"""
owner, _, name = nwo.partition("/")
for part in (nwo, name, owner):
if part:
# Only where it stands alone, so a repo named "out" leaves
# "timeout" whole.
stderr = re.sub(
rf"(?<![\w.-]){re.escape(part)}(?![\w.-])", " ", stderr,
flags=re.IGNORECASE,
)
return bool(_TRANSIENT_GH_ERROR.search(stderr))


def _run_gh_with_one_retry(
args: list[str], nwo: str,
) -> subprocess.CompletedProcess | None:
"""Run a gh command for ``nwo``; if it fails transiently, wait once and retry.

None when the command fails twice, or fails once with an error that a
retry would not fix.
"""
for attempt in (1, 2):
try:
return subprocess.run(
Expand All @@ -220,9 +259,10 @@ def _run_gh_with_one_retry(args: list[str]) -> subprocess.CompletedProcess | Non
)
except FileNotFoundError:
return None
except subprocess.CalledProcessError:
if attempt == 1:
time.sleep(_ENRICHMENT_RETRY_DELAY_S)
except subprocess.CalledProcessError as error:
if attempt == 2 or not _is_transient_gh_error(error.stderr or "", nwo):
return None
time.sleep(_RETRY_DELAY_S)
return None


Expand All @@ -249,9 +289,12 @@ def _fetch_pr_enrichment_graphql(
of.

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.
on an error — a page whose gh call failed (at once on an error a retry
would not fix, after the one retry on a transient one), unparseable
output, a response without data, more pages with no cursor to reach
them — so ``by_pr`` holds only what earlier pages collected, and the
pass counts as failed even when those pages came back. Reaching
``max_prs`` or the last page is a normal end.
"""
try:
owner, name = nwo.split("/", 1)
Expand All @@ -266,17 +309,21 @@ def _fetch_pr_enrichment_graphql(
end_cursor: str | None = None

while len(by_pr) < max_prs:
# Every variable is a string, so each goes as a raw field (`-f`):
# `-F` lets gh coerce the value, sending a repo named 2048 as a
# number (or `true`/`null` as a boolean/null) that `String!` rejects,
# and reading a file for a value starting with "@".
args = [
"gh", "api", "graphql",
"-f", "query=" + _COMMITS_GRAPHQL_QUERY,
"-F", f"owner={owner}",
"-F", f"name={name}",
"-f", f"owner={owner}",
"-f", f"name={name}",
"-f", f"states[]={graphql_state}",
]
if end_cursor:
args.extend(["-F", f"cursor={end_cursor}"])
args.extend(["-f", f"cursor={end_cursor}"])

result = _run_gh_with_one_retry(args)
result = _run_gh_with_one_retry(args, nwo)
if result is None:
return by_pr, False

Expand All @@ -285,21 +332,31 @@ def _fetch_pr_enrichment_graphql(
except json.JSONDecodeError:
return by_pr, False

page = (
data.get("data", {})
.get("repository", {})
.get("pullRequests")
if data.get("data") else None
)
if not page:
# Any non-dict on the way down (null repository, null data) is a
# missing page, not a reason to lose the whole PR read.
payload = data.get("data") if isinstance(data, dict) else None
repository = payload.get("repository") if isinstance(payload, dict) else None
page = repository.get("pullRequests") if isinstance(repository, dict) else None
if not page or not isinstance(page, dict):
return by_pr, False

for node in page.get("nodes", []):
# Without a list of nodes the page's PRs are missing, as above. A null
# PR node or commit entry inside the list is skipped instead.
nodes = page.get("nodes")
if not isinstance(nodes, list):
return by_pr, False
for node in nodes:
if not isinstance(node, dict):
continue
number = node.get("number")
if number is None:
continue
commits = []
for entry in node.get("commits", {}).get("nodes", []):
commits_conn = node.get("commits")
entries = commits_conn.get("nodes") if isinstance(commits_conn, dict) else None
for entry in entries or []:
if not isinstance(entry, dict):
continue
commit = entry.get("commit") or {}
oid = commit.get("oid", "")
if not oid:
Expand Down Expand Up @@ -328,26 +385,33 @@ def _fetch_pr_enrichment_graphql(
return by_pr, True


def _gh_pr_list(nwo: str, fields: str, limit: int, gh_state: str) -> list[dict] | None:
"""Run gh pr list and return parsed JSON, or None on failure."""
try:
result = subprocess.run(
[
"gh", "pr", "list",
"--repo", nwo,
"--state", gh_state,
"--json", fields,
"--limit", str(limit),
],
capture_output=True,
text=True,
check=True,
env=git_env(),
)
except (subprocess.CalledProcessError, FileNotFoundError):
return None
def _gh_pr_list(
nwo: str, fields: str, limit: int, gh_state: str, *, retry: bool = False,
) -> list[dict] | None:
"""Run gh pr list and return parsed JSON, or None on failure.

if not result.stdout.strip():
With ``retry``, a transient failure gets one more try
(`_run_gh_with_one_retry`).
"""
args = [
"gh", "pr", "list",
"--repo", nwo,
"--state", gh_state,
"--json", fields,
"--limit", str(limit),
]
if retry:
result = _run_gh_with_one_retry(args, nwo)
else:
try:
result = subprocess.run(
args, capture_output=True, text=True, check=True,
env=git_env(),
)
except (subprocess.CalledProcessError, FileNotFoundError):
result = None

if result is None or not result.stdout.strip():
return None

try:
Expand Down Expand Up @@ -427,6 +491,11 @@ def _read_pull_requests_uncached(repo_path: str, days: int) -> PullRequestFetch:
return PullRequestFetch(
prs=_parse_pull_requests(merged_raw + closed_raw + open_raw, since),
degraded=tuple(sorted(merged_degraded | closed_degraded | open_degraded)),
degraded_by_state={
"merged": tuple(sorted(merged_degraded)),
"closed": tuple(sorted(closed_degraded)),
"open": tuple(sorted(open_degraded)),
},
)


Expand Down
1 change: 1 addition & 0 deletions iris/ingestion/window_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def pull_requests(
return PullRequestFetch(
prs=[pr for pr in fetch.prs if keep(pr, since)],
degraded=fetch.degraded,
degraded_by_state=fetch.degraded_by_state,
)

fresh = load()
Expand Down
Loading
Loading