diff --git a/docs/community_growth_20k.md b/docs/community_growth_20k.md index 20285e93f..6c190c841 100644 --- a/docs/community_growth_20k.md +++ b/docs/community_growth_20k.md @@ -110,11 +110,12 @@ High-visibility external integrations can create more qualified traffic than a o Refresh this table before weekly planning and after any reviewer or CI change: ```bash -gh pr view 46180 -R huggingface/transformers --json state,mergeable,reviewDecision,statusCheckRollup,updatedAt,url -gh pr view 64053 -R ray-project/ray --json state,mergeable,reviewDecision,statusCheckRollup,updatedAt,url -gh pr view 1801 -R huggingface/optimum-intel --json state,mergeable,reviewDecision,statusCheckRollup,updatedAt,url +python scripts/collect_growth_metrics.py --integrations +python scripts/collect_growth_metrics.py --integrations --format json ``` +Set `GITHUB_TOKEN` when running this from CI or a shared network so GitHub's public API rate limit does not hide the real PR state. + | Integration PR | Growth reason | Current maintainer action | |---|---|---| | `huggingface/transformers#46180` Fun-ASR-Nano model support | Makes Fun-ASR-Nano usable through the default HF API surface and model docs | Keep CI evidence current, address only actionable review threads, and avoid repeating maintainer pings unless new evidence appears. If `tests_processors` fails only in unrelated LightOnOCR cache setup, ask for a rerun once with the exact failing job. | diff --git a/scripts/collect_growth_metrics.py b/scripts/collect_growth_metrics.py index 2693b5828..889c053bd 100755 --- a/scripts/collect_growth_metrics.py +++ b/scripts/collect_growth_metrics.py @@ -27,6 +27,12 @@ DEFAULT_PACKAGE = "funasr" DEFAULT_BASELINE_STARS = 31224 DEFAULT_TARGET_ADDITIONAL_STARS = 20000 DEFAULT_TARGET_DATE = "2026-09-30" +DEFAULT_INTEGRATION_PRS = [ + "huggingface/transformers#46180", + "ray-project/ray#64053", + "huggingface/optimum-intel#1801", +] +FAILED_CHECK_CONCLUSIONS = {"action_required", "cancelled", "failure", "startup_failure", "timed_out"} def fetch_json(url: str, headers: Optional[Dict[str, str]] = None) -> Any: @@ -68,6 +74,90 @@ def collect_github_repo_metrics(repo: str) -> Dict[str, Any]: } +def parse_pull_request_spec(spec: str) -> tuple[str, int]: + if "#" not in spec: + raise ValueError(f"pull request spec must look like owner/repo#number: {spec}") + repo, number = spec.split("#", 1) + repo = repo.strip("/") + try: + pr_number = int(number) + except ValueError as exc: + raise ValueError(f"pull request number must be an integer: {spec}") from exc + if repo.count("/") != 1 or pr_number <= 0: + raise ValueError(f"pull request spec must look like owner/repo#number: {spec}") + return repo, pr_number + + +def summarize_commit_checks(repo: str, head_sha: str) -> Dict[str, Any]: + status = fetch_json(f"https://api.github.com/repos/{repo}/commits/{head_sha}/status", github_headers()) + check_runs_payload = fetch_json( + f"https://api.github.com/repos/{repo}/commits/{head_sha}/check-runs?per_page=100", + github_headers(), + ) + check_runs = check_runs_payload.get("check_runs", []) + failed_check_runs = [] + pending_check_runs = [] + for check_run in check_runs: + name = check_run.get("name") + url = check_run.get("html_url") or check_run.get("details_url") + if check_run.get("status") != "completed": + pending_check_runs.append({"name": name, "status": check_run.get("status"), "url": url}) + elif check_run.get("conclusion") in FAILED_CHECK_CONCLUSIONS: + failed_check_runs.append({"name": name, "conclusion": check_run.get("conclusion"), "url": url}) + + status_state = status.get("state") + if failed_check_runs or status_state in {"error", "failure"}: + state = "failure" + elif pending_check_runs or status_state == "pending": + state = "pending" + elif check_runs or status.get("statuses"): + state = "success" + else: + state = "unknown" + + return { + "state": state, + "commit_status_state": status_state, + "total_check_runs": check_runs_payload.get("total_count", len(check_runs)), + "failed_check_runs": failed_check_runs, + "pending_check_runs": pending_check_runs, + } + + +def collect_pull_request_metrics(spec: str) -> Dict[str, Any]: + repo, pr_number = parse_pull_request_spec(spec) + pull_request = fetch_json(f"https://api.github.com/repos/{repo}/pulls/{pr_number}", github_headers()) + head = pull_request.get("head") or {} + base = pull_request.get("base") or {} + author = pull_request.get("user") or {} + head_sha = head.get("sha") + checks = summarize_commit_checks(repo, head_sha) if head_sha else {"state": "unknown"} + return { + "pr": f"{repo}#{pr_number}", + "repo": repo, + "number": pr_number, + "title": pull_request.get("title"), + "state": pull_request.get("state"), + "draft": pull_request.get("draft"), + "mergeable": pull_request.get("mergeable"), + "mergeable_state": pull_request.get("mergeable_state"), + "updated_at": pull_request.get("updated_at"), + "html_url": pull_request.get("html_url"), + "head_ref": head.get("ref"), + "head_sha": head_sha, + "base_ref": base.get("ref"), + "author": author.get("login"), + "checks": checks, + } + + +def collect_integration_metrics(prs: Sequence[str]) -> Dict[str, Any]: + return { + "collected_at_utc": dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat(), + "integrations": [collect_pull_request_metrics(pr) for pr in prs], + } + + def collect_metrics(repo: str, package: str) -> Dict[str, Any]: github = collect_github_repo_metrics(repo) pypi = fetch_json(f"https://pypi.org/pypi/{package}/json", {"User-Agent": "funasr-growth-metrics"}) @@ -190,6 +280,48 @@ def format_ecosystem_markdown(metrics: Dict[str, Any]) -> str: return "\n".join(lines) + "\n" +def format_integration_markdown(metrics: Dict[str, Any]) -> str: + lines = [ + f"# FunASR External Integration Snapshot ({metrics['collected_at_utc']})", + "", + "| Pull request | State | Mergeable | Checks | Failed | Pending | Updated |", + "|---|---|---|---|---:|---:|---|", + ] + for integration in metrics["integrations"]: + checks = integration.get("checks", {}) + failed_count = len(checks.get("failed_check_runs") or []) + pending_count = len(checks.get("pending_check_runs") or []) + lines.append( + f"| [{integration['pr']}]({integration.get('html_url')}) | " + f"{integration.get('state')} | " + f"{integration.get('mergeable_state') or integration.get('mergeable')} | " + f"{checks.get('state')} | " + f"{failed_count:,} | " + f"{pending_count:,} | " + f"`{integration.get('updated_at')}` |" + ) + lines.extend(["", "## Failed or pending checks", ""]) + for integration in metrics["integrations"]: + checks = integration.get("checks", {}) + failed = checks.get("failed_check_runs") or [] + pending = checks.get("pending_check_runs") or [] + if not failed and not pending: + continue + lines.append(f"### {integration['pr']}") + for check in failed: + url = check.get("url") + suffix = f" ({url})" if url else "" + lines.append(f"- Failed: {check.get('name')} [{check.get('conclusion')}]{suffix}") + for check in pending: + url = check.get("url") + suffix = f" ({url})" if url else "" + lines.append(f"- Pending: {check.get('name')} [{check.get('status')}]{suffix}") + lines.append("") + if lines[-1] == "## Failed or pending checks": + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Collect FunASR growth metrics.") parser.add_argument("--repo", default=DEFAULT_REPO, help="GitHub repository, e.g. modelscope/FunASR") @@ -200,6 +332,13 @@ def parse_args() -> argparse.Namespace: help="GitHub repositories for --ecosystem mode", ) parser.add_argument("--ecosystem", action="store_true", help="Collect the four-repository FunASR ecosystem") + parser.add_argument("--integrations", action="store_true", help="Collect tracked external integration PRs") + parser.add_argument( + "--integration-prs", + nargs="+", + default=DEFAULT_INTEGRATION_PRS, + help="GitHub pull requests for --integrations mode, e.g. owner/repo#123", + ) parser.add_argument("--pypi-package", default=DEFAULT_PACKAGE, help="PyPI package name") parser.add_argument("--star-goal", type=int, default=20000, help="Target GitHub star count") parser.add_argument("--baseline-stars", type=int, default=DEFAULT_BASELINE_STARS, help="Ecosystem baseline stars") @@ -217,7 +356,9 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() try: - if args.ecosystem: + if args.integrations: + metrics = collect_integration_metrics(args.integration_prs) + elif args.ecosystem: metrics = collect_ecosystem_metrics( args.repos, args.pypi_package, @@ -227,12 +368,14 @@ def main() -> int: ) else: metrics = collect_metrics(args.repo, args.pypi_package) - except RuntimeError as exc: + except (RuntimeError, ValueError) as exc: print(exc, file=sys.stderr) return 1 if args.format == "json": print(json.dumps(metrics, ensure_ascii=False, indent=2)) + elif args.integrations: + print(format_integration_markdown(metrics), end="") elif args.ecosystem: print(format_ecosystem_markdown(metrics), end="") else: diff --git a/tests/test_collect_growth_metrics.py b/tests/test_collect_growth_metrics.py index 05e6942a0..9522d9068 100644 --- a/tests/test_collect_growth_metrics.py +++ b/tests/test_collect_growth_metrics.py @@ -127,6 +127,64 @@ def test_collect_ecosystem_metrics_calculates_daily_target(monkeypatch): assert metrics["ecosystem"]["required_daily_average"] == 181 +def test_collect_integration_metrics_summarizes_pull_request_checks(monkeypatch): + module = load_growth_metrics_module() + + def fake_fetch_json(url, headers=None): + if url == "https://api.github.com/repos/huggingface/transformers/pulls/46180": + return { + "number": 46180, + "title": "Add Fun-ASR-Nano model", + "state": "open", + "draft": False, + "mergeable": True, + "mergeable_state": "clean", + "html_url": "https://github.com/huggingface/transformers/pull/46180", + "updated_at": "2026-06-29T23:45:57Z", + "head": {"sha": "8a744ec", "ref": "add-fun-asr-nano"}, + "base": {"ref": "main"}, + "user": {"login": "LauraGPT"}, + } + if url == "https://api.github.com/repos/huggingface/transformers/commits/8a744ec/status": + return {"state": "success", "statuses": []} + if url == "https://api.github.com/repos/huggingface/transformers/commits/8a744ec/check-runs?per_page=100": + return { + "total_count": 3, + "check_runs": [ + {"name": "pr-ci / Check code quality", "status": "completed", "conclusion": "success"}, + { + "name": "pr-ci / tests_processors / tests_processors [shard 3/8]", + "status": "completed", + "conclusion": "failure", + "html_url": "https://github.com/huggingface/transformers/actions/runs/1/job/2", + }, + {"name": "pr-ci / slow tests", "status": "queued", "conclusion": None}, + ], + } + raise AssertionError(f"unexpected URL: {url}") + + monkeypatch.setattr(module, "fetch_json", fake_fetch_json) + + metrics = module.collect_integration_metrics(["huggingface/transformers#46180"]) + + integration = metrics["integrations"][0] + assert integration["pr"] == "huggingface/transformers#46180" + assert integration["title"] == "Add Fun-ASR-Nano model" + assert integration["mergeable"] is True + assert integration["checks"]["state"] == "failure" + assert integration["checks"]["total_check_runs"] == 3 + assert integration["checks"]["failed_check_runs"] == [ + { + "name": "pr-ci / tests_processors / tests_processors [shard 3/8]", + "conclusion": "failure", + "url": "https://github.com/huggingface/transformers/actions/runs/1/job/2", + } + ] + assert integration["checks"]["pending_check_runs"] == [ + {"name": "pr-ci / slow tests", "status": "queued", "url": None} + ] + + def test_main_outputs_ecosystem_json(monkeypatch): module = load_growth_metrics_module() @@ -175,3 +233,55 @@ def test_main_outputs_ecosystem_json(monkeypatch): payload = json.loads(stdout.getvalue()) assert payload["ecosystem"]["total_stars"] == 34622 assert payload["ecosystem"]["remaining_to_target"] == 16602 + + +def test_main_outputs_integrations_json(monkeypatch): + module = load_growth_metrics_module() + + def fake_fetch_json(url, headers=None): + if url == "https://api.github.com/repos/ray-project/ray/pulls/64053": + return { + "number": 64053, + "title": "docs(serve): add FunASR ASR integration example", + "state": "open", + "draft": False, + "mergeable": True, + "mergeable_state": "clean", + "html_url": "https://github.com/ray-project/ray/pull/64053", + "updated_at": "2026-06-30T01:53:52Z", + "head": {"sha": "780eb4c", "ref": "issue-64052"}, + "base": {"ref": "master"}, + "user": {"login": "nh-atuan"}, + } + if url == "https://api.github.com/repos/ray-project/ray/commits/780eb4c/status": + return {"state": "success", "statuses": [{"context": "buildkite/microcheck", "state": "success"}]} + if url == "https://api.github.com/repos/ray-project/ray/commits/780eb4c/check-runs?per_page=100": + return { + "total_count": 1, + "check_runs": [ + {"name": "disable-automerge", "status": "completed", "conclusion": "success"}, + ], + } + raise AssertionError(f"unexpected URL: {url}") + + monkeypatch.setattr(module, "fetch_json", fake_fetch_json) + monkeypatch.setattr( + sys, + "argv", + [ + "collect_growth_metrics.py", + "--integrations", + "--integration-prs", + "ray-project/ray#64053", + "--format", + "json", + ], + ) + + with redirect_stdout(io.StringIO()) as stdout: + assert module.main() == 0 + + payload = json.loads(stdout.getvalue()) + integration = payload["integrations"][0] + assert integration["pr"] == "ray-project/ray#64053" + assert integration["checks"]["state"] == "success"