diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 94d6f8e..2cd28f5 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -202,6 +202,6 @@ jobs: git checkout --orphan badges git rm -rf . cp -r .badges/* . - git add *.json + git add *.svg git commit --no-verify -m "Update badges [skip ci]" git push origin badges --force diff --git a/README.md b/README.md index 63937ee..9c75a9b 100644 --- a/README.md +++ b/README.md @@ -8,12 +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/) +[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) +[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) +[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki) +[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) +[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases) +[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/python.svg)](https://www.python.org/downloads/) ## Quick Start diff --git a/docs/index.md b/docs/index.md index ec489d4..2142953 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,11 +5,11 @@ 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) +[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) +[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) +[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki) +[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) +[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases) ## User Documentation diff --git a/scripts/generate_badges.py b/scripts/generate_badges.py index e5e89a1..91ad06f 100644 --- a/scripts/generate_badges.py +++ b/scripts/generate_badges.py @@ -1,15 +1,9 @@ #!/usr/bin/env python3 -"""Generate shields.io endpoint badge JSON files from project metrics. +"""Generate self-contained SVG badge 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" - } +then writes SVG badge files that can be served as static files from +the Gitea raw file API. Usage: python3 scripts/generate_badges.py --output-dir .badges/ @@ -17,7 +11,6 @@ Usage: from __future__ import annotations -import json import re import subprocess # nosec B404 from pathlib import Path @@ -31,15 +24,22 @@ _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+)%") +# shields.io color names to hex values +COLOR_HEX: dict[str, str] = { + "brightgreen": "#4c1", + "green": "#97ca00", + "yellowgreen": "#a4a61d", + "yellow": "#dfb317", + "orange": "#fe7d37", + "red": "#e05d44", + "blue": "#007ec6", + "lightgrey": "#9f9f9f", +} -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 _xml_escape(text: str) -> str: + """Escape XML special characters.""" + return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) def run_command(cmd: list[str]) -> tuple[int, str, str]: @@ -54,6 +54,50 @@ def run_command(cmd: list[str]) -> tuple[int, str, str]: return result.returncode, result.stdout, result.stderr +def make_badge(label: str, message: str, color: str) -> dict[str, str | int]: + """Build a badge data dict.""" + return { + "schemaVersion": 1, + "label": label, + "message": message, + "color": color, + } + + +def render_svg(label: str, message: str, color: str) -> str: + """Render a shields.io-style SVG badge.""" + color_hex = COLOR_HEX.get(color, color if color.startswith("#") else "#9f9f9f") + + # Approximate text width: 7px per character + 10px padding + label_text = _xml_escape(label) + message_text = _xml_escape(message) + label_w = max(len(label) * 7 + 10, 30) + message_w = max(len(message) * 7 + 10, 30) + total_w = label_w + message_w + + return f''' + {label_text}: {message_text} + + + + + + + + + + + + + + {label_text} + {message_text} + + +''' + + def extract_coverage(output: str) -> float | None: """Extract total coverage percentage from pytest-cov output.""" for line in output.splitlines(): @@ -118,7 +162,7 @@ def doc_coverage_color(pct: int) -> str: def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]: - """Generate all badge JSON files and return them as a dict.""" + """Generate all badge SVG files and return badge data as a dict.""" badges: dict[str, dict[str, str | int]] = {} # 1. Code coverage + test count (single pytest-cov run) @@ -176,11 +220,12 @@ def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]: # 5. Python version (static but nice) badges["python"] = make_badge("python", "3.12", "blue") - # Write JSON files + # Write SVG 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") + svg = render_svg(str(badge["label"]), str(badge["message"]), str(badge["color"])) + path = output_dir / f"{name}.svg" + path.write_text(svg) click.echo(f" Generated: {path}") return badges @@ -190,10 +235,10 @@ def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]: @click.option( "--output-dir", default=str(REPO_ROOT / ".badges"), - help="Directory to write badge JSON files.", + help="Directory to write badge SVG files.", ) def cli(output_dir: str) -> None: - """Generate shields.io endpoint badge JSON files.""" + """Generate self-contained SVG badge files from project metrics.""" out = Path(output_dir) click.echo(f"Generating badges in {out}...") badges = generate_badges(out) diff --git a/tests/unit/test_generate_badges.py b/tests/unit/test_generate_badges.py index 924dd63..4968c78 100644 --- a/tests/unit/test_generate_badges.py +++ b/tests/unit/test_generate_badges.py @@ -1,12 +1,12 @@ """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 ( + COLOR_HEX, cli, coverage_color, doc_coverage_color, @@ -16,6 +16,7 @@ from scripts.generate_badges import ( generate_badges, make_badge, read_version, + render_svg, run_command, ) @@ -42,6 +43,38 @@ class TestMakeBadge: } +class TestRenderSvg: + def test_generates_valid_svg(self) -> None: + svg = render_svg("coverage", "100%", "brightgreen") + assert svg.startswith("\n") + assert "coverage" in svg + assert "100%" in svg + assert COLOR_HEX["brightgreen"] in svg + + def test_uses_color_hex_for_known_colors(self) -> None: + svg = render_svg("tests", "573 passing", "brightgreen") + assert "#4c1" in svg + + def test_uses_hex_directly_for_unknown_hex_color(self) -> None: + svg = render_svg("label", "msg", "#abc123") + assert "#abc123" in svg + + def test_uses_lightgrey_for_unknown_named_color(self) -> None: + svg = render_svg("label", "msg", "nonexistent") + assert "#9f9f9f" in svg + + def test_escapes_xml_special_chars(self) -> None: + svg = render_svg("label", "