From 4fc0a8cab38664e0b58e3929dbfdede49483b910 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 14 Oct 2026 13:50:34 +0800 Subject: [PATCH] MIL-010 task 6: reusable workflow code-metrics.yml and tools/code-metrics.sh Task: MIL-010#6 Refs #160 --- .gitea/workflows/code-metrics.yml | 81 ++++++++ tests/test_badges.py | 7 + tests/test_code_metrics.py | 306 ++++++++++++++++++++++++++++++ tools/badges.py | 2 + tools/code-metrics.sh | 130 +++++++++++++ 5 files changed, 526 insertions(+) create mode 100644 .gitea/workflows/code-metrics.yml create mode 100644 tests/test_code_metrics.py create mode 100644 tools/code-metrics.sh diff --git a/.gitea/workflows/code-metrics.yml b/.gitea/workflows/code-metrics.yml new file mode 100644 index 0000000..abbc514 --- /dev/null +++ b/.gitea/workflows/code-metrics.yml @@ -0,0 +1,81 @@ +name: Code metrics + +# Reusable workflow (ADR-0010). A repository calls it with +# +# on: +# push: +# branches: [main] +# jobs: +# metrics: +# uses: TirSystem/github-action/.gitea/workflows/code-metrics.yml@v1 +# permissions: +# contents: write +# +# It counts the code files and the lines of code of the repository (comment +# and blank lines are not counted), reads an optional Cobertura XML coverage +# report and commits three SVG badges to a branch of the calling repository. +# It runs no test, needs no secret and writes only the badges branch. +on: + workflow_call: + inputs: + working-directory: + description: "Directory, relative to the repository root, whose code is counted" + type: string + default: "." + exclude: + description: "Comma list of patterns of files and directories that are not counted, in addition to the defaults of ADR-0010" + type: string + default: "" + coverage-report: + description: "Path, relative to the repository root, of a Cobertura XML coverage report; empty writes no coverage badge" + type: string + default: "" + coverage-warn: + description: "Coverage in percent below which the coverage badge is red" + type: string + default: "60" + coverage-good: + description: "Coverage in percent from which the coverage badge is green" + type: string + default: "80" + badges-branch: + description: "Branch that holds the badge files; it is created when it does not exist" + type: string + default: "badges" + tools-ref: + description: "Tag or branch of TirSystem/github-action whose tools/ files are run; pin it to the exact tag together with the workflow" + type: string + default: "v1" + +jobs: + code-metrics: + permissions: + contents: write + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + WORKING_DIRECTORY: ${{ inputs.working-directory }} + EXCLUDE: ${{ inputs.exclude }} + COVERAGE_REPORT: ${{ inputs.coverage-report }} + COVERAGE_WARN: ${{ inputs.coverage-warn }} + COVERAGE_GOOD: ${{ inputs.coverage-good }} + BADGES_BRANCH: ${{ inputs.badges-branch }} + steps: + - name: Check out the repository + uses: actions/checkout@v4.2.2 + + - name: Fetch the shared scripts + uses: actions/checkout@v4.2.2 + with: + repository: TirSystem/github-action + ref: ${{ inputs.tools-ref }} + path: shared-scripts + + - name: Validate the inputs + run: bash shared-scripts/tools/code-metrics.sh validate + + - name: Count the code and write the badges + run: bash shared-scripts/tools/code-metrics.sh measure + + - name: Publish the badges + run: bash shared-scripts/tools/code-metrics.sh publish diff --git a/tests/test_badges.py b/tests/test_badges.py index e980169..628a522 100644 --- a/tests/test_badges.py +++ b/tests/test_badges.py @@ -280,3 +280,10 @@ class TestMain: code = badges.main(["--metrics", str(bad), "--output-dir", str(tmp_path / "o")]) assert code == 1 assert "cannot be read" in capsys.readouterr().out + + def test_no_code_files_is_a_notice_and_badges_that_show_zero(self, tmp_path, capsys): + out = tmp_path / "out" + metrics = metrics_file(tmp_path, files=0, lines=0) + assert badges.main(["--metrics", str(metrics), "--output-dir", str(out)]) == 0 + assert "::notice::No code files were found" in capsys.readouterr().out + assert 'aria-label="code files: 0"' in (out / "code-files.svg").read_text() diff --git a/tests/test_code_metrics.py b/tests/test_code_metrics.py new file mode 100644 index 0000000..e9d8631 --- /dev/null +++ b/tests/test_code_metrics.py @@ -0,0 +1,306 @@ +"""Offline tests for the reusable code-metrics workflow and tools/code-metrics.sh (ADR-0010). + +The script steps run in a throwaway git repository whose `origin` is a bare +repository on disk, so no network and no instance is needed. +""" + +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml + +from workflow_source import REPO_ROOT + +WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "code-metrics.yml" +SCRIPT = REPO_ROOT / "tools" / "code-metrics.sh" +BASH = shutil.which("bash") +GIT = shutil.which("git") + +pytestmark = pytest.mark.skipif( + BASH is None or GIT is None, reason="bash and git are needed to run the workflow steps" +) + +COVERAGE = '\n\n' + + +@pytest.fixture(scope="module") +def document() -> dict: + return yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + + +class TestDefinition: + def test_is_a_reusable_workflow_with_the_contract_inputs(self, document): + inputs = document[True]["workflow_call"]["inputs"] # PyYAML reads `on` as True + assert {name: item["default"] for name, item in inputs.items()} == { + "working-directory": ".", + "exclude": "", + "coverage-report": "", + "coverage-warn": "60", + "coverage-good": "80", + "badges-branch": "badges", + "tools-ref": "v1", + } + + def test_asks_for_contents_write_and_nothing_else_and_needs_no_secret(self, document): + job = document["jobs"]["code-metrics"] + assert job["permissions"] == {"contents": "write"} + assert "secrets" not in WORKFLOW.read_text(encoding="utf-8").replace("needs no secret", "") + + def test_inputs_reach_the_scripts_only_through_the_environment(self, document): + job = document["jobs"]["code-metrics"] + for step in job["steps"]: + assert "${{" not in step.get("run", ""), f"{step.get('name')} interpolates an expression" + assert set(job["env"]) == { + "WORKING_DIRECTORY", + "EXCLUDE", + "COVERAGE_REPORT", + "COVERAGE_WARN", + "COVERAGE_GOOD", + "BADGES_BRANCH", + } + + def test_the_workflow_embeds_no_script_and_runs_the_file(self, document): + steps = document["jobs"]["code-metrics"]["steps"] + assert [step["run"] for step in steps if "run" in step] == [ + "bash shared-scripts/tools/code-metrics.sh validate", + "bash shared-scripts/tools/code-metrics.sh measure", + "bash shared-scripts/tools/code-metrics.sh publish", + ] + + def test_the_scripts_are_fetched_at_the_tools_ref_input(self, document): + steps = document["jobs"]["code-metrics"]["steps"] + fetch = next(step for step in steps if step.get("name") == "Fetch the shared scripts") + assert fetch["with"] == { + "repository": "TirSystem/github-action", + "ref": "${{ inputs.tools-ref }}", + "path": "shared-scripts", + } + + def test_the_script_parses(self): + result = subprocess.run([BASH, "-n", str(SCRIPT)], text=True, capture_output=True) + assert result.returncode == 0, result.stderr + + def test_an_unknown_command_is_refused(self): + result = subprocess.run([BASH, str(SCRIPT), "bogus"], text=True, capture_output=True) + assert result.returncode == 2 + + +def git(*args: str, cwd: Path) -> str: + result = subprocess.run( + [GIT, "-c", "user.name=t", "-c", "user.email=t@t", *args], + cwd=cwd, + text=True, + capture_output=True, + check=True, + ) + return result.stdout.strip() + + +@pytest.fixture() +def python3_bin(tmp_path) -> Path: + """A directory with a `python3` that runs the interpreter of the tests.""" + directory = tmp_path / "pybin" + directory.mkdir() + shim = directory / "python3" + shim.write_text(f'#!/bin/sh\nexec "{Path(sys.executable).as_posix()}" "$@"\n', newline="\n") + shim.chmod(0o755) + return directory + + +@pytest.fixture() +def remote(tmp_path) -> Path: + path = tmp_path / "remote.git" + subprocess.run([GIT, "init", "-q", "--bare", str(path)], check=True) + return path + + +@pytest.fixture() +def repo(tmp_path, remote) -> Path: + root = tmp_path / "repo" + root.mkdir() + git("init", "-q", "-b", "main", cwd=root) + git("remote", "add", "origin", str(remote), cwd=root) + (root / "a.py").write_text("x = 1\n# c\n\n", newline="\n") + (root / "run.sh").write_text("echo hi\n", newline="\n") + git("add", "-A", cwd=root) + git("commit", "-q", "-m", "init", cwd=root) + return root + + +def run(command: str, repo: Path, tmp_path: Path, python3_bin: Path, **env: str): + environment = dict(os.environ) + environment.update( + { + "WORKING_DIRECTORY": ".", + "EXCLUDE": "", + "COVERAGE_REPORT": "", + "COVERAGE_WARN": "60", + "COVERAGE_GOOD": "80", + "BADGES_BRANCH": "badges", + "BADGES_OUT": str(tmp_path / "out"), + "PATH": f"{python3_bin.as_posix()}{os.pathsep}{environment['PATH']}", + } + ) + environment.update(env) + return subprocess.run( + [BASH, str(SCRIPT), command], cwd=repo, env=environment, text=True, capture_output=True + ) + + +def branch_files(remote: Path, branch: str = "badges") -> list[str]: + out = subprocess.run( + [GIT, "--git-dir", str(remote), "ls-tree", "-r", "--name-only", branch], + text=True, + capture_output=True, + ) + return out.stdout.split() + + +def commit_count(remote: Path, branch: str = "badges") -> int: + out = subprocess.run( + [GIT, "--git-dir", str(remote), "rev-list", "--count", branch], text=True, capture_output=True + ) + return int(out.stdout.strip() or 0) + + +def measure_and_publish(repo, tmp_path, python3_bin, **env): + measured = run("measure", repo, tmp_path, python3_bin, **env) + assert measured.returncode == 0, measured.stdout + measured.stderr + return run("publish", repo, tmp_path, python3_bin, **env) + + +class TestValidate: + def test_accepts_the_defaults(self, repo, tmp_path, python3_bin): + result = run("validate", repo, tmp_path, python3_bin) + assert result.returncode == 0, result.stdout + assert "badges go to the branch badges" in result.stdout + + @pytest.mark.parametrize( + ("name", "value", "message"), + [ + ("WORKING_DIRECTORY", "/etc", "working-directory must be a relative path"), + ("WORKING_DIRECTORY", "../x", "working-directory must be a relative path"), + ("WORKING_DIRECTORY", "nope", "working-directory 'nope' does not exist"), + ("COVERAGE_REPORT", "/tmp/c.xml", "coverage-report must be a relative path"), + ("BADGES_BRANCH", "main", "must not be a default branch"), + ("BADGES_BRANCH", "master", "must not be a default branch"), + ("BADGES_BRANCH", "", "must not be a default branch"), + ("BADGES_BRANCH", "a b", "is not a valid branch name"), + ("BADGES_BRANCH", "x..y", "is not a valid branch name"), + ], + ) + def test_refuses_an_invalid_input_and_names_it( + self, repo, tmp_path, python3_bin, name, value, message + ): + result = run("validate", repo, tmp_path, python3_bin, **{name: value}) + assert result.returncode == 1 + assert f"::error::[configuration] " in result.stdout + assert message in result.stdout + + +class TestPublish: + def test_creates_the_branch_with_the_badges_and_the_numbers(self, repo, remote, tmp_path, python3_bin): + result = measure_and_publish(repo, tmp_path, python3_bin) + assert result.returncode == 0, result.stdout + result.stderr + assert sorted(branch_files(remote)) == ["code-files.svg", "code-lines.svg", "code-metrics.json"] + shown = subprocess.run( + [GIT, "--git-dir", str(remote), "show", "badges:code-lines.svg"], text=True, capture_output=True + ).stdout + assert 'aria-label="lines of code: 2"' in shown # a.py has one code line, run.sh one + message = subprocess.run( + [GIT, "--git-dir", str(remote), "log", "-1", "--format=%s", "badges"], + text=True, + capture_output=True, + ).stdout.strip() + assert message == "Update code metrics badges [skip ci]" + + def test_the_branch_of_the_caller_is_not_changed(self, repo, remote, tmp_path, python3_bin): + before = git("status", "--porcelain", cwd=repo) + measure_and_publish(repo, tmp_path, python3_bin) + assert git("status", "--porcelain", cwd=repo) == before + assert git("branch", "--show-current", cwd=repo) == "main" + + def test_an_unchanged_run_commits_nothing(self, repo, remote, tmp_path, python3_bin): + measure_and_publish(repo, tmp_path, python3_bin) + second = measure_and_publish(repo, tmp_path, python3_bin) + assert second.returncode == 0 + assert "Badges are current; nothing to commit." in second.stdout + assert commit_count(remote) == 1 + + def test_a_changed_number_is_one_commit_that_changes_only_badge_files( + self, repo, remote, tmp_path, python3_bin + ): + measure_and_publish(repo, tmp_path, python3_bin) + (repo / "b.py").write_text("y = 2\nz = 3\n", newline="\n") + git("add", "-A", cwd=repo) + git("commit", "-q", "-m", "more", cwd=repo) + result = measure_and_publish(repo, tmp_path, python3_bin) + assert result.returncode == 0, result.stdout + assert commit_count(remote) == 2 + changed = subprocess.run( + [GIT, "--git-dir", str(remote), "show", "--name-only", "--format=", "badges"], + text=True, + capture_output=True, + ).stdout.split() + assert sorted(changed) == ["code-files.svg", "code-lines.svg", "code-metrics.json"] + + def test_a_coverage_report_adds_the_coverage_badge(self, repo, remote, tmp_path, python3_bin): + (repo / "cov.xml").write_text(COVERAGE, newline="\n") + result = measure_and_publish(repo, tmp_path, python3_bin, COVERAGE_REPORT="cov.xml") + assert result.returncode == 0, result.stdout + assert "coverage.svg" in branch_files(remote) + + def test_a_missing_report_fails_names_the_file_and_changes_nothing( + self, repo, remote, tmp_path, python3_bin + ): + result = run("measure", repo, tmp_path, python3_bin, COVERAGE_REPORT="nope.xml") + assert result.returncode == 1 + assert "::error::[configuration] coverage-report 'nope.xml' does not exist." in result.stdout + assert branch_files(remote) == [] + + def test_the_shared_scripts_directory_is_not_counted(self, repo, remote, tmp_path, python3_bin): + shared = repo / "shared-scripts" / "tools" + shared.mkdir(parents=True) + (shared / "big.py").write_text("a = 1\n" * 50, newline="\n") + measure_and_publish(repo, tmp_path, python3_bin) + shown = subprocess.run( + [GIT, "--git-dir", str(remote), "show", "badges:code-files.svg"], text=True, capture_output=True + ).stdout + assert 'aria-label="code files: 2"' in shown + + def test_a_branch_that_holds_other_files_is_refused(self, repo, remote, tmp_path, python3_bin): + git("push", "-q", "origin", "main:docs", cwd=repo) + result = measure_and_publish(repo, tmp_path, python3_bin, BADGES_BRANCH="docs") + assert result.returncode == 1 + assert "it is not a badges branch" in result.stdout + + def test_publish_without_measure_is_refused(self, repo, tmp_path, python3_bin): + result = run("publish", repo, tmp_path, python3_bin) + assert result.returncode == 1 + assert "run measure first" in result.stdout + + def test_a_rejected_push_is_retried_once_after_a_fetch(self, repo, remote, tmp_path, python3_bin): + marker = tmp_path / "rejected-once" + hook = remote / "hooks" / "pre-receive" + hook.write_text( + f'#!/bin/sh\nif [ ! -e "{marker.as_posix()}" ]; then touch "{marker.as_posix()}"; ' + "echo rejected once >&2; exit 1; fi\nexit 0\n", + newline="\n", + ) + hook.chmod(0o755) + result = measure_and_publish(repo, tmp_path, python3_bin) + assert result.returncode == 0, result.stdout + result.stderr + assert marker.exists() + + def test_two_rejected_pushes_fail(self, repo, remote, tmp_path, python3_bin): + hook = remote / "hooks" / "pre-receive" + hook.write_text("#!/bin/sh\necho rejected >&2\nexit 1\n", newline="\n") + hook.chmod(0o755) + result = measure_and_publish(repo, tmp_path, python3_bin) + assert result.returncode == 1 + assert re.search(r"push to 'badges' failed twice", result.stdout) diff --git a/tools/badges.py b/tools/badges.py index 1c6ebf1..047b028 100644 --- a/tools/badges.py +++ b/tools/badges.py @@ -205,6 +205,8 @@ def main(argv: list[str] | None = None) -> int: f"::error::[configuration] metrics file '{args.metrics}' cannot be read: {error}" ) return 1 + if file_count == 0: + print("::notice::No code files were found; the badges show 0.") if coverage is None: print("::notice::No coverage-report given; no coverage badge is written.") metrics_json = json.dumps(metrics, indent=2, sort_keys=True) + "\n" diff --git a/tools/code-metrics.sh b/tools/code-metrics.sh new file mode 100644 index 0000000..44c3d04 --- /dev/null +++ b/tools/code-metrics.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# Steps of the reusable workflow .gitea/workflows/code-metrics.yml (ADR-0010). +# The workflow checks out the calling repository, fetches this file into +# shared-scripts/ and runs one command per step: +# +# code-metrics.sh validate check the inputs, before anything is counted +# code-metrics.sh measure count the code, read the coverage report and +# write the badge files (count_loc.py, badges.py) +# code-metrics.sh publish commit the badge files to the badges branch, +# and nothing when none of them changed +# +# Settings come from the environment, never from interpolated expressions: +# WORKING_DIRECTORY, EXCLUDE, COVERAGE_REPORT, COVERAGE_WARN, COVERAGE_GOOD, +# BADGES_BRANCH, BADGES_OUT (the directory of the badge files; a temporary +# directory under RUNNER_TEMP by default). +# Every failure is printed as ::error::[category] message. + +TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT_DIR="${BADGES_OUT:-${RUNNER_TEMP:-/tmp}/code-metrics}" +ALLOWED_FILES='^(code-files\.svg|code-lines\.svg|coverage\.svg|code-metrics\.json)$' +COMMIT_MESSAGE="Update code metrics badges [skip ci]" + +fail() { + echo "::error::[configuration] $1" + exit 1 +} + +relative_path() { + case "$1" in + "" | /* | .. | ../* | */.. | */../*) return 1 ;; + esac +} + +validate_inputs() { + set -u + relative_path "$WORKING_DIRECTORY" || + fail "working-directory must be a relative path inside the repository." + [ -d "$WORKING_DIRECTORY" ] || + fail "working-directory '$WORKING_DIRECTORY' does not exist." + if [ -n "$COVERAGE_REPORT" ]; then + relative_path "$COVERAGE_REPORT" || + fail "coverage-report must be a relative path inside the repository." + fi + case "$BADGES_BRANCH" in + main | master | HEAD | "") fail "badges-branch '$BADGES_BRANCH' must not be a default branch." ;; + esac + git check-ref-format --branch "$BADGES_BRANCH" > /dev/null 2>&1 || + fail "badges-branch '$BADGES_BRANCH' is not a valid branch name." + echo "Counting ${WORKING_DIRECTORY}; badges go to the branch ${BADGES_BRANCH}" +} + +measure() { + set -eu + metrics="$(mktemp)" + python3 "$TOOLS_DIR/count_loc.py" \ + --directory "$WORKING_DIRECTORY" --exclude "$EXCLUDE" --output "$metrics" + rm -rf "$OUT_DIR" + python3 "$TOOLS_DIR/badges.py" \ + --metrics "$metrics" --output-dir "$OUT_DIR" \ + --coverage-report "$COVERAGE_REPORT" \ + --coverage-warn "$COVERAGE_WARN" --coverage-good "$COVERAGE_GOOD" + rm -f "$metrics" +} + +# Copy the badge files into the worktree and stage them; return 0 when +# something differs from the branch. +stage_files() { + local worktree="$1" file + for file in "$OUT_DIR"/*; do + cp "$file" "$worktree/$(basename "$file")" + done + git -C "$worktree" add -- . + ! git -C "$worktree" diff --cached --quiet +} + +commit_files() { + git -C "$1" -c user.name="code-metrics" -c user.email="code-metrics@invalid" \ + commit --quiet -m "$COMMIT_MESSAGE" +} + +publish() { + set -eu + [ -d "$OUT_DIR" ] || fail "no badge files in '$OUT_DIR'; run measure first." + local worktree exists=0 committed=0 attempt + worktree="$(mktemp -d)" + if git fetch --quiet origin "+refs/heads/$BADGES_BRANCH:refs/remotes/origin/$BADGES_BRANCH" 2> /dev/null; then + exists=1 + if git ls-tree -r --name-only "origin/$BADGES_BRANCH" | grep -qvE "$ALLOWED_FILES"; then + fail "branch '$BADGES_BRANCH' holds files that are not badge files; it is not a badges branch." + fi + git worktree add --quiet --detach "$worktree" "origin/$BADGES_BRANCH" + else + git worktree add --quiet --detach "$worktree" HEAD + git -C "$worktree" checkout --quiet --orphan "$BADGES_BRANCH" + git -C "$worktree" rm -rfq --ignore-unmatch . + fi + + for attempt in 1 2; do + if [ "$committed" -eq 0 ]; then + if stage_files "$worktree"; then + commit_files "$worktree" + committed=1 + elif [ "$exists" -eq 1 ]; then + echo "Badges are current; nothing to commit." + break + fi + fi + if git -C "$worktree" push --quiet origin "HEAD:refs/heads/$BADGES_BRANCH"; then + echo "Published the badges to the branch ${BADGES_BRANCH}." + break + fi + [ "$attempt" -lt 2 ] || fail "the push to '$BADGES_BRANCH' failed twice." + if git fetch --quiet origin "+refs/heads/$BADGES_BRANCH:refs/remotes/origin/$BADGES_BRANCH" 2> /dev/null; then + git -C "$worktree" reset --quiet --hard "origin/$BADGES_BRANCH" + exists=1 + committed=0 + fi + done + git worktree remove --force "$worktree" +} + +case "${1:-}" in + validate) validate_inputs ;; + measure) measure ;; + publish) publish ;; + *) + echo "::error::[configuration] usage: code-metrics.sh validate|measure|publish" >&2 + exit 2 + ;; +esac