Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,21 @@ Mutmut keeps the data of what it has done and the mutants in the `mutants/`
directory. If you want to make sure you run a full mutmut run you can delete
this directory to start from scratch.


Mutation score badges
---------------------

`mutmut badge` turns `mutmut export-cicd-stats` output into `Shields endpoint JSON <https://shields.io/badges/endpoint-badge>`_:

.. code-block:: console

mutmut export-cicd-stats
mutmut badge --output mutation-score.json

.. code-block:: md

![mutation](https://img.shields.io/endpoint?url=https://example.com/mutation-score.json)

Contributing to Mutmut
----------------------

Expand Down
43 changes: 43 additions & 0 deletions src/mutmut/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from abc import ABC
from collections import defaultdict
from collections.abc import Callable
from colorsys import hls_to_rgb
from dataclasses import dataclass
from dataclasses import field
from datetime import datetime
Expand Down Expand Up @@ -1191,6 +1192,13 @@ def save_cicd_stats(source_file_mutation_data_by_path: dict[str, SourceFileMutat
)


def mutation_score_to_hex_color(score: float) -> str:
clamped_score = max(0.0, min(100.0, score))
hue = (clamped_score / 100.0) * (120.0 / 360.0)
red, green, blue = hls_to_rgb(hue, 0.5, 1.0)
return f"#{round(red * 255):02x}{round(green * 255):02x}{round(blue * 255):02x}"


# exports CI/CD stats to block pull requests from merging if mutation score is too low, or used in other ways in CI/CD pipelines
@cli.command()
def export_cicd_stats() -> None:
Expand Down Expand Up @@ -1218,6 +1226,41 @@ def export_cicd_stats() -> None:
print("Saved CI/CD stats to mutants/mutmut-cicd-stats.json")


@cli.command()
@click.option(
"--input",
"input_path",
default="mutants/mutmut-cicd-stats.json",
show_default=True,
type=click.Path(exists=True, dir_okay=False, path_type=Path),
)
@click.option("--output", required=True, type=click.Path(dir_okay=False, path_type=Path))
@click.option("--label", default="mutation", show_default=True)
def badge(input_path: Path, output: Path, label: str) -> None:
try:
with input_path.open() as f:
stats = json.load(f)
except JSONDecodeError as e:
raise click.ClickException(f"{input_path} does not contain valid JSON") from e

tested = int(stats.get("total", 0)) - int(stats.get("skipped", 0))
score = 0.0 if tested <= 0 else ((int(stats.get("killed", 0)) + int(stats.get("timeout", 0))) / tested) * 100
output.parent.mkdir(parents=True, exist_ok=True)
with output.open("w") as f:
json.dump(
{
"schemaVersion": 1,
"label": label,
"message": f"{score:.1f}%",
"color": mutation_score_to_hex_color(score),
},
f,
indent=4,
)
f.write("\n")
print(f"Saved mutation badge to {output}")


def collect_source_file_mutation_data(
*, mutant_names: tuple[str, ...] | list[str]
) -> tuple[
Expand Down
37 changes: 37 additions & 0 deletions tests/e2e/test_cli_version.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,48 @@
import json
from pathlib import Path

from click.testing import CliRunner

from mutmut import __version__
from mutmut.__main__ import cli
from mutmut.__main__ import mutation_score_to_hex_color


def test_cli_version():
result = CliRunner().invoke(cli, ["--version"])

assert result.exit_code == 0
assert __version__ in result.output


def test_mutation_score_to_hex_color_gradient_endpoints():
assert mutation_score_to_hex_color(0.0) == "#ff0000"
assert mutation_score_to_hex_color(100.0) == "#00ff00"


def test_badge_command_writes_shields_json(tmp_path: Path):
stats_path = tmp_path / "mutants" / "mutmut-cicd-stats.json"
badge_path = tmp_path / "artifacts" / "mutation-score.json"
stats_path.parent.mkdir(parents=True)
stats_path.write_text(json.dumps({"killed": 4, "timeout": 1, "total": 10, "skipped": 2}))

result = CliRunner().invoke(cli, ["badge", "--input", str(stats_path), "--output", str(badge_path)])

assert result.exit_code == 0
assert json.loads(badge_path.read_text()) == {
"schemaVersion": 1,
"label": "mutation",
"message": "62.5%",
"color": "#bfff00",
}


def test_badge_command_reports_input_errors(tmp_path: Path):
malformed_path = tmp_path / "broken.json"
malformed_path.write_text("{")
malformed_result = CliRunner().invoke(
cli,
["badge", "--input", str(malformed_path), "--output", str(tmp_path / "badge.json")],
)
assert malformed_result.exit_code == 1
assert "does not contain valid JSON" in malformed_result.output
Loading