GRM-45: feat: add self-updating quality badges to README
This commit is contained in:
@@ -177,3 +177,28 @@ jobs:
|
||||
JOB_NAME: ${{ github.job }}
|
||||
MATRIX_INDEX: ${{ matrix.runner-index }}
|
||||
GITEA_REPOSITORY: ${{ github.repository }}
|
||||
|
||||
badges:
|
||||
needs: [quality]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
run: make setup
|
||||
- name: Generate badge JSON files
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 scripts/generate_badges.py --output-dir .badges/
|
||||
- name: Push badges to badges branch
|
||||
run: |
|
||||
git config user.name "gitea-actions-bot"
|
||||
git config user.email "actions@oblachno.fyi"
|
||||
git checkout --orphan badges
|
||||
git rm -rf .
|
||||
cp -r .badges/* .
|
||||
git add *.json
|
||||
git commit -m "Update badges [skip ci]"
|
||||
git push origin badges --force
|
||||
|
||||
@@ -35,3 +35,6 @@ bandit-report.*
|
||||
activate.sh
|
||||
activate.fish
|
||||
activate.zsh
|
||||
|
||||
# Generated badges (CI pushes to badges branch)
|
||||
.badges/
|
||||
|
||||
@@ -8,6 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ A lean command-line tool to automate the installation, configuration, and lifecy
|
||||
|
||||
> **Pronunciation:** GRM is short for *Gitea Runner Manager*, but say it like **ГРЪМ** (roughly "GRUM") — the Bulgarian word for **thunder**. An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
|
||||
## User Documentation
|
||||
|
||||
- [Getting Started](Getting-Started.-) — Installation, quick start, first run
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Unit tests for scripts/generate_badges.py."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.generate_badges import (
|
||||
cli,
|
||||
coverage_color,
|
||||
doc_coverage_color,
|
||||
extract_coverage,
|
||||
extract_doc_coverage,
|
||||
extract_test_count,
|
||||
generate_badges,
|
||||
make_badge,
|
||||
read_version,
|
||||
run_command,
|
||||
)
|
||||
|
||||
|
||||
class TestRunCommand:
|
||||
@patch("scripts.generate_badges.subprocess.run")
|
||||
def test_returns_returncode_stdout_stderr(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="out", stderr="err")
|
||||
rc, out, err = run_command(["echo", "hello"])
|
||||
assert rc == 0
|
||||
assert out == "out"
|
||||
assert err == "err"
|
||||
mock_run.assert_called_once()
|
||||
|
||||
|
||||
class TestMakeBadge:
|
||||
def test_creates_valid_badge_dict(self) -> None:
|
||||
badge = make_badge("coverage", "100%", "brightgreen")
|
||||
assert badge == {
|
||||
"schemaVersion": 1,
|
||||
"label": "coverage",
|
||||
"message": "100%",
|
||||
"color": "brightgreen",
|
||||
}
|
||||
|
||||
|
||||
class TestExtractCoverage:
|
||||
def test_extracts_from_total_line(self) -> None:
|
||||
output = (
|
||||
"src/gitea_runner_manager/cli.py 118 0 100%\n"
|
||||
"TOTAL 1798 0 100.00%\n"
|
||||
)
|
||||
assert extract_coverage(output) == 100.0
|
||||
|
||||
def test_extracts_partial_coverage(self) -> None:
|
||||
output = "TOTAL 100 20 80.00%\n"
|
||||
assert extract_coverage(output) == 80.0
|
||||
|
||||
def test_returns_none_when_no_match(self) -> None:
|
||||
assert extract_coverage("no coverage here") is None
|
||||
|
||||
|
||||
class TestExtractTestCount:
|
||||
def test_extracts_passed_count(self) -> None:
|
||||
assert extract_test_count("543 passed in 2.32s") == 543
|
||||
|
||||
def test_extracts_with_warnings(self) -> None:
|
||||
assert extract_test_count("543 passed, 1 warning in 2.32s") == 543
|
||||
|
||||
def test_returns_none_when_no_match(self) -> None:
|
||||
assert extract_test_count("no tests here") is None
|
||||
|
||||
|
||||
class TestExtractDocCoverage:
|
||||
def test_extracts_percentage(self) -> None:
|
||||
output = "\nDoc coverage: 20/20 (100%)"
|
||||
assert extract_doc_coverage(output) == 100
|
||||
|
||||
def test_extracts_partial(self) -> None:
|
||||
output = "\nDoc coverage: 18/20 (90%)"
|
||||
assert extract_doc_coverage(output) == 90
|
||||
|
||||
def test_returns_none_when_no_match(self) -> None:
|
||||
assert extract_doc_coverage("no doc coverage here") is None
|
||||
|
||||
|
||||
class TestCoverageColor:
|
||||
def test_100_is_brightgreen(self) -> None:
|
||||
assert coverage_color(100.0) == "brightgreen"
|
||||
|
||||
def test_90_is_green(self) -> None:
|
||||
assert coverage_color(90.0) == "green"
|
||||
|
||||
def test_80_is_yellowgreen(self) -> None:
|
||||
assert coverage_color(80.0) == "yellowgreen"
|
||||
|
||||
def test_70_is_yellow(self) -> None:
|
||||
assert coverage_color(70.0) == "yellow"
|
||||
|
||||
def test_60_is_orange(self) -> None:
|
||||
assert coverage_color(60.0) == "orange"
|
||||
|
||||
def test_below_60_is_red(self) -> None:
|
||||
assert coverage_color(50.0) == "red"
|
||||
|
||||
|
||||
class TestDocCoverageColor:
|
||||
def test_100_is_brightgreen(self) -> None:
|
||||
assert doc_coverage_color(100) == "brightgreen"
|
||||
|
||||
def test_90_is_green(self) -> None:
|
||||
assert doc_coverage_color(90) == "green"
|
||||
|
||||
def test_80_is_yellowgreen(self) -> None:
|
||||
assert doc_coverage_color(80) == "yellowgreen"
|
||||
|
||||
def test_70_is_yellow(self) -> None:
|
||||
assert doc_coverage_color(70) == "yellow"
|
||||
|
||||
def test_below_70_is_orange(self) -> None:
|
||||
assert doc_coverage_color(60) == "orange"
|
||||
|
||||
|
||||
class TestReadVersion:
|
||||
def test_reads_version_from_init(self) -> None:
|
||||
with patch("scripts.generate_badges.INIT_FILE") as mock_file:
|
||||
mock_file.read_text.return_value = '__version__ = "0.5.0"\n'
|
||||
assert read_version() == "0.5.0"
|
||||
|
||||
def test_returns_unknown_when_no_version(self) -> None:
|
||||
with patch("scripts.generate_badges.INIT_FILE") as mock_file:
|
||||
mock_file.read_text.return_value = "no version here\n"
|
||||
assert read_version() == "unknown"
|
||||
|
||||
|
||||
class TestGenerateBadges:
|
||||
@patch("scripts.generate_badges.run_command")
|
||||
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("scripts.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("scripts.generate_badges.extract_test_count", return_value=543)
|
||||
@patch("scripts.generate_badges.extract_doc_coverage", return_value=100)
|
||||
def test_generates_all_badge_files(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# run_command is called for pytest, doc_coverage, ruff, ruff format, pyright, bandit
|
||||
mock_run.return_value = (0, "output", "")
|
||||
badges = generate_badges(tmp_path)
|
||||
|
||||
# Verify all expected badges are generated
|
||||
expected = {"coverage", "tests", "docs", "quality", "version", "python"}
|
||||
assert set(badges.keys()) == expected
|
||||
|
||||
# Verify JSON files were written
|
||||
for name in expected:
|
||||
json_file = tmp_path / f"{name}.json"
|
||||
assert json_file.exists()
|
||||
data = json.loads(json_file.read_text())
|
||||
assert data["schemaVersion"] == 1
|
||||
assert "label" in data
|
||||
assert "message" in data
|
||||
assert "color" in data
|
||||
|
||||
@patch("scripts.generate_badges.run_command")
|
||||
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("scripts.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("scripts.generate_badges.extract_test_count", return_value=543)
|
||||
@patch("scripts.generate_badges.extract_doc_coverage", return_value=100)
|
||||
def test_quality_badge_pass_when_all_lint_passes(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.return_value = (0, "output", "")
|
||||
badges = generate_badges(tmp_path)
|
||||
assert badges["quality"]["message"] == "A"
|
||||
assert badges["quality"]["color"] == "brightgreen"
|
||||
|
||||
@patch("scripts.generate_badges.run_command")
|
||||
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("scripts.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("scripts.generate_badges.extract_test_count", return_value=543)
|
||||
@patch("scripts.generate_badges.extract_doc_coverage", return_value=100)
|
||||
def test_quality_badge_fails_when_lint_fails(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# First call (pytest) succeeds, second (doc_coverage) succeeds,
|
||||
# third (ruff check) fails
|
||||
mock_run.side_effect = [
|
||||
(0, "output", ""),
|
||||
(0, "output", ""),
|
||||
(1, "error", ""),
|
||||
(0, "output", ""),
|
||||
(0, "output", ""),
|
||||
(0, "output", ""),
|
||||
]
|
||||
badges = generate_badges(tmp_path)
|
||||
assert badges["quality"]["message"] == "F"
|
||||
assert badges["quality"]["color"] == "red"
|
||||
|
||||
@patch("scripts.generate_badges.run_command")
|
||||
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("scripts.generate_badges.extract_coverage", return_value=None)
|
||||
@patch("scripts.generate_badges.extract_test_count", return_value=None)
|
||||
@patch("scripts.generate_badges.extract_doc_coverage", return_value=None)
|
||||
def test_badges_show_unknown_when_extraction_fails(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.return_value = (1, "garbled output", "")
|
||||
badges = generate_badges(tmp_path)
|
||||
assert badges["coverage"]["message"] == "unknown"
|
||||
assert badges["coverage"]["color"] == "red"
|
||||
assert badges["tests"]["message"] == "unknown"
|
||||
assert badges["tests"]["color"] == "red"
|
||||
assert badges["docs"]["message"] == "unknown"
|
||||
assert badges["docs"]["color"] == "red"
|
||||
|
||||
|
||||
class TestCli:
|
||||
@patch("scripts.generate_badges.generate_badges")
|
||||
def test_cli_generates_badges(self, mock_gen: MagicMock, tmp_path: Path) -> None:
|
||||
mock_gen.return_value = {
|
||||
"coverage": make_badge("coverage", "100%", "brightgreen"),
|
||||
"tests": make_badge("tests", "543 passing", "brightgreen"),
|
||||
}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--output-dir", str(tmp_path)])
|
||||
assert result.exit_code == 0
|
||||
assert "Generating badges" in result.output
|
||||
assert "Generated 2 badges" in result.output
|
||||
mock_gen.assert_called_once_with(tmp_path)
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
import scripts.generate_badges as gb
|
||||
|
||||
with patch.object(gb, "cli") as mock_cli:
|
||||
with patch.object(gb, "__name__", "__main__"):
|
||||
gb.cli([])
|
||||
mock_cli.assert_called_once_with([])
|
||||
Reference in New Issue
Block a user