Files
modelscope--funasr/scripts/collect_growth_metrics.py
2026-07-13 13:25:10 +08:00

1012 lines
42 KiB
Python
Executable File

#!/usr/bin/env python3
"""Collect lightweight growth metrics for the FunASR repository.
The script uses only Python's standard library and public APIs by default.
Set GITHUB_TOKEN to raise GitHub API rate limits in CI or release workflows.
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Dict, Optional, Sequence
DEFAULT_REPO = "modelscope/FunASR"
DEFAULT_ECOSYSTEM_REPOS = [
"modelscope/FunASR",
"FunAudioLLM/Fun-ASR",
"FunAudioLLM/SenseVoice",
"modelscope/FunClip",
]
DEFAULT_PACKAGE = "funasr"
DEFAULT_BASELINE_STARS = 31224
DEFAULT_TARGET_ADDITIONAL_STARS = 20000
DEFAULT_TARGET_DATE = "2026-09-30"
DEFAULT_INTEGRATION_PRS = [
"huggingface/transformers#46180",
"sgl-project/sglang-omni#898",
"yuekaizhang/Fun-ASR-vllm#21",
"ray-project/ray#64053",
"huggingface/optimum-intel#1801",
"openvino-agent/optimum-intel#5",
"huggingface/speech-to-speech#319",
"livekit/agents#6176",
"mahimairaja/voiceai#16",
"punkpeye/awesome-mcp-servers#7153",
"run-llama/llama_index#21958",
"run-llama/llama_index#21996",
"mem0ai/mem0#5571",
"Significant-Gravitas/AutoGPT#13500",
"tmoroney/auto-subs#629",
"infiniflow/ragflow#16473",
"pipecat-ai/pipecat#4844",
"speaches-ai/speaches#658",
"mudler/LocalAI#10090",
"getpaseo/paseo#1634",
"agno-agi/agno#8501",
"GetStream/Vision-Agents#606",
"TEN-framework/ten-framework#2191",
"activepieces/activepieces#13985",
"Uberi/speech_recognition#903",
"ai4s-research/awesome-ai-for-science#69",
"lukasmasuch/best-of-ml-python#455",
"tensorchord/Awesome-LLMOps#533",
"rafska/awesome-local-llm#118",
"mahseema/awesome-ai-tools#1689",
"mahseema/awesome-ai-tools#1403",
"INTERMT/Awesome-PyTorch-Chinese#5",
"krzjoa/awesome-python-data-science#99",
"zzw922cn/awesome-speech-recognition-speech-synthesis-papers#27",
"Osmantic/ODS#1639",
"faroit/awesome-python-scientific-audio#85",
"joewongjc/type4me#207",
"ga642381/speech-trident#31",
"EmulationAI/awesome-large-audio-models#19",
"ddlBoJack/Awesome-Speech-Language-Model#6",
"LqNoob/Neural-Codec-and-Speech-Language-Models#4",
"PyTorchKR/oss-landscape#688",
"metame-ai/awesome-audio-plaza#10",
"vinta/awesome-python#3246",
"fighting41love/funNLP#478",
"josephmisiti/awesome-machine-learning#1339",
"RVC-Boss/GPT-SoVITS#2801",
"jobbole/awesome-python-cn#141",
"ChristosChristofidis/awesome-deep-learning#317",
"Hannibal046/Awesome-LLM#623",
"AiHubCN/Awesome-Chinese-LLM#103",
"pluja/awesome-privacy#836",
"BradyFU/Awesome-Multimodal-Large-Language-Models#280",
"mahmoud/awesome-python-applications#227",
"bharathgs/Awesome-pytorch-list#164",
"owainlewis/awesome-artificial-intelligence#243",
"steven2358/awesome-generative-ai#821",
"WangRongsheng/awesome-LLM-resources#162",
"crownpku/Awesome-Chinese-NLP#32",
]
FAILED_CHECK_CONCLUSIONS = {"action_required", "cancelled", "failure", "startup_failure", "timed_out"}
AGGREGATE_FAILURE_CHECK_NAMES = {"pr-ci / PR CI status"}
KNOWN_EXTERNAL_CHECK_FAILURES = {
"huggingface/transformers#46180": {
"failed_check_names": {"pr-ci / tests_processors / tests_processors [shard 3/8]"},
"aggregate_check_names": AGGREGATE_FAILURE_CHECK_NAMES,
"reason": "LightOnOCR shared hub-cache read-only failure; PR CI status is the aggregate failure",
"action": "wait for maintainer rerun",
},
"huggingface/optimum-intel#1801": {
"failed_check_names": {
"build (*seq2seq*, 4.57.6)",
"build (*quantization*, 4.57.6)",
"build (*quantization*, 4.45.0)",
"build (*quantization*, latest)",
"build (*export*, 4.57.6)",
"build (*export*, 4.45.0)",
"build (*modeling*, 4.57.6)",
"build (*export*, latest)",
"build",
"build (*quantization*)",
"build (*seq2seq*)",
},
"reason": "Current c70504e failures are unrelated OpenVINO matrix failures in Pix2Struct, image-text quantization/export, and tiny-random T5; direct cleanup mirror #1856 was closed for maintainer preliminary PR",
"action": "wait for maintainer preliminary PR",
}
}
KNOWN_REVIEW_GATES = {
"sgl-project/sglang-omni#898": {
"action": "wait for contributor conflict resolution",
"reason": "Contributor-owned branch is dirty; conflict recipe already posted from the FunASR side",
},
"punkpeye/awesome-mcp-servers#7153": {
"action": "submit Glama",
"reason": "Glama listing and score badge required before review",
},
"mem0ai/mem0#5571": {
"action": "wait for preview authorization",
"reason": "Vercel preview deployment requires Mem0 team authorization",
},
"Significant-Gravitas/AutoGPT#13500": {
"action": "wait for author CLA",
"reason": "CLA must be completed by an authorized author; Vercel preview also requires AutoGPT team authorization",
},
"TEN-framework/ten-framework#2191": {
"action": "wait for maintainer review",
"reason": "Claude review action requires maintainer permissions for fork PRs",
},
"mahimairaja/voiceai#16": {
"action": "wait for maintainer workflow approval",
"reason": "Maintainer approved; link-check fix and local lychee validation posted, but the new fork check suite is action_required with no check-runs",
},
"activepieces/activepieces#13985": {
"action": "wait for author CLA",
"reason": "CLA must be completed by an authorized author; do not sign legal agreements",
}
}
KNOWN_ASSISTED_REVIEW_REQUESTS = {
"infiniflow/ragflow#16473": {
"reason": "review evidence already posted; avoid duplicate pings",
},
"livekit/agents#6176": {
"reason": "review feedback addressed and checks are green; avoid duplicate pings",
},
"run-llama/llama_index#21958": {
"reason": "review evidence already posted; avoid duplicate pings",
},
"run-llama/llama_index#21996": {
"reason": "FunASR dependency gate addressed; avoid duplicate pings",
},
"mudler/LocalAI#10090": {
"reason": "review evidence already posted; avoid duplicate pings",
},
"getpaseo/paseo#1634": {
"reason": "SenseVoice local STT validation already posted; avoid duplicate pings",
},
"ray-project/ray#64053": {
"reason": "review evidence already posted; avoid duplicate pings",
},
"agno-agi/agno#8501": {
"reason": "review evidence already posted; avoid duplicate pings",
},
"lukasmasuch/best-of-ml-python#455": {
"reason": "review evidence already posted; avoid duplicate pings",
},
"tensorchord/Awesome-LLMOps#533": {
"reason": "DCO and link validation already posted; avoid duplicate pings",
},
"rafska/awesome-local-llm#118": {
"reason": "local ASR listing validation already posted; avoid duplicate pings",
},
"mahseema/awesome-ai-tools#1689": {
"reason": "review evidence already posted; avoid duplicate pings",
},
"mahseema/awesome-ai-tools#1403": {
"reason": "FunASR discovery-list evidence already posted; avoid duplicate pings",
},
"INTERMT/Awesome-PyTorch-Chinese#5": {
"reason": "FunASR discovery-list evidence already posted; avoid duplicate pings",
},
"krzjoa/awesome-python-data-science#99": {
"reason": "FunASR discovery-list evidence already posted; avoid duplicate pings",
},
"zzw922cn/awesome-speech-recognition-speech-synthesis-papers#27": {
"reason": "FunAudioLLM paper-list evidence already posted; avoid duplicate pings",
},
"Osmantic/ODS#1639": {
"reason": "SenseVoice/FunASR backend evidence already posted; avoid duplicate pings",
},
"faroit/awesome-python-scientific-audio#85": {
"reason": "FunASR scientific-audio listing evidence already posted; avoid duplicate pings",
},
"joewongjc/type4me#207": {
"reason": "draft validation status already posted; keep draft until Qwen3-only ASR smoke test",
},
"ga642381/speech-trident#31": {
"reason": "SenseVoice model-list evidence already posted; avoid duplicate pings",
},
"yuekaizhang/Fun-ASR-vllm#21": {
"reason": "Fun-ASR-vLLM dtype compatibility validation already posted; wait for maintainer review",
},
"openvino-agent/optimum-intel#5": {
"reason": "OpenVINO review-structure cleanup reference branch already posted; wait for maintainer guidance",
},
"EmulationAI/awesome-large-audio-models#19": {
"reason": "FunAudioLLM paper-list validation already posted; wait for maintainer review",
},
"ddlBoJack/Awesome-Speech-Language-Model#6": {
"reason": "Fun-ASR-Nano speech-language-model validation already posted; wait for maintainer review",
},
"LqNoob/Neural-Codec-and-Speech-Language-Models#4": {
"reason": "Fun-ASR-Nano and SenseVoice model-list validation already posted; wait for maintainer review",
},
"PyTorchKR/oss-landscape#688": {
"reason": "FunASR OSS landscape entry is clean and README-only; avoid status noise unless maintainers ask",
},
"metame-ai/awesome-audio-plaza#10": {
"reason": "FunASR, SenseVoice, and FunClip ASR-section PR already has prior pings; avoid duplicate comments",
},
"ai4s-research/awesome-ai-for-science#69": {
"reason": "review evidence already posted; avoid duplicate pings",
},
"tmoroney/auto-subs#629": {
"reason": "review evidence already posted; avoid duplicate pings",
},
"pipecat-ai/pipecat#4844": {
"reason": "review evidence already posted; avoid duplicate pings",
},
"GetStream/Vision-Agents#606": {
"reason": "review feedback addressed and checks are green; avoid duplicate pings",
},
"Uberi/speech_recognition#903": {
"reason": "review evidence already posted; no repo checks exposed",
},
"huggingface/speech-to-speech#319": {
"reason": "ruff blocker fixed and validation posted; no repo checks exposed",
},
"speaches-ai/speaches#658": {
"reason": "lightweight validation evidence posted; no repo checks exposed",
},
"vinta/awesome-python#3246": {
"reason": "high-star discovery-list PR already opened with project evidence; avoid duplicate pings",
},
"fighting41love/funNLP#478": {
"reason": "high-star discovery-list PR already opened with project evidence; avoid duplicate pings",
},
"josephmisiti/awesome-machine-learning#1339": {
"reason": "high-star discovery-list PR already opened with project evidence; avoid duplicate pings",
},
"RVC-Boss/GPT-SoVITS#2801": {
"reason": "Fun-ASR-Nano compatibility fix already opened with reproduction context; avoid duplicate pings",
},
"jobbole/awesome-python-cn#141": {
"reason": "high-star discovery-list PR already opened with project evidence; avoid duplicate pings",
},
"ChristosChristofidis/awesome-deep-learning#317": {
"reason": "high-star discovery-list PR already opened with project evidence; avoid duplicate pings",
},
"Hannibal046/Awesome-LLM#623": {
"reason": "high-star discovery-list PR already opened with project evidence; avoid duplicate pings",
},
"AiHubCN/Awesome-Chinese-LLM#103": {
"reason": "high-star discovery-list PR already opened with project evidence; avoid duplicate pings",
},
"pluja/awesome-privacy#836": {
"reason": "high-star discovery-list PR already opened with project evidence; avoid duplicate pings",
},
"BradyFU/Awesome-Multimodal-Large-Language-Models#280": {
"reason": "high-star discovery-list PR already opened with project evidence; avoid duplicate pings",
},
"mahmoud/awesome-python-applications#227": {
"reason": "high-star discovery-list PR already opened with project evidence; avoid duplicate pings",
},
"bharathgs/Awesome-pytorch-list#164": {
"reason": "high-star discovery-list PR already opened with project evidence; avoid duplicate pings",
},
"owainlewis/awesome-artificial-intelligence#243": {
"reason": "high-star discovery-list PR already opened with project evidence; avoid duplicate pings",
},
"steven2358/awesome-generative-ai#821": {
"reason": "high-star discovery-list PR already opened with project evidence; avoid duplicate pings",
},
"WangRongsheng/awesome-LLM-resources#162": {
"reason": "high-star discovery-list PR already opened with project evidence; avoid duplicate pings",
},
"crownpku/Awesome-Chinese-NLP#32": {
"reason": "high-star discovery-list PR already opened with project evidence; avoid duplicate pings",
},
}
REPORTER_WAITING_LABELS = {"needs feedback"}
CONTRIBUTOR_WAITING_LABELS = {"good first issue", "help wanted", "ready for PR"}
MANUAL_HANDOFF_ACTIONS = {
"submit Glama",
"wait for author CLA",
"wait for contributor conflict resolution",
"wait for preview authorization",
}
PASSIVE_INTEGRATION_ACTIONS = {
"archive",
"finish draft",
*MANUAL_HANDOFF_ACTIONS,
"preview auth gate",
"resolve CLA",
"wait for checks",
"wait for maintainer preliminary PR",
"wait for maintainer rerun",
"wait for maintainer review",
}
def fetch_json(url: str, headers: Optional[Dict[str, str]] = None) -> Any:
request = urllib.request.Request(url, headers=headers or {})
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"GET {url} failed with HTTP {exc.code}: {body}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"GET {url} failed: {exc.reason}") from exc
def github_headers() -> Dict[str, str]:
headers = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "funasr-growth-metrics",
}
token = os.environ.get("GITHUB_TOKEN")
if not token:
token_path = Path.home() / ".config" / "funasr-ops" / "github_token"
try:
token = token_path.read_text(encoding="utf-8").strip()
except OSError:
token = None
if not token:
try:
completed = subprocess.run(
["gh", "auth", "token"],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
timeout=5,
)
except (OSError, subprocess.SubprocessError):
token = None
else:
token = completed.stdout.strip()
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
def collect_github_repo_metrics(repo: str) -> Dict[str, Any]:
owner_repo = repo.strip("/")
github = fetch_json(f"https://api.github.com/repos/{owner_repo}", github_headers())
open_pull_requests = len(
fetch_json(f"https://api.github.com/repos/{owner_repo}/pulls?state=open&per_page=100", github_headers())
)
open_items = github.get("open_issues_count")
open_issues = max((open_items or 0) - open_pull_requests, 0) if open_items is not None else None
return {
"repo": owner_repo,
"stars": github.get("stargazers_count"),
"forks": github.get("forks_count"),
"watchers": github.get("subscribers_count"),
"open_items": open_items,
"open_issues": open_issues,
"open_pull_requests": open_pull_requests,
"default_branch": github.get("default_branch"),
"pushed_at": github.get("pushed_at"),
"html_url": github.get("html_url"),
}
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})
for status_context in status.get("statuses", []):
name = status_context.get("context")
url = status_context.get("target_url")
state = status_context.get("state")
if state in {"error", "failure"}:
failed_check_runs.append({"name": name, "conclusion": state, "url": url})
elif state == "pending":
pending_check_runs.append({"name": name, "status": state, "url": url})
status_state = status.get("state")
if failed_check_runs or status_state in {"error", "failure"}:
state = "failure"
elif pending_check_runs:
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 parse_github_datetime(value: Optional[str]) -> Optional[dt.datetime]:
if not value:
return None
normalized = value.replace("Z", "+00:00")
try:
parsed = dt.datetime.fromisoformat(normalized)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=dt.timezone.utc)
return parsed.astimezone(dt.timezone.utc)
def days_since(value: Optional[str], now: dt.datetime) -> Optional[int]:
parsed = parse_github_datetime(value)
if parsed is None:
return None
return max((now - parsed).days, 0)
def classify_known_external_failure(spec: str, checks: Dict[str, Any]) -> Optional[str]:
known_failure = KNOWN_EXTERNAL_CHECK_FAILURES.get(spec)
if not known_failure:
return None
failed_names = {
str(check.get("name") or "")
for check in checks.get("failed_check_runs") or []
if check.get("name")
}
if not failed_names:
return None
if checks.get("pending_check_runs"):
return None
direct_names = set(known_failure["failed_check_names"])
aggregate_names = set(known_failure.get("aggregate_check_names") or [])
if direct_names.issubset(failed_names) and failed_names.issubset(direct_names | aggregate_names):
return str(known_failure["reason"])
return None
def recommend_integration_action(
pull_request: Dict[str, Any],
checks: Dict[str, Any],
known_external_failure_reason: Optional[str] = None,
known_external_failure_action: Optional[str] = None,
known_review_gate_action: Optional[str] = None,
known_assisted_review_reason: Optional[str] = None,
) -> str:
if pull_request.get("state") != "open":
return "archive"
if pull_request.get("draft"):
return "finish draft"
mergeable_state = pull_request.get("mergeable_state")
if mergeable_state == "dirty" and known_review_gate_action:
return known_review_gate_action
if mergeable_state == "dirty":
return "resolve conflicts"
check_state = checks.get("state")
pending_names = " ".join(
str(check.get("name") or "") for check in checks.get("pending_check_runs") or []
).lower()
failed_names = " ".join(
str(check.get("name") or "") for check in checks.get("failed_check_runs") or []
).lower()
failed_urls = " ".join(
str(check.get("url") or "") for check in checks.get("failed_check_runs") or []
).lower()
if "cla" in pending_names and known_review_gate_action:
return known_review_gate_action
if "cla" in pending_names:
return "resolve CLA"
if known_review_gate_action and (
"claude-review" in failed_names or "review bot" in failed_names
):
return known_review_gate_action
if "claude-review" in failed_names or "review bot" in failed_names:
return "review bot gate"
if (
"vercel" in failed_names
and "vercel.com/git/authorize" in failed_urls
and known_review_gate_action
):
return known_review_gate_action
if "vercel" in failed_names and "vercel.com/git/authorize" in failed_urls:
return "preview auth gate"
if check_state == "failure" and known_external_failure_reason:
return known_external_failure_action or "request rerun"
if check_state == "failure":
return "fix checks"
if check_state == "pending":
return "wait for checks"
if known_review_gate_action:
return known_review_gate_action
if (
known_assisted_review_reason
and check_state in {"success", "unknown"}
and mergeable_state in {"blocked", "clean", "unknown", "unstable", None}
):
return "wait for maintainer review"
if check_state in {"success", "unknown"} and mergeable_state == "clean":
return "request review"
if check_state in {"success", "unknown"} and mergeable_state in {"blocked", "unstable"}:
return "review gate"
return "inspect"
def collect_pull_request_metrics(spec: str, now: Optional[dt.datetime] = None) -> Dict[str, Any]:
repo, pr_number = parse_pull_request_spec(spec)
now = now or dt.datetime.now(dt.timezone.utc)
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 {}
base_repo = base.get("repo") 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"}
known_external_failure_reason = classify_known_external_failure(spec, checks)
known_external_failure = KNOWN_EXTERNAL_CHECK_FAILURES.get(spec) or {}
known_external_failure_action = (
str(known_external_failure.get("action"))
if known_external_failure_reason and known_external_failure.get("action")
else None
)
known_review_gate = KNOWN_REVIEW_GATES.get(spec) or {}
known_assisted_review = KNOWN_ASSISTED_REVIEW_REQUESTS.get(spec) or {}
updated_at = pull_request.get("updated_at")
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"),
"repo_stars": base_repo.get("stargazers_count"),
"repo_forks": base_repo.get("forks_count"),
"updated_at": updated_at,
"updated_age_days": days_since(updated_at, now),
"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,
"known_external_failure_reason": known_external_failure_reason,
"known_external_failure_action": known_external_failure_action,
"known_review_gate_reason": known_review_gate.get("reason"),
"known_assisted_review_reason": known_assisted_review.get("reason"),
"next_action": recommend_integration_action(
pull_request,
checks,
known_external_failure_reason,
known_external_failure_action,
known_review_gate.get("action"),
known_assisted_review.get("reason"),
),
}
def collect_integration_metrics(prs: Sequence[str], now: Optional[dt.datetime] = None) -> Dict[str, Any]:
collected_at = (now or dt.datetime.now(dt.timezone.utc)).replace(microsecond=0)
return {
"collected_at_utc": collected_at.isoformat(),
"integrations": [collect_pull_request_metrics(pr, collected_at) for pr in prs],
}
def classify_issue_waiting_on(labels: Sequence[str]) -> str:
normalized = {label.lower() for label in labels}
if normalized & REPORTER_WAITING_LABELS:
return "reporter"
if normalized & CONTRIBUTOR_WAITING_LABELS:
return "contributor"
return "maintainer"
def collect_open_issues(repo: str) -> list[Dict[str, Any]]:
owner_repo = repo.strip("/")
payload = fetch_json(f"https://api.github.com/repos/{owner_repo}/issues?state=open&per_page=100", github_headers())
issues = []
for issue in payload:
if issue.get("pull_request"):
continue
labels = [label.get("name") for label in issue.get("labels", []) if label.get("name")]
author = issue.get("user") or {}
issues.append(
{
"repo": owner_repo,
"number": issue.get("number"),
"title": issue.get("title"),
"html_url": issue.get("html_url"),
"updated_at": issue.get("updated_at"),
"comments": issue.get("comments"),
"author": author.get("login"),
"labels": labels,
"waiting_on": classify_issue_waiting_on(labels),
}
)
return issues
def collect_issue_metrics(repos: Sequence[str]) -> Dict[str, Any]:
repositories = []
for repo in repos:
owner_repo = repo.strip("/")
issues = collect_open_issues(owner_repo)
repositories.append({"repo": owner_repo, "open_issue_count": len(issues), "open_issues": issues})
return {
"collected_at_utc": dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat(),
"repositories": repositories,
}
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"})
metrics = {
"collected_at_utc": dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat(),
"github": github,
"pypi": {
"package": package,
"version": pypi.get("info", {}).get("version"),
"summary": pypi.get("info", {}).get("summary"),
"project_url": pypi.get("info", {}).get("project_url"),
},
}
return metrics
def collect_ecosystem_metrics(
repos: Sequence[str],
package: str,
baseline_stars: int,
target_additional_stars: int,
target_date: str = DEFAULT_TARGET_DATE,
today: Optional[dt.date] = None,
) -> Dict[str, Any]:
repositories = [collect_github_repo_metrics(repo) for repo in repos]
pypi = fetch_json(f"https://pypi.org/pypi/{package}/json", {"User-Agent": "funasr-growth-metrics"})
total_stars = sum(repo.get("stars") or 0 for repo in repositories)
added_stars = total_stars - baseline_stars
remaining_to_target = max(target_additional_stars - added_stars, 0)
target_day = dt.date.fromisoformat(target_date)
current_day = today or dt.datetime.now(dt.timezone.utc).date()
days_remaining = max((target_day - current_day).days, 0)
required_daily_average = (
(remaining_to_target + days_remaining - 1) // days_remaining if days_remaining else remaining_to_target
)
return {
"collected_at_utc": dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat(),
"ecosystem": {
"repositories": repositories,
"total_stars": total_stars,
"baseline_stars": baseline_stars,
"target_additional_stars": target_additional_stars,
"added_stars": added_stars,
"remaining_to_target": remaining_to_target,
"target_date": target_date,
"days_remaining": days_remaining,
"required_daily_average": required_daily_average,
},
"pypi": {
"package": package,
"version": pypi.get("info", {}).get("version"),
"summary": pypi.get("info", {}).get("summary"),
"project_url": pypi.get("info", {}).get("project_url"),
},
}
def format_markdown(metrics: Dict[str, Any], star_goal: int) -> str:
github = metrics["github"]
pypi = metrics["pypi"]
stars = github.get("stars") or 0
remaining = max(star_goal - stars, 0)
lines = [
f"# FunASR Growth Snapshot ({metrics['collected_at_utc']})",
"",
f"- GitHub stars: **{stars:,}** / {star_goal:,} ({remaining:,} remaining)",
f"- GitHub forks: **{github.get('forks'):,}**" if github.get("forks") is not None else "- GitHub forks: n/a",
f"- GitHub watchers: **{github.get('watchers'):,}**" if github.get("watchers") is not None else "- GitHub watchers: n/a",
f"- Open issues: **{github.get('open_issues'):,}**" if github.get("open_issues") is not None else "- Open issues: n/a",
f"- Open pull requests: **{github.get('open_pull_requests'):,}**"
if github.get("open_pull_requests") is not None
else "- Open pull requests: n/a",
f"- PyPI package: **{pypi.get('package')} {pypi.get('version')}**",
f"- Last GitHub push: `{github.get('pushed_at')}`",
"",
"## Links",
"",
f"- Repository: {github.get('html_url')}",
f"- PyPI: {pypi.get('project_url')}",
"- GitHub Pages: https://modelscope.github.io/FunASR/",
"- Trendshift: https://trendshift.io/repositories/10479",
]
return "\n".join(lines) + "\n"
def format_ecosystem_markdown(metrics: Dict[str, Any]) -> str:
ecosystem = metrics["ecosystem"]
pypi = metrics["pypi"]
lines = [
f"# FunASR Ecosystem Growth Snapshot ({metrics['collected_at_utc']})",
"",
f"- Combined GitHub stars: **{ecosystem['total_stars']:,}**",
f"- Baseline stars: **{ecosystem['baseline_stars']:,}**",
f"- Added since baseline: **{ecosystem['added_stars']:,}** / {ecosystem['target_additional_stars']:,}",
f"- Remaining to target: **{ecosystem['remaining_to_target']:,}**",
f"- Target date: **{ecosystem['target_date']}** ({ecosystem['days_remaining']:,} days remaining)",
f"- Required daily average: **{ecosystem['required_daily_average']:,}** stars/day",
f"- PyPI package: **{pypi.get('package')} {pypi.get('version')}**",
"",
"## Repositories",
"",
"| Repository | Stars | Forks | Open issues | Open PRs | Last push |",
"|---|---:|---:|---:|---:|---|",
]
for repo in ecosystem["repositories"]:
lines.append(
f"| [{repo['repo']}]({repo.get('html_url')}) | "
f"{(repo.get('stars') or 0):,} | "
f"{(repo.get('forks') or 0):,} | "
f"{(repo.get('open_issues') or 0):,} | "
f"{(repo.get('open_pull_requests') or 0):,} | "
f"`{repo.get('pushed_at')}` |"
)
lines.extend(
[
"",
"## Links",
"",
f"- PyPI: {pypi.get('project_url')}",
"- GitHub Pages: https://modelscope.github.io/FunASR/",
"- Trendshift: https://trendshift.io/repositories/10479",
]
)
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 | Stars | Forks | State | Mergeable | Checks | Failed | Pending | Age | Action | 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 [])
updated_age_days = integration.get("updated_age_days")
age = f"{updated_age_days}d" if updated_age_days is not None else "n/a"
repo_stars = integration.get("repo_stars")
repo_stars = f"{repo_stars:,}" if repo_stars is not None else "n/a"
repo_forks = integration.get("repo_forks")
repo_forks = f"{repo_forks:,}" if repo_forks is not None else "n/a"
lines.append(
f"| [{integration['pr']}]({integration.get('html_url')}) | "
f"{repo_stars} | "
f"{repo_forks} | "
f"{integration.get('state')} | "
f"{integration.get('mergeable_state') or integration.get('mergeable')} | "
f"{checks.get('state')} | "
f"{failed_count:,} | "
f"{pending_count:,} | "
f"{age} | "
f"{integration.get('next_action') or 'inspect'} | "
f"`{integration.get('updated_at')}` |"
)
priority_integrations = sorted(
(
integration
for integration in metrics["integrations"]
if integration.get("state") == "open" and integration.get("repo_stars") is not None
),
key=lambda integration: int(integration.get("repo_stars") or 0),
reverse=True,
)
if priority_integrations:
lines.extend(["", "## High-exposure priorities", ""])
for integration in priority_integrations[:5]:
lines.append(
f"- [{integration['pr']}]({integration.get('html_url')}): "
f"{int(integration['repo_stars']):,} stars, "
f"{integration.get('next_action') or 'inspect'}"
)
active_operator_integrations = sorted(
(
integration
for integration in metrics["integrations"]
if integration.get("state") == "open"
and integration.get("repo_stars") is not None
and (integration.get("next_action") or "inspect") not in PASSIVE_INTEGRATION_ACTIONS
),
key=lambda integration: int(integration.get("repo_stars") or 0),
reverse=True,
)
if active_operator_integrations:
lines.extend(["", "## Active operator queue", ""])
for integration in active_operator_integrations:
lines.append(
f"- [{integration['pr']}]({integration.get('html_url')}): "
f"{integration.get('next_action') or 'inspect'}"
)
manual_handoff_integrations = sorted(
(
integration
for integration in metrics["integrations"]
if integration.get("state") == "open"
and (integration.get("next_action") or "inspect") in MANUAL_HANDOFF_ACTIONS
),
key=lambda integration: int(integration.get("repo_stars") or 0),
reverse=True,
)
if manual_handoff_integrations:
lines.extend(["", "## Manual handoff gates", ""])
for integration in manual_handoff_integrations:
reason = integration.get("known_review_gate_reason") or "manual action required"
lines.append(
f"- [{integration['pr']}]({integration.get('html_url')}): "
f"{integration.get('next_action') or 'inspect'}; {reason}"
)
review_gate_integrations = [
integration for integration in metrics["integrations"] if integration.get("known_review_gate_reason")
]
if review_gate_integrations:
lines.extend(["", "## Manual review gates", ""])
for integration in review_gate_integrations:
lines.append(
f"- [{integration['pr']}]({integration.get('html_url')}): "
f"{integration['known_review_gate_reason']}"
)
assisted_review_integrations = [
integration for integration in metrics["integrations"] if integration.get("known_assisted_review_reason")
]
if assisted_review_integrations:
lines.extend(["", "## Assisted review waits", ""])
for integration in assisted_review_integrations:
lines.append(
f"- [{integration['pr']}]({integration.get('html_url')}): "
f"{integration['known_assisted_review_reason']}"
)
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}")
if integration.get("known_external_failure_reason"):
lines.append(f"- Known external failure: {integration['known_external_failure_reason']}")
lines.append("")
if lines[-1] == "## Failed or pending checks":
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def format_issue_markdown(metrics: Dict[str, Any]) -> str:
lines = [
f"# FunASR Open Issue Snapshot ({metrics['collected_at_utc']})",
"",
"| Repository | Issue | Waiting on | Labels | Comments | Updated |",
"|---|---|---|---|---:|---|",
]
any_issue = False
for repository in metrics["repositories"]:
for issue in repository["open_issues"]:
any_issue = True
labels = ", ".join(issue.get("labels") or [])
lines.append(
f"| {repository['repo']} | "
f"[#{issue['number']} {issue.get('title')}]({issue.get('html_url')}) | "
f"{issue.get('waiting_on')} | "
f"{labels} | "
f"{issue.get('comments') or 0:,} | "
f"`{issue.get('updated_at')}` |"
)
if not any_issue:
lines.append("| n/a | No open issues | n/a | n/a | 0 | n/a |")
return "\n".join(lines) + "\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")
parser.add_argument(
"--repos",
nargs="+",
default=DEFAULT_ECOSYSTEM_REPOS,
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("--issues", action="store_true", help="Collect open issue triage status for repositories")
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")
parser.add_argument(
"--target-additional-stars",
type=int,
default=DEFAULT_TARGET_ADDITIONAL_STARS,
help="Ecosystem added-star target",
)
parser.add_argument("--target-date", default=DEFAULT_TARGET_DATE, help="Ecosystem target date in YYYY-MM-DD format")
parser.add_argument("--format", choices=["markdown", "json"], default="markdown", help="Output format")
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
if args.integrations:
metrics = collect_integration_metrics(args.integration_prs)
elif args.issues:
metrics = collect_issue_metrics(args.repos)
elif args.ecosystem:
metrics = collect_ecosystem_metrics(
args.repos,
args.pypi_package,
args.baseline_stars,
args.target_additional_stars,
args.target_date,
)
else:
metrics = collect_metrics(args.repo, args.pypi_package)
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.issues:
print(format_issue_markdown(metrics), end="")
elif args.ecosystem:
print(format_ecosystem_markdown(metrics), end="")
else:
print(format_markdown(metrics, args.star_goal), end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())