Skip to content
129 changes: 84 additions & 45 deletions docs/METRICS.md

Large diffs are not rendered by default.

6 changes: 4 additions & 2 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
13 changes: 8 additions & 5 deletions iris/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
"pr_enrichment_degraded_caveat": (
"PR data is incomplete for this run — failed steps: {steps}. Merge "
"strategy is reported as unknown when enrichment failed for merged "
"PRs, and flow, review and in-PR metrics may be missing or skewed. A "
"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)."
),
Expand Down Expand Up @@ -928,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 dos PRs mergeados 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
52 changes: 39 additions & 13 deletions iris/ingestion/github_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,18 +212,41 @@ def _fetch_prs(nwo: str, limit: int, gh_state: str) -> tuple[list[dict], set[str
# 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 or a slow or dropped connection can
# pass on a second try; anything else — a 4xx, a GraphQL error — fails the same
# way again, so retrying it would only add the pause to every run.
# 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"HTTP 5\d\d|timeout|timed out|deadline exceeded|connection reset"
r"|connection refused|EOF",
r"\bHTTP 5\d\d\b|\btime(?:d)? ?out\b|\bdeadline exceeded\b"
r"|\bconnection (?:reset|refused)\b|\bEOF\b",
re.IGNORECASE,
)


def _run_gh_with_one_retry(args: list[str]) -> subprocess.CompletedProcess | None:
"""Run a gh command; if it fails transiently, wait once and retry.
def _is_transient_gh_error(stderr: str, nwo: str) -> bool:
"""Whether gh's stderr reads as an error a second try could clear.

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.
Expand All @@ -237,7 +260,7 @@ def _run_gh_with_one_retry(args: list[str]) -> subprocess.CompletedProcess | Non
except FileNotFoundError:
return None
except subprocess.CalledProcessError as error:
if attempt == 2 or not _TRANSIENT_GH_ERROR.search(error.stderr or ""):
if attempt == 2 or not _is_transient_gh_error(error.stderr or "", nwo):
return None
time.sleep(_RETRY_DELAY_S)
return None
Expand Down Expand Up @@ -266,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 Down Expand Up @@ -297,7 +323,7 @@ def _fetch_pr_enrichment_graphql(
if 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 Down Expand Up @@ -375,7 +401,7 @@ def _gh_pr_list(
"--limit", str(limit),
]
if retry:
result = _run_gh_with_one_retry(args)
result = _run_gh_with_one_retry(args, nwo)
else:
try:
result = subprocess.run(
Expand Down
58 changes: 44 additions & 14 deletions iris/metrics/aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@
from iris.models.commit import Commit
from iris.models.external import ExternalDORAData
from iris.models.metrics import ReportMetrics
from iris.models.pull_request import DEGRADED_ENRICHMENT, DEGRADED_REVIEWS, PullRequest
from iris.models.pull_request import (
DEGRADED_BASIC,
DEGRADED_ENRICHMENT,
DEGRADED_FETCH,
DEGRADED_REVIEWS,
PullRequest,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -76,7 +82,8 @@ def aggregate(
Args:
commits: Commits from git_reader (sorted by date ascending).
churn_days: Churn/stabilization window in days.
prs: Optional list of merged PRs from github_reader.
prs: Optional PRs from github_reader, in all three states (merged,
closed and open); each analysis keeps the states it reads.
external_data: Optional pre-fetched DORA events (deployments +
incidents) from a connected provider — currently Datadog. When
provided, populates the ``dora_*`` fields on ReportMetrics;
Expand All @@ -92,8 +99,13 @@ def aggregate(
(`PullRequestFetch.degraded_by_state`). Each omission then
follows the state that feeds the field: merged PRs for review,
flow, acceptance and merge strategy, open PRs for staleness;
closed PRs feed only counts, so they omit nothing. Without it
(None or empty) every state takes ``pr_fetch_degraded``.
closed PRs feed only Flow Load, which every state feeds. A
state whose list (``basic``) failed is missing for everything
it feeds: merged for the PR lifecycle, review, flow, coverage,
acceptance, merge strategy and the timeline's ``prs_merged``;
open for every open-PR field; any state for Flow Load. Without
it (None or empty) every state takes ``pr_fetch_degraded``,
where ``fetch`` (the whole read raised) means every list failed.

Returns:
ReportMetrics with all fields populated. PR fields are None
Expand All @@ -102,8 +114,21 @@ def aggregate(
if pr_fetch_degraded_by_state:
merged_degraded = pr_fetch_degraded_by_state.get("merged", ())
open_degraded = pr_fetch_degraded_by_state.get("open", ())
every_state_degraded = list(pr_fetch_degraded_by_state.values())
else:
merged_degraded = open_degraded = pr_fetch_degraded
every_state_degraded = [pr_fetch_degraded]
# A state whose list failed (`basic`) has no PRs here while the others
# do; with `fetch` (the whole read raised, so only the union carries it)
# no state has any. Whatever a missing state feeds would read as a real
# zero — 0% acceptance, 0 PRs merged a week, a WIP short of its PRs — so
# it is left out, as with no PRs at all.
list_failed = {DEGRADED_BASIC, DEGRADED_FETCH}
merged_missing = not list_failed.isdisjoint(merged_degraded)
open_missing = not list_failed.isdisjoint(open_degraded)
any_list_missing = any(
not list_failed.isdisjoint(steps) for steps in every_state_degraded
)
# With the reviews pass failed every merged PR has no reviews:
# review-derived fields would read as fabricated facts, so they are left out.
reviews_degraded = DEGRADED_REVIEWS in merged_degraded
Expand Down Expand Up @@ -288,9 +313,12 @@ def aggregate(
for c in churn_detail.couplings
]

# Activity timeline
# Activity timeline. Without the merged list, `prs_merged` per week is
# unknown (None), not 0.
timeline_kwargs: dict = {}
timeline_result = calculate_activity_timeline(commits, churn_days, prs=prs)
timeline_result = calculate_activity_timeline(
commits, churn_days, prs=None if merged_missing else prs,
)
if timeline_result:
timeline_kwargs["activity_timeline"] = [
{
Expand Down Expand Up @@ -319,7 +347,7 @@ def aggregate(

# Acceptance rate (requires PR data with commit hashes)
acceptance_kwargs: dict = {}
if prs and not enrichment_degraded:
if prs and not merged_missing and not enrichment_degraded:
acceptance_result = calculate_acceptance_rate(commits, prs)
if acceptance_result:
if acceptance_result.by_origin:
Expand All @@ -335,7 +363,7 @@ def aggregate(
# Its phases anchor on the first review: with the reviews read failed,
# every PR would count as never reviewed, so the block is left out.
flow_efficiency_kwargs: dict = {}
if prs and not reviews_degraded:
if prs and not merged_missing and not reviews_degraded:
flow_efficiency_result = analyze_flow_efficiency(
prs,
commit_origin_map=origin_map,
Expand Down Expand Up @@ -365,7 +393,7 @@ def aggregate(

# Human Review Coverage — fraction of merged PRs a human actually reviewed
human_review_coverage_kwargs: dict = {}
if prs and not reviews_degraded:
if prs and not merged_missing and not reviews_degraded:
coverage_result = analyze_human_review_coverage(
prs,
commit_origin_map=origin_map,
Expand All @@ -388,7 +416,7 @@ def aggregate(

# Open PR Aging — snapshot of stuck inventory (non-draft, non-bot)
open_pr_aging_kwargs: dict = {}
if prs:
if prs and not open_missing:
aging_result = analyze_open_pr_aging(
prs,
now=now_utc(),
Expand Down Expand Up @@ -426,7 +454,7 @@ def aggregate(
# PR ground truth (merge_commit_parent_count) and commit_refs with the
# local main history. Strictly per-repository — no author axis.
merge_strategy_kwargs: dict = {}
if prs:
if prs and not merged_missing:
merge_strategy_result = detect_merge_strategy(
prs,
commits,
Expand All @@ -447,9 +475,11 @@ def aggregate(
merge_strategy_result.distribution,
)

# Flow Load — WIP per ISO week (PRs in flight + author concurrency)
# Flow Load — WIP per ISO week (PRs in flight + author concurrency).
# Every PR state is in flight at some point, so a missing list in any of
# them would undercount the WIP.
flow_load_kwargs: dict = {}
flow_load_result = analyze_flow_load(prs or [], commits)
flow_load_result = None if any_list_missing else analyze_flow_load(prs or [], commits)
if flow_load_result:
flow_load_kwargs["flow_load"] = [
{
Expand All @@ -465,7 +495,7 @@ def aggregate(

# PR lifecycle (optional — only over merged PRs)
pr_kwargs: dict = {}
if prs:
if prs and not merged_missing:
pr_result = analyze_pr_lifecycle(prs)
if pr_result is not None:
pr_kwargs = {
Expand Down
17 changes: 17 additions & 0 deletions tests/test_pr_enrichment_caveat.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,20 @@ def test_caveat_is_translated(tmp_path):

def test_clean_run_has_no_caveat(tmp_path):
assert _CAVEAT_EN not in _report_text(tmp_path, None)


def test_caveat_lists_open_pr_metrics_among_what_may_be_missing(tmp_path):
# A failed open list, or a failed enrichment or reviews pass for open
# PRs, omits open-PR fields too, so the caveat names them.
assert "flow, review, in-PR and open-PR metrics" in _report_text(tmp_path, ["basic"])
assert "de commits em PR e de PRs abertos" in _report_text(
tmp_path, ["basic"], lang="pt-br")


def test_caveat_lists_pr_lifecycle_metrics_among_what_may_be_missing(tmp_path):
# A failed merged list omits the PR lifecycle fields, and a partial read
# skews them, so the caveat names them too.
assert "PR lifecycle (counts, time to merge, cycle time)" in _report_text(
tmp_path, ["basic"])
assert "ciclo de vida de PR (contagens, tempo até o merge, cycle time)" in (
_report_text(tmp_path, ["basic"], lang="pt-br"))
Loading
Loading