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
143 changes: 96 additions & 47 deletions docs/METRICS.md

Large diffs are not rendered by default.

17 changes: 11 additions & 6 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 @@ -60,10 +62,13 @@ def calculate_origin_funnel(metrics: ReportMetrics) -> FunnelResult | None:
if the PR read was degraded and acceptance is absent: the "In PR"
stage reads acceptance, and every later conversion chains from it.
"""
# A failed enrichment omits acceptance, 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.
# `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

Expand Down
4 changes: 4 additions & 0 deletions iris/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -529,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 @@ -559,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 @@ -692,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
19 changes: 11 additions & 8 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 @@ -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 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
123 changes: 89 additions & 34 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.

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.

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."""
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 Down Expand Up @@ -280,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 @@ -342,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 @@ -441,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