From 54a584d60938650efe3801cdfb50b2434a52a6c7 Mon Sep 17 00:00:00 2001 From: emil Date: Sun, 21 Jun 2026 22:30:05 +0000 Subject: [PATCH] GRM-45: feat: add self-updating quality badges to README --- .gitea/workflows/ci.yml | 25 +++ .gitignore | 3 + README.md | 6 + docs/index.md | 7 + scripts/generate_badges.py | 206 +++++++++++++++++++++++ tests/unit/test_generate_badges.py | 259 +++++++++++++++++++++++++++++ 6 files changed, 506 insertions(+) create mode 100644 scripts/generate_badges.py create mode 100644 tests/unit/test_generate_badges.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index ed9076b..383cc6c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 93637ea..9e952f6 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ bandit-report.* activate.sh activate.fish activate.zsh + +# Generated badges (CI pushes to badges branch) +.badges/ diff --git a/README.md b/README.md index 47af680..63937ee 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate [![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE) +[![Coverage](https://img.shields.io/endpoint?url=https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/coverage.json)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) +[![Tests](https://img.shields.io/endpoint?url=https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/tests.json)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) +[![Docs](https://img.shields.io/endpoint?url=https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/docs.json)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki) +[![Code Quality](https://img.shields.io/endpoint?url=https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/quality.json)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) +[![Version](https://img.shields.io/endpoint?url=https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/version.json)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases) +[![Python](https://img.shields.io/endpoint?url=https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/python.json)](https://www.python.org/downloads/) ## Quick Start diff --git a/docs/index.md b/docs/index.md index a222fa0..ec489d4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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). +[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) +[![Coverage](https://img.shields.io/endpoint?url=https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/coverage.json)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) +[![Tests](https://img.shields.io/endpoint?url=https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/tests.json)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) +[![Docs](https://img.shields.io/endpoint?url=https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/docs.json)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki) +[![Code Quality](https://img.shields.io/endpoint?url=https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/quality.json)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) +[![Version](https://img.shields.io/endpoint?url=https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/version.json)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases) + ## User Documentation - [Getting Started](Getting-Started.-) — Installation, quick start, first run diff --git a/scripts/generate_badges.py b/scripts/generate_badges.py new file mode 100644 index 0000000..ef589e1 --- /dev/null +++ b/scripts/generate_badges.py @@ -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 diff --git a/tests/unit/test_generate_badges.py b/tests/unit/test_generate_badges.py new file mode 100644 index 0000000..924dd63 --- /dev/null +++ b/tests/unit/test_generate_badges.py @@ -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([])