Two issues caused coverage and docs badges to show "unknown": 1. Coverage regex expected decimal (100.00%) but pytest-cov outputs 100% when coverage is exactly 100. Made decimal part optional. 2. doc_coverage.py passed pct="100%" to a template that already had %, producing (100%%). Removed the redundant % from the pct variable. Also relaxed the doc coverage regex to not require closing ). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
207 lines
6.1 KiB
Python
207 lines
6.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate shields.io endpoint badge JSON files from project metrics.
|
|
|
|
Runs pytest-cov, doc-coverage, lint checks, and version extraction,
|
|
then writes JSON files in the shields.io endpoint format:
|
|
|
|
{
|
|
"schemaVersion": 1,
|
|
"label": "coverage",
|
|
"message": "100%",
|
|
"color": "brightgreen"
|
|
}
|
|
|
|
Usage:
|
|
python3 scripts/generate_badges.py --output-dir .badges/
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import subprocess # nosec B404
|
|
from pathlib import Path
|
|
|
|
import click
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
INIT_FILE = REPO_ROOT / "src" / "gitea_runner_manager" / "__init__.py"
|
|
|
|
_COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%")
|
|
_PASSED_RE = re.compile(r"(\d+) passed")
|
|
_DOC_COVERAGE_RE = re.compile(r"Doc coverage:\s+\d+/\d+\s+\((\d+)%")
|
|
|
|
|
|
def make_badge(label: str, message: str, color: str) -> dict[str, str | int]:
|
|
"""Build a shields.io endpoint badge dict."""
|
|
return {
|
|
"schemaVersion": 1,
|
|
"label": label,
|
|
"message": message,
|
|
"color": color,
|
|
}
|
|
|
|
|
|
def run_command(cmd: list[str]) -> tuple[int, str, str]:
|
|
"""Run a command and return (returncode, stdout, stderr)."""
|
|
result = subprocess.run( # nosec B603
|
|
cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
cwd=str(REPO_ROOT),
|
|
)
|
|
return result.returncode, result.stdout, result.stderr
|
|
|
|
|
|
def extract_coverage(output: str) -> float | None:
|
|
"""Extract total coverage percentage from pytest-cov output."""
|
|
for line in output.splitlines():
|
|
match = _COVERAGE_RE.search(line)
|
|
if match:
|
|
return float(match.group(1))
|
|
return None
|
|
|
|
|
|
def extract_test_count(output: str) -> int | None:
|
|
"""Extract number of passed tests from pytest output."""
|
|
for line in output.splitlines():
|
|
match = _PASSED_RE.search(line)
|
|
if match:
|
|
return int(match.group(1))
|
|
return None
|
|
|
|
|
|
def extract_doc_coverage(output: str) -> int | None:
|
|
"""Extract doc coverage percentage from doc_coverage.py output."""
|
|
match = _DOC_COVERAGE_RE.search(output)
|
|
if match:
|
|
return int(match.group(1))
|
|
return None
|
|
|
|
|
|
def read_version() -> str:
|
|
"""Read __version__ from the package __init__.py."""
|
|
content = INIT_FILE.read_text()
|
|
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
|
|
if match:
|
|
return match.group(1)
|
|
return "unknown"
|
|
|
|
|
|
def coverage_color(pct: float) -> str:
|
|
"""Map coverage percentage to a color."""
|
|
if pct >= 100:
|
|
return "brightgreen"
|
|
if pct >= 90:
|
|
return "green"
|
|
if pct >= 80:
|
|
return "yellowgreen"
|
|
if pct >= 70:
|
|
return "yellow"
|
|
if pct >= 60:
|
|
return "orange"
|
|
return "red"
|
|
|
|
|
|
def doc_coverage_color(pct: int) -> str:
|
|
"""Map doc coverage percentage to a color."""
|
|
if pct >= 100:
|
|
return "brightgreen"
|
|
if pct >= 90:
|
|
return "green"
|
|
if pct >= 80:
|
|
return "yellowgreen"
|
|
if pct >= 70:
|
|
return "yellow"
|
|
return "orange"
|
|
|
|
|
|
def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]:
|
|
"""Generate all badge JSON files and return them as a dict."""
|
|
badges: dict[str, dict[str, str | int]] = {}
|
|
|
|
# 1. Code coverage + test count (single pytest-cov run)
|
|
rc, stdout, stderr = run_command(
|
|
[
|
|
".venv/bin/pytest",
|
|
"tests/",
|
|
"-v",
|
|
"--cov=src/gitea_runner_manager",
|
|
"--cov=scripts",
|
|
"--cov-report=term-missing",
|
|
"--cov-fail-under=0",
|
|
]
|
|
)
|
|
combined = stdout + "\n" + stderr
|
|
|
|
coverage = extract_coverage(combined)
|
|
if coverage is not None:
|
|
badges["coverage"] = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
|
|
else:
|
|
badges["coverage"] = make_badge("coverage", "unknown", "red")
|
|
|
|
test_count = extract_test_count(combined)
|
|
if test_count is not None:
|
|
badges["tests"] = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
|
|
else:
|
|
badges["tests"] = make_badge("tests", "unknown", "red")
|
|
|
|
# 2. Documentation coverage
|
|
rc, stdout, _ = run_command(
|
|
[
|
|
".venv/bin/python3",
|
|
"scripts/ci/doc_coverage.py",
|
|
]
|
|
)
|
|
doc_pct = extract_doc_coverage(stdout)
|
|
if doc_pct is not None:
|
|
badges["docs"] = make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
|
|
else:
|
|
badges["docs"] = make_badge("docs", "unknown", "red")
|
|
|
|
# 3. Code quality (ruff + pyright + bandit all pass)
|
|
lint_rc, _, _ = run_command([".venv/bin/ruff", "check", "src/", "tests/", "scripts/"])
|
|
format_rc, _, _ = run_command([".venv/bin/ruff", "format", "--check", "src/", "tests/", "scripts/"])
|
|
type_rc, _, _ = run_command([".venv/bin/pyright"])
|
|
bandit_rc, _, _ = run_command([".venv/bin/bandit", "-r", "src/", "scripts/"])
|
|
|
|
all_pass = all(rc == 0 for rc in [lint_rc, format_rc, type_rc, bandit_rc])
|
|
badges["quality"] = make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
|
|
|
|
# 4. Version
|
|
version = read_version()
|
|
badges["version"] = make_badge("version", f"v{version}", "blue")
|
|
|
|
# 5. Python version (static but nice)
|
|
badges["python"] = make_badge("python", "3.12", "blue")
|
|
|
|
# Write JSON files
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
for name, badge in badges.items():
|
|
path = output_dir / f"{name}.json"
|
|
path.write_text(json.dumps(badge, indent=2) + "\n")
|
|
click.echo(f" Generated: {path}")
|
|
|
|
return badges
|
|
|
|
|
|
@click.command()
|
|
@click.option(
|
|
"--output-dir",
|
|
default=str(REPO_ROOT / ".badges"),
|
|
help="Directory to write badge JSON files.",
|
|
)
|
|
def cli(output_dir: str) -> None:
|
|
"""Generate shields.io endpoint badge JSON files."""
|
|
out = Path(output_dir)
|
|
click.echo(f"Generating badges in {out}...")
|
|
badges = generate_badges(out)
|
|
click.echo(f"\nGenerated {len(badges)} badges:")
|
|
for name, badge in badges.items():
|
|
click.echo(f" {name}: {badge['label']}={badge['message']} ({badge['color']})")
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
cli() # pragma: no cover
|