You are viewing a plain text version of this content. The canonical link for it is here.
Posted to cvs@incubator.apache.org by jm...@apache.org on 2026/05/31 01:13:56 UTC
(incubator) branch master updated: made multi-repo aware and some github optimisation
This is an automated email from the ASF dual-hosted git repository.
justinmclean pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator.git
The following commit(s) were added to refs/heads/master by this push:
new e00a6da58 made multi-repo aware and some github optimisation
e00a6da58 is described below
commit e00a6da58402b1997dbf339496b4c61949b0f68f
Author: Justin Mclean <jm...@apache.org>
AuthorDate: Sun May 31 11:13:41 2026 +1000
made multi-repo aware and some github optimisation
---
tools/health/incubator_health.py | 285 +++++++++++++++++++++++++++++----
tools/health/reports/KIE.md | 92 +++++------
tools/health/reports/OpenServerless.md | 84 +++++-----
3 files changed, 346 insertions(+), 115 deletions(-)
diff --git a/tools/health/incubator_health.py b/tools/health/incubator_health.py
index 897c9e8fc..adf46ec56 100644
--- a/tools/health/incubator_health.py
+++ b/tools/health/incubator_health.py
@@ -18,6 +18,7 @@ from __future__ import annotations
import argparse
import csv
import datetime as dt
+import json
import math
import os
import time
@@ -63,6 +64,18 @@ def parse_args() -> argparse.Namespace:
p.add_argument("--minutes-dir", required=True, help="Path to ASF board archived agendas (txt files).")
p.add_argument("--repos", nargs="*", default=[], help="GitHub repos (owner/name). Default guess is apache/<slug>.")
+ p.add_argument("--auto-discover-repos",
+ action=argparse.BooleanOptionalAction,
+ default=True,
+ help="Discover apache/* repos per podling via GitHub search "
+ "(name matches slug or incubator-<slug>). On by default; "
+ "captures multi-repo podlings like KIE and OpenServerless. "
+ "Pass --no-auto-discover-repos to force the apache/<slug> guess. "
+ "Ignored if --repos is given.")
+ p.add_argument("--repos-cache-dir", default=".repo_cache",
+ help="Directory for cached repo-discovery results (default: .repo_cache).")
+ p.add_argument("--repos-cache-ttl-days", type=int, default=7,
+ help="Discovery cache TTL in days (default: 7).")
p.add_argument("--today", default=None, help="Override today YYYY-MM-DD.")
# Output
@@ -142,6 +155,117 @@ def canonical_repo(repo: str) -> str:
return hit or repo
+# ---------------- Repo auto-discovery ----------------
+
+def _slug_variants(slug: str) -> List[str]:
+ """All accepted spellings of a podling slug for repo-name matching.
+
+ Some podlings use the dashed form in their podling name but the joined
+ form in their repo names (Pony Mail → apache/incubator-ponymail-*).
+ """
+ s = (slug or "").strip().lower()
+ if not s:
+ return []
+ dashed = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
+ joined = re.sub(r"[^a-z0-9]+", "", s)
+ return [v for v in dict.fromkeys([dashed, joined]) if v]
+
+
+def _repo_name_matches_slug(repo_name: str, slug: str) -> bool:
+ """Whole-segment match against the slug (or its joined variant) and
+ optional `incubator-` prefix.
+
+ Accepts: <slug>, incubator-<slug>, <slug>-..., incubator-<slug>-...
+ where <slug> may be either the dashed or joined spelling.
+ Rejects: other-<slug>, <slug>foo (substring matches without a segment break)
+ """
+ name = repo_name.lower()
+ for v in _slug_variants(slug):
+ s = re.escape(v)
+ if re.match(rf"^(incubator-)?{s}(-.+)?$", name):
+ return True
+ return False
+
+
+def discover_apache_repos(
+ slug: str,
+ cache_dir: Optional[str] = None,
+ ttl_days: int = 7,
+ debug: bool = False,
+) -> List[str]:
+ """Find apache/* repos belonging to a podling via GitHub repo search.
+
+ Filters search hits to repos whose name matches the podling slug in a
+ whole-segment way (see `_repo_name_matches_slug`). Results are cached to
+ `<cache_dir>/<slug>.json` for `ttl_days` so reruns don't burn rate-limit.
+ Returns `[]` if discovery turns up nothing — caller decides on fallback.
+ """
+ slug = (slug or "").strip().lower()
+ if not slug:
+ return []
+
+ cache_file = None
+ if cache_dir:
+ try:
+ os.makedirs(cache_dir, exist_ok=True)
+ cache_file = os.path.join(cache_dir, f"{slug}.json")
+ if os.path.exists(cache_file):
+ age = time.time() - os.path.getmtime(cache_file)
+ if age < ttl_days * 86400:
+ try:
+ with open(cache_file, "r") as f:
+ data = json.load(f)
+ repos = data.get("repos", [])
+ if isinstance(repos, list) and all(isinstance(r, str) for r in repos):
+ log(f" [discover] cache hit ({len(repos)} repos, {int(age)}s old) — {slug}", debug)
+ return repos
+ except Exception as e:
+ log(f" [discover] cache read failed for {slug}: {e}", debug)
+ except OSError as e:
+ log(f" [discover] cache dir unusable ({cache_dir}): {e}", debug)
+ cache_file = None
+
+ q = f"org:apache {slug} in:name"
+ log(f" [discover] GitHub search: {q!r}", debug)
+ try:
+ items = gh_paginate(
+ "https://api.github.com/search/repositories",
+ {"q": q, "per_page": "100"},
+ )
+ except Exception as e:
+ log(f" [discover] search failed for {slug}: {e}", debug)
+ return []
+
+ matched: List[str] = []
+ skipped: List[str] = []
+ for it in items:
+ full = it.get("full_name", "") if isinstance(it, dict) else ""
+ if not full or "/" not in full:
+ continue
+ owner, name = full.split("/", 1)
+ if owner.lower() != "apache":
+ continue
+ if _repo_name_matches_slug(name, slug):
+ matched.append(full)
+ else:
+ skipped.append(full)
+
+ matched = sorted(set(matched))
+ if debug and skipped:
+ log(f" [discover] filtered out {len(skipped)} unrelated hits (e.g. {', '.join(skipped[:3])}{' …' if len(skipped) > 3 else ''})", debug)
+ log(f" [discover] kept {len(matched)} repo(s) for {slug}", debug)
+
+ if cache_file:
+ try:
+ with open(cache_file, "w") as f:
+ json.dump({"fetched": dt.datetime.utcnow().isoformat() + "Z",
+ "slug": slug, "query": q, "repos": matched}, f, indent=2)
+ except OSError as e:
+ log(f" [discover] cache write failed for {slug}: {e}", debug)
+
+ return matched
+
+
# ---------------- Trend arrows & scoring ----------------
def trend_score(curr: Optional[float], prev: Optional[float], *, lower_is_better: bool, flat_tol: float = 0.10) -> int:
@@ -249,11 +373,13 @@ def gh_get(url: str, params: Dict[str, str] | None = None, max_retries: int = 6)
raise RuntimeError(f"GitHub GET failed after {max_retries} attempts: {url}")
def gh_paginate(url: str, params: Dict[str, str]) -> List[dict]:
+ """Walk GitHub's Link: rel="next" pagination through gh_get so all
+ callers inherit primary/secondary rate-limit back-off."""
out: List[dict] = []
- sess = requests.Session()
- while True:
- r = sess.get(url, params=params, headers=gh_headers(), timeout=30)
- r.raise_for_status()
+ next_url: Optional[str] = url
+ next_params: Optional[Dict[str, str]] = params
+ while next_url:
+ r = gh_get(next_url, next_params)
data = r.json()
if isinstance(data, list):
chunk = data
@@ -266,12 +392,13 @@ def gh_paginate(url: str, params: Dict[str, str]) -> List[dict]:
nxt = None
for part in ln.split(","):
if 'rel="next"' in part:
- nxt = part[part.find("<")+1:part.find(">")]
+ nxt = part[part.find("<") + 1:part.find(">")]
break
if not nxt:
break
- url = nxt
- params = {}
+ # Subsequent requests carry params inline in the Link URL.
+ next_url = nxt
+ next_params = None
return out
def gh_search_issues(q: str) -> int:
@@ -288,7 +415,16 @@ def gh_get_pr(repo: str, number: int) -> dict:
def gh_list_commits(repo: str, since: dt.datetime, until: dt.datetime) -> List[dict]:
url = f"https://api.github.com/repos/{repo}/commits"
- return gh_paginate(url, {"since": since.strftime(ISO), "until": until.strftime(ISO), "per_page": "100"})
+ try:
+ return gh_paginate(url, {"since": since.strftime(ISO), "until": until.strftime(ISO), "per_page": "100"})
+ except requests.HTTPError as e:
+ # 409: "Git Repository is empty" (placeholder repo, no commits yet).
+ # 404: repo deleted, renamed, or otherwise unreachable.
+ # Treat as zero commits — no point logging a stack-trace per window.
+ status = getattr(e.response, "status_code", None)
+ if status in (404, 409):
+ return []
+ raise
def median_days_between(dts: List[dt.datetime]) -> Optional[float]:
if len(dts) < 2:
@@ -798,16 +934,94 @@ def compute_bus_factor_proxies(by_ident_counts: Dict[str, int], total: int) -> T
break
return k50, k75
-def compute_github_metrics(repos: List[str], start: dt.datetime, end: dt.datetime, debug: bool=False) -> Dict[str, any]:
+def gh_list_contributor_idents(repo: str, debug: bool = False) -> set:
+ """Set of identity strings for everyone who has ever committed to `repo`.
+
+ Uses /repos/<r>/contributors?anon=true, which returns ONE row per identity
+ (with a `contributions` count) rather than one row per commit — orders of
+ magnitude cheaper than walking /commits when you only need membership.
+
+ Anonymous entries (commits whose author email is not linked to a GitHub
+ user) have no `login` but do carry `name` and `email`; we fall back to
+ those so coverage matches the /commits-based prior we used to compute.
+
+ Caveat: GitHub caps this endpoint at the top ~500 contributors per repo.
+ For the few podlings with very long tails this can truncate the prior set
+ slightly; in practice the cap is well above what every current ASF
+ podling repo carries.
+ """
+ url = f"https://api.github.com/repos/{repo}/contributors"
+ try:
+ rows = gh_paginate(url, {"anon": "true", "per_page": "100"})
+ except requests.HTTPError as e:
+ status = getattr(e.response, "status_code", None)
+ # 204: no content (no contributors yet). 404: missing. 403/451: blocked.
+ if status in (204, 404, 403, 451):
+ return set()
+ raise
+ out: set = set()
+ for row in rows:
+ if not isinstance(row, dict):
+ continue
+ login = (row.get("login") or "").strip()
+ # Anonymous entries: type == "Anonymous", with name + email.
+ name = (row.get("name") or "").strip()
+ ident = login or name
+ if ident:
+ out.add(ident)
+ log(f" [prior] {repo}: {len(out)} identities", debug)
+ return out
+
+
+def collect_prior_committer_idents(
+ repos: List[str],
+ debug: bool = False,
+) -> set:
+ """Cross-repo set of identities that have ever committed to any repo.
+
+ Used as the "returning contributor" set when classifying who's new in a
+ given window. Computed once per podling; not date-bounded because the
+ underlying /contributors endpoint isn't date-filterable, and "ever
+ contributed before" is the strict reading of "returning."
+ """
+ prior: set = set()
+ for r in repos:
+ try:
+ prior |= gh_list_contributor_idents(r, debug=debug)
+ except Exception as e:
+ log(f"[gh] prior contributors {r}: {e}", debug)
+ log(f" [prior] {len(prior)} identities across {len(repos)} repo(s) (all-time)", debug)
+ return prior
+
+
+def compute_github_metrics(
+ repos: List[str],
+ start: dt.datetime,
+ end: dt.datetime,
+ debug: bool = False,
+ *,
+ prior_idents: Optional[set] = None,
+) -> Dict[str, any]:
+ """Compute GH metrics for a pre-canonicalized list of repos.
+
+ The caller is responsible for canonicalization. Doing it here would mean
+ re-probing every repo on every window (3×), which burns rate-limit; for
+ auto-discovered repos canonicalization is also pure waste because
+ `full_name` from /search/repositories is canonical by definition.
+
+ `prior_idents` is the cross-repo set of identities that have committed to
+ any repo before this window. Passed in once per podling so the prior fetch
+ isn't repeated per window. If not provided, it's computed lazily here via
+ /contributors.
+ """
commit_authors_window: set = set()
- commit_authors_prior: set = set()
total_commits = 0
by_ident_counts: Dict[str, int] = {}
- horizon_start = start - dt.timedelta(days=365 * 5)
- repos = [canonical_repo(r) for r in repos]
+ if prior_idents is None:
+ prior_idents = collect_prior_committer_idents(repos, debug=debug)
+
for r in repos:
- r = canonical_repo(r)
try:
commits = gh_list_commits(r, start, end)
total_commits += len(commits)
@@ -819,20 +1033,10 @@ def compute_github_metrics(repos: List[str], start: dt.datetime, end: dt.datetim
if ident:
commit_authors_window.add((r, ident))
by_ident_counts[ident] = by_ident_counts.get(ident, 0) + 1
-
- prior = gh_list_commits(r, horizon_start, start - dt.timedelta(seconds=1))
- for c in prior:
- a = c.get("author") or {}
- login = a.get("login")
- name = (c.get("commit") or {}).get("author", {}).get("name")
- ident = (login or name or "").strip()
- if ident:
- commit_authors_prior.add((r, ident))
except Exception as e:
log(f"[gh] commits {r}: {e}", debug)
window_idents = {ident for (_, ident) in commit_authors_window}
- prior_idents = {ident for (_, ident) in commit_authors_prior}
bus50, bus75 = compute_bus_factor_proxies(by_ident_counts, total_commits)
@@ -1181,12 +1385,32 @@ def main():
for idx, podling in enumerate(podlings, 1):
log(f"[{idx}/{total}] {podling}", args.debug)
- # Repo guess if not provided — prefer apache/<slug> only
+ # Repo guess if not provided.
+ # Precedence: --repos (literal) > --auto-discover-repos > apache/<slug>.
+ # `needs_canonical` tracks whether the list came from a source where
+ # `incubator-<name>` toggling might be needed. Discovery results come
+ # straight from /search/repositories and are already canonical, so we
+ # skip the canonical_repo() probes for them — that probe fires up to
+ # 2 API calls per repo and otherwise runs once per podling.
+ needs_canonical = True
if args.repos:
repos = args.repos[:]
else:
- slug = re.sub(r"[^a-z0-9]+", "-", podling.lower())
- repos = [f"apache/{slug}"]
+ slug = re.sub(r"[^a-z0-9]+", "-", podling.lower()).strip("-")
+ repos = []
+ if args.auto_discover_repos:
+ repos = discover_apache_repos(
+ slug,
+ cache_dir=args.repos_cache_dir,
+ ttl_days=args.repos_cache_ttl_days,
+ debug=args.debug,
+ )
+ if repos:
+ needs_canonical = False
+ if not repos:
+ repos = [f"apache/{slug}"]
+ if needs_canonical:
+ repos = [canonical_repo(r) for r in repos]
log(f" [repos] {', '.join(repos)}", args.debug)
# Strict window selection by podling age
@@ -1198,6 +1422,11 @@ def main():
win_specs = select_windows_strict(today, pod_start)
+ # Prior-committer set: built once per podling via /contributors
+ # (one row per identity, not per commit). Not date-bounded — used
+ # only as a membership check for the "new contributor" metric.
+ prior_idents = collect_prior_committer_idents(repos, debug=args.debug)
+
windows: List[WindowMetrics] = []
for label, start, end in win_specs:
log(f" [win {label}] {to_iso_date(start)} → {to_iso_date(end)}", args.debug)
@@ -1228,7 +1457,9 @@ def main():
# GitHub metrics (incl. bus factor + diversity)
log(" [gh] commits/issues/prs/diversity …", args.debug)
- gh = compute_github_metrics(repos, start, end, debug=args.debug)
+ gh = compute_github_metrics(
+ repos, start, end, debug=args.debug, prior_idents=prior_idents
+ )
for k, v in gh.items():
setattr(wm, k, v)
diff --git a/tools/health/reports/KIE.md b/tools/health/reports/KIE.md
index 4c2c56231..586c8a31b 100644
--- a/tools/health/reports/KIE.md
+++ b/tools/health/reports/KIE.md
@@ -1,5 +1,5 @@
# KIE — Incubator Health (Reports + GitHub + Mailing Lists)
-_Generated on 2026-05-29_
+_Generated on 2026-05-31_
**Windows:** 3m, 6m, 12m
@@ -8,67 +8,67 @@ _Generated on 2026-05-29_
- **Releases (from list votes/results):** 1 (—)
- **Median days between releases:** — (—)
- **New contributors:** 0 (—)
-- **Unique committers:** 0 (—)
-- **Commits:** 0 (—)
-- **Issues opened/closed:** 0/0 (—/—)
-- **PRs opened/merged:** 0/0 (—/—)
-- **Median PR time-to-merge (days):** — (—)
-- **Bus factor proxy (contributors to reach 50% / 75% of commits):** — / — (→/→)
-- **Mailing list msgs (dev@):** 154 (↗)
-- **Unique posters (dev@):** 37 (↗↗)
-- **Reviewer diversity (eff.#, sampled):** — (—)
-- **PR author diversity (eff.#, sampled):** — (—)
-- **Unique reviewers (sampled):** 0 (—)
-- **Unique PR authors (sampled):** 0 (—)
+- **Unique committers:** 44 (↗↗)
+- **Commits:** 292 (→)
+- **Issues opened/closed:** 152/160 (→/→)
+- **PRs opened/merged:** 394/292 (↗/→)
+- **Median PR time-to-merge (days):** 2.7 (↗)
+- **Bus factor proxy (contributors to reach 50% / 75% of commits):** 6 / 13 (↘↘/↘↘)
+- **Mailing list msgs (dev@):** 155 (↗)
+- **Unique posters (dev@):** 38 (↗↗)
+- **Reviewer diversity (eff.#, sampled):** 7.95 (↘)
+- **PR author diversity (eff.#, sampled):** 17.99 (↘↘)
+- **Unique reviewers (sampled):** 32 (↗↗)
+- **Unique PR authors (sampled):** 43 (↗↗)
## Trends (short vs long)
- **Releases (from list votes/results):** 1 (—)
- **Median days between releases:** — (—)
- **New contributors:** 0 (—)
-- **Unique committers:** 0 (—)
-- **Commits:** 0 (—)
-- **Issues opened/closed:** 0/0 (—/—)
-- **PRs opened/merged:** 0/0 (—/—)
-- **Median PR time-to-merge (days):** — (—)
-- **Bus factor proxy (contributors to reach 50% / 75% of commits):** — / — (→/→)
-- **Mailing list msgs (dev@):** 154 (↗)
-- **Unique posters (dev@):** 37 (↗↗↗)
-- **Reviewer diversity (eff.#, sampled):** — (—)
-- **PR author diversity (eff.#, sampled):** — (—)
-- **Unique reviewers (sampled):** 0 (—)
-- **Unique PR authors (sampled):** 0 (—)
+- **Unique committers:** 44 (↗↗)
+- **Commits:** 292 (↘↘)
+- **Issues opened/closed:** 152/160 (→/→)
+- **PRs opened/merged:** 394/292 (↗↗/↗)
+- **Median PR time-to-merge (days):** 2.7 (→)
+- **Bus factor proxy (contributors to reach 50% / 75% of commits):** 6 / 13 (↗/↘↘)
+- **Mailing list msgs (dev@):** 155 (↗)
+- **Unique posters (dev@):** 38 (↗↗↗)
+- **Reviewer diversity (eff.#, sampled):** 7.95 (↘↘)
+- **PR author diversity (eff.#, sampled):** 17.99 (↘↘)
+- **Unique reviewers (sampled):** 32 (↗↗↗)
+- **Unique PR authors (sampled):** 43 (↗↗↗)
## Window Details
-### 3m (2026-02-28 → 2026-05-29)
+### 3m (2026-02-28 → 2026-05-31)
- **Releases (from list votes/results):** 1 | **Median gap (days):** —
-- **New contributors:** 0 | **Unique committers:** 0 | **Commits:** 0
-- **Issues:** opened 0 / closed 0
-- **PRs:** opened 0 / merged 0 | **Median merge time (days):** —
-- **Reviews (sampled):** median reviewers/PR **—** | reviewer diversity (eff.#) **—** | PR author diversity (eff.#) **—** | unique reviewers **0**, unique authors **0**
-- **Bus factor proxy (50% / 75%):** — / —
+- **New contributors:** 0 | **Unique committers:** 44 | **Commits:** 292
+- **Issues:** opened 152 / closed 160
+- **PRs:** opened 394 / merged 292 | **Median merge time (days):** 2.7
+- **Reviews (sampled):** median reviewers/PR **2.0** | reviewer diversity (eff.#) **7.95** | PR author diversity (eff.#) **17.99** | unique reviewers **32**, unique authors **43**
+- **Bus factor proxy (50% / 75%):** 6 / 13
- **Incubator reports:** 1 | **Avg mentor sign-offs:** 1.0
-- **Mailing lists:** dev messages **154**, dev unique posters **37**
+- **Mailing lists:** dev messages **155**, dev unique posters **38**
-### 6m (2025-11-29 → 2026-05-29)
+### 6m (2025-11-30 → 2026-05-31)
- **Releases (from list votes/results):** 1 | **Median gap (days):** —
-- **New contributors:** 0 | **Unique committers:** 0 | **Commits:** 0
-- **Issues:** opened 0 / closed 0
-- **PRs:** opened 0 / merged 0 | **Median merge time (days):** —
-- **Reviews (sampled):** median reviewers/PR **—** | reviewer diversity (eff.#) **—** | PR author diversity (eff.#) **—** | unique reviewers **0**, unique authors **0**
-- **Bus factor proxy (50% / 75%):** — / —
+- **New contributors:** 0 | **Unique committers:** 51 | **Commits:** 539
+- **Issues:** opened 297 / closed 318
+- **PRs:** opened 696 / merged 536 | **Median merge time (days):** 3.2
+- **Reviews (sampled):** median reviewers/PR **2.0** | reviewer diversity (eff.#) **9.06** | PR author diversity (eff.#) **24.87** | unique reviewers **36**, unique authors **49**
+- **Bus factor proxy (50% / 75%):** 8 / 19
- **Incubator reports:** 2 | **Avg mentor sign-offs:** 1.5
-- **Mailing lists:** dev messages **248**, dev unique posters **47**
+- **Mailing lists:** dev messages **249**, dev unique posters **47**
-### 12m (2025-05-29 → 2026-05-29)
+### 12m (2025-05-31 → 2026-05-31)
- **Releases (from list votes/results):** 2 | **Median gap (days):** 292
-- **New contributors:** 0 | **Unique committers:** 0 | **Commits:** 0
-- **Issues:** opened 0 / closed 0
-- **PRs:** opened 0 / merged 0 | **Median merge time (days):** —
-- **Reviews (sampled):** median reviewers/PR **—** | reviewer diversity (eff.#) **—** | PR author diversity (eff.#) **—** | unique reviewers **0**, unique authors **0**
-- **Bus factor proxy (50% / 75%):** — / —
+- **New contributors:** 0 | **Unique committers:** 129 | **Commits:** 2185
+- **Issues:** opened 654 / closed 603
+- **PRs:** opened 1223 / merged 957 | **Median merge time (days):** 2.9
+- **Reviews (sampled):** median reviewers/PR **2** | reviewer diversity (eff.#) **12.57** | PR author diversity (eff.#) **30.19** | unique reviewers **39**, unique authors **59**
+- **Bus factor proxy (50% / 75%):** 5 / 22
- **Incubator reports:** 4 | **Avg mentor sign-offs:** 2.0
-- **Mailing lists:** dev messages **513**, dev unique posters **59**
+- **Mailing lists:** dev messages **503**, dev unique posters **58**
## Reading the Indicators
- **Use trend arrows** to compare available windows; when a window lacks ≥28 days or ≥5 events, trends show **—**.
diff --git a/tools/health/reports/OpenServerless.md b/tools/health/reports/OpenServerless.md
index cd31c00de..3d16e856b 100644
--- a/tools/health/reports/OpenServerless.md
+++ b/tools/health/reports/OpenServerless.md
@@ -1,5 +1,5 @@
# OpenServerless — Incubator Health (Reports + GitHub + Mailing Lists)
-_Generated on 2026-05-29_
+_Generated on 2026-05-31_
**Windows:** 3m, 6m, 12m
@@ -7,68 +7,68 @@ _Generated on 2026-05-29_
- **Releases (from list votes/results):** 0 (—)
- **Median days between releases:** — (—)
-- **New contributors:** 0 (—)
-- **Unique committers:** 1 (—)
-- **Commits:** 4 (—)
+- **New contributors:** 4 (—)
+- **Unique committers:** 10 (↗↗)
+- **Commits:** 165 (↗↗)
- **Issues opened/closed:** 0/0 (—/—)
-- **PRs opened/merged:** 4/4 (—/—)
-- **Median PR time-to-merge (days):** 4.5 (—)
-- **Bus factor proxy (contributors to reach 50% / 75% of commits):** 1 / 1 (→/→)
-- **Mailing list msgs (dev@):** 169 (↗)
+- **PRs opened/merged:** 52/31 (↗/↗↗)
+- **Median PR time-to-merge (days):** 0.9 (↘↘)
+- **Bus factor proxy (contributors to reach 50% / 75% of commits):** 3 / 5 (→/→)
+- **Mailing list msgs (dev@):** 174 (↗)
- **Unique posters (dev@):** 16 (↗↗)
-- **Reviewer diversity (eff.#, sampled):** 1.0 (—)
-- **PR author diversity (eff.#, sampled):** 1.0 (—)
-- **Unique reviewers (sampled):** 1 (—)
-- **Unique PR authors (sampled):** 1 (—)
+- **Reviewer diversity (eff.#, sampled):** 1.8 (—)
+- **PR author diversity (eff.#, sampled):** 3.68 (→)
+- **Unique reviewers (sampled):** 2 (—)
+- **Unique PR authors (sampled):** 5 (↗↗)
## Trends (short vs long)
- **Releases (from list votes/results):** 0 (—)
- **Median days between releases:** — (—)
-- **New contributors:** 0 (—)
-- **Unique committers:** 1 (—)
-- **Commits:** 4 (—)
+- **New contributors:** 4 (—)
+- **Unique committers:** 10 (↗↗↗)
+- **Commits:** 165 (↗)
- **Issues opened/closed:** 0/0 (—/—)
-- **PRs opened/merged:** 4/4 (—/—)
-- **Median PR time-to-merge (days):** 4.5 (—)
-- **Bus factor proxy (contributors to reach 50% / 75% of commits):** 1 / 1 (→/→)
-- **Mailing list msgs (dev@):** 169 (↘)
+- **PRs opened/merged:** 52/31 (↘/↘)
+- **Median PR time-to-merge (days):** 0.9 (↘↘)
+- **Bus factor proxy (contributors to reach 50% / 75% of commits):** 3 / 5 (↗↗/→)
+- **Mailing list msgs (dev@):** 174 (↘)
- **Unique posters (dev@):** 16 (↗↗↗)
-- **Reviewer diversity (eff.#, sampled):** 1.0 (—)
-- **PR author diversity (eff.#, sampled):** 1.0 (—)
-- **Unique reviewers (sampled):** 1 (—)
-- **Unique PR authors (sampled):** 1 (—)
+- **Reviewer diversity (eff.#, sampled):** 1.8 (—)
+- **PR author diversity (eff.#, sampled):** 3.68 (→)
+- **Unique reviewers (sampled):** 2 (—)
+- **Unique PR authors (sampled):** 5 (↗↗)
## Window Details
-### 3m (2026-02-28 → 2026-05-29)
+### 3m (2026-02-28 → 2026-05-31)
- **Releases (from list votes/results):** 0 | **Median gap (days):** —
-- **New contributors:** 0 | **Unique committers:** 1 | **Commits:** 4
+- **New contributors:** 4 | **Unique committers:** 10 | **Commits:** 165
- **Issues:** opened 0 / closed 0
-- **PRs:** opened 4 / merged 4 | **Median merge time (days):** 4.5
-- **Reviews (sampled):** median reviewers/PR **1.0** | reviewer diversity (eff.#) **1.0** | PR author diversity (eff.#) **1.0** | unique reviewers **1**, unique authors **1**
-- **Bus factor proxy (50% / 75%):** 1 / 1
+- **PRs:** opened 52 / merged 31 | **Median merge time (days):** 0.9
+- **Reviews (sampled):** median reviewers/PR **0** | reviewer diversity (eff.#) **1.8** | PR author diversity (eff.#) **3.68** | unique reviewers **2**, unique authors **5**
+- **Bus factor proxy (50% / 75%):** 3 / 5
- **Incubator reports:** 1 | **Avg mentor sign-offs:** 3.0
-- **Mailing lists:** dev messages **169**, dev unique posters **16**
+- **Mailing lists:** dev messages **174**, dev unique posters **16**
-### 6m (2025-11-29 → 2026-05-29)
+### 6m (2025-11-30 → 2026-05-31)
- **Releases (from list votes/results):** 0 | **Median gap (days):** —
-- **New contributors:** 0 | **Unique committers:** 1 | **Commits:** 4
+- **New contributors:** 5 | **Unique committers:** 10 | **Commits:** 220
- **Issues:** opened 6 / closed 4
-- **PRs:** opened 5 / merged 4 | **Median merge time (days):** 4.5
-- **Reviews (sampled):** median reviewers/PR **1.0** | reviewer diversity (eff.#) **1.0** | PR author diversity (eff.#) **1.0** | unique reviewers **1**, unique authors **1**
-- **Bus factor proxy (50% / 75%):** 1 / 1
+- **PRs:** opened 87 / merged 46 | **Median merge time (days):** 0.1
+- **Reviews (sampled):** median reviewers/PR **0.0** | reviewer diversity (eff.#) **1.6** | PR author diversity (eff.#) **4.05** | unique reviewers **2**, unique authors **6**
+- **Bus factor proxy (50% / 75%):** 3 / 5
- **Incubator reports:** 2 | **Avg mentor sign-offs:** 2.5
-- **Mailing lists:** dev messages **287**, dev unique posters **23**
+- **Mailing lists:** dev messages **289**, dev unique posters **23**
-### 12m (2025-05-29 → 2026-05-29)
+### 12m (2025-05-31 → 2026-05-31)
- **Releases (from list votes/results):** 0 | **Median gap (days):** —
-- **New contributors:** 0 | **Unique committers:** 1 | **Commits:** 9
-- **Issues:** opened 58 / closed 36
-- **PRs:** opened 13 / merged 9 | **Median merge time (days):** 1.6
-- **Reviews (sampled):** median reviewers/PR **1** | reviewer diversity (eff.#) **1.0** | PR author diversity (eff.#) **1.0** | unique reviewers **1**, unique authors **1**
-- **Bus factor proxy (50% / 75%):** 1 / 1
+- **New contributors:** 11 | **Unique committers:** 19 | **Commits:** 537
+- **Issues:** opened 73 / closed 38
+- **PRs:** opened 232 / merged 159 | **Median merge time (days):** 0.1
+- **Reviews (sampled):** median reviewers/PR **0** | reviewer diversity (eff.#) **1.75** | PR author diversity (eff.#) **3.88** | unique reviewers **2**, unique authors **14**
+- **Bus factor proxy (50% / 75%):** 2 / 5
- **Incubator reports:** 4 | **Avg mentor sign-offs:** 2.25
-- **Mailing lists:** dev messages **795**, dev unique posters **24**
+- **Mailing lists:** dev messages **800**, dev unique posters **24**
## Reading the Indicators
- **Use trend arrows** to compare available windows; when a window lacks ≥28 days or ≥5 events, trends show **—**.
---------------------------------------------------------------------
To unsubscribe, e-mail: cvs-unsubscribe@incubator.apache.org
For additional commands, e-mail: cvs-help@incubator.apache.org