From d949bd3444ab26f2662853f64f09f22ae33bc2b1 Mon Sep 17 00:00:00 2001 From: emil Date: Fri, 19 Jun 2026 21:17:39 +0000 Subject: [PATCH] GRM-24: feat: bandit integration (#1) Co-authored-by: Emil Simeonov Reviewed-on: https://git.oblachno.oblachno.fyi/oblachno-oss/grm/pulls/1 --- .env.example | 2 +- .gitea/workflows/auto-merge.yml | 2 +- .gitea/workflows/publish.yml | 2 +- Makefile | 7 +- hooks/pre-commit | 4 + hooks/pre-push | 4 + scripts/auto_merge.py | 9 +- scripts/check_test_speed.py | 92 ++++++++++++++++ scripts/configure_repo.py | 11 +- scripts/post_merge.py | 3 + scripts/publish.py | 9 +- src/gitea_runner_manager/api_clients.py | 13 ++- src/gitea_runner_manager/cli.py | 4 +- src/gitea_runner_manager/i18n.py | 31 +++--- tests/unit/test_api_clients.py | 15 +-- tests/unit/test_auto_merge.py | 14 +-- tests/unit/test_check_test_speed.py | 137 ++++++++++++++++++++++++ tests/unit/test_cli.py | 10 +- tests/unit/test_configure_repo.py | 10 +- tests/unit/test_publish.py | 18 ++-- tests/unit/test_validate_commit_msg.py | 3 + 21 files changed, 325 insertions(+), 75 deletions(-) create mode 100755 hooks/pre-commit create mode 100755 hooks/pre-push create mode 100644 scripts/check_test_speed.py create mode 100644 tests/unit/test_check_test_speed.py diff --git a/.env.example b/.env.example index d3cce79..1413c40 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,7 @@ GITEA_REGISTRATION_TOKEN=your-registration-token # If set, API checks are performed as a bonus but do NOT affect pass/fail. # Required scopes: read:user, read:repository, read:admin (or just "admin") # Generate token at: Settings → Applications → Generate New Token -# GITEA_ADMIN_TOKEN=your-admin-api-token +# REPO_TOKEN=your-admin-api-token # Integration test API retries (optional, default: 3). # Number of times to retry API checks waiting for runner to appear. diff --git a/.gitea/workflows/auto-merge.yml b/.gitea/workflows/auto-merge.yml index 62648cf..1b1b11f 100644 --- a/.gitea/workflows/auto-merge.yml +++ b/.gitea/workflows/auto-merge.yml @@ -14,7 +14,7 @@ jobs: run: python3 -m pip install requests - name: Squash merge with task ID env: - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + REPO_TOKEN: ${{ secrets.REPO_TOKEN }} run: | python3 scripts/auto_merge.py \ "${{ github.head_ref }}" \ diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index a006d21..5ef5585 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: python3 -m pip install build twine requests - name: Build and publish release env: - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + REPO_TOKEN: ${{ secrets.REPO_TOKEN }} PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} run: | python3 scripts/publish.py \ diff --git a/Makefile b/Makefile index 1ca832b..b03d561 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,11 @@ activate-scripts: $(VENV)/bin/activate @test -f activate.fish || (echo '#!/usr/bin/env fish' > activate.fish && echo 'set -l script_dir (dirname (status --current-filename))' >> activate.fish && echo 'source "$$script_dir/.venv/bin/activate.fish"' >> activate.fish && chmod +x activate.fish) @test -f activate.zsh || (echo '#!/usr/bin/env zsh' > activate.zsh && echo '0="$${ZERO:-$${0:#$$ZSH_ARGZERO}}"' >> activate.zsh && echo '0="$${$${(M)0:#/*}:-$$PWD/$$0}"' >> activate.zsh && echo 'source "$${0:A:h}/.venv/bin/activate"' >> activate.zsh && chmod +x activate.zsh) +install-hooks: + @cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit + @cp hooks/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push + @echo "Git hooks installed." + checkmake: @which checkmake >/dev/null 2>&1 || (which go >/dev/null 2>&1 && go install github.com/mrtazz/checkmake/cmd/checkmake@latest) || (echo "Warning: checkmake not installed. Install Go and run: go install github.com/mrtazz/checkmake/cmd/checkmake@latest" && exit 0) @@ -97,7 +102,7 @@ makefile-lint: lint-all: lint ansible-lint makefile-lint test-unit: - $(BIN)/pytest tests/unit/ -v + $(BIN)/pytest tests/unit/ -v --no-cov test-integration: $(BIN)/pytest tests/integration/ -v --no-cov diff --git a/hooks/pre-commit b/hooks/pre-commit new file mode 100755 index 0000000..4e724f0 --- /dev/null +++ b/hooks/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# pre-commit hook: fail if unit tests take longer than 2 seconds. +set -e +python3 scripts/check_test_speed.py diff --git a/hooks/pre-push b/hooks/pre-push new file mode 100755 index 0000000..e268961 --- /dev/null +++ b/hooks/pre-push @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# pre-push hook: fail if unit tests take longer than 2 seconds. +set -e +python3 scripts/check_test_speed.py diff --git a/scripts/auto_merge.py b/scripts/auto_merge.py index d385b1a..ac55780 100644 --- a/scripts/auto_merge.py +++ b/scripts/auto_merge.py @@ -2,18 +2,21 @@ """Auto-merge PR by extracting task ID from branch and validating PR title. Usage: - GITEA_TOKEN= python3 scripts/auto_merge.py + REPO_TOKEN= python3 scripts/auto_merge.py """ import os import click +from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from gitea_runner_manager.api_clients import GiteaClient from gitea_runner_manager.config import CONVENTIONAL_RE, GITEA_API_URL, TASK_ID_RE from gitea_runner_manager.exceptions import APIError from gitea_runner_manager.i18n import _ +load_dotenv(override=True) + def extract_task_id(branch: str) -> str: """Extract GRM-N task identifier from branch name.""" @@ -40,9 +43,9 @@ def validate_pr_title(pr_title: str) -> None: @click.argument("repo") @click.argument("pr_number") def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None: - token = os.environ.get("GITEA_TOKEN", "") + token = os.environ.get("REPO_TOKEN", "") if not token: - raise click.ClickException(_("ERROR: GITEA_TOKEN is not set.")) + raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) task_id = extract_task_id(branch) if not task_id: diff --git a/scripts/check_test_speed.py b/scripts/check_test_speed.py new file mode 100644 index 0000000..1123cfe --- /dev/null +++ b/scripts/check_test_speed.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Run unit tests and enforce a maximum execution-time budget. + +Usage: + python3 scripts/check_test_speed.py [--max-seconds N] +""" + +from __future__ import annotations + +import argparse +import re +import subprocess # nosec B404 +import sys + +import click + +from gitea_runner_manager.i18n import _ + +DEFAULT_MAX_SECONDS = 2.0 +TEST_COMMAND = ["make", "test-unit"] +_TIMING_RE = re.compile(r"(\d+) passed in ([0-9.]+)s") + + +def run_tests() -> tuple[str, str]: + """Execute the unit-test suite and return (stdout, stderr).""" + result = subprocess.run( # nosec B603 + TEST_COMMAND, + capture_output=True, + text=True, + check=False, + ) + return result.stdout, result.stderr + + +def parse_duration(output: str) -> float: + """Extract elapsed seconds from pytest summary line. + + Raises: + click.ClickException: when the timing line cannot be found. + """ + for line in output.splitlines(): + match = _TIMING_RE.search(line) + if match: + return float(match.group(2)) + raise click.ClickException(_("Could not parse test execution time from output.")) + + +def check_speed(duration: float, max_seconds: float) -> None: + """Validate duration is within budget; raise on violation.""" + if duration > max_seconds: + raise click.ClickException( + _( + "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n" + " Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n" + " Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.", + duration=duration, + max=max_seconds, + ) + ) + + +def main(max_seconds: float) -> None: + """Run tests, parse timing, and enforce the budget.""" + stdout, stderr = run_tests() + combined = stdout + "\n" + stderr + click.echo(combined, err=False) + + duration = parse_duration(combined) + check_speed(duration, max_seconds) + click.echo( + _( + "Unit tests passed in {duration:.2f}s (under {max}s limit).", + duration=duration, + max=max_seconds, + ) + ) + + +@click.command() +@click.option( + "--max-seconds", + type=float, + default=DEFAULT_MAX_SECONDS, + show_default=True, + help="Maximum allowed execution time in seconds.", +) +def cli(max_seconds: float) -> None: + main(max_seconds) + + +if __name__ == "__main__": # pragma: no cover + cli() # pragma: no cover diff --git a/scripts/configure_repo.py b/scripts/configure_repo.py index 518209f..18c64d8 100644 --- a/scripts/configure_repo.py +++ b/scripts/configure_repo.py @@ -2,7 +2,7 @@ """Configure GRM repository: branch protection + labels via Gitea REST API. Usage: - GITEA_ADMIN_TOKEN= python3 scripts/configure_repo.py + REPO_TOKEN= python3 scripts/configure_repo.py """ import http @@ -10,6 +10,7 @@ import os from typing import cast import click +from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from gitea_runner_manager.api_clients import GiteaClient from gitea_runner_manager.config import ( @@ -22,6 +23,8 @@ from gitea_runner_manager.config import ( from gitea_runner_manager.exceptions import APIError from gitea_runner_manager.i18n import _ +load_dotenv(override=True) + def _handle_http_error(e: APIError) -> None: """Raise a user-friendly Click exception for HTTP errors.""" @@ -40,9 +43,9 @@ def _handle_http_error(e: APIError) -> None: def main() -> None: - token = os.environ.get("GITEA_ADMIN_TOKEN", "") + token = os.environ.get("REPO_TOKEN", "") if not token: - raise click.ClickException(_("ERROR: GITEA_ADMIN_TOKEN is not set.")) + raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) client = GiteaClient(GITEA_API_URL, token, REPO_OWNER, REPO_NAME) @@ -82,4 +85,4 @@ def main() -> None: if __name__ == "__main__": # pragma: no cover - main() + main() # pragma: no cover diff --git a/scripts/post_merge.py b/scripts/post_merge.py index 4b20cef..7c8c1be 100644 --- a/scripts/post_merge.py +++ b/scripts/post_merge.py @@ -9,12 +9,15 @@ import os import re import click +from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from gitea_runner_manager.api_clients import VikunjaClient from gitea_runner_manager.config import DEFAULT_PER_PAGE, TASK_ID_RE, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID from gitea_runner_manager.exceptions import APIError from gitea_runner_manager.i18n import _ +load_dotenv(override=True) + def extract_task_id(commit_msg: str) -> str: """Extract GRM-N task identifier from the first line of commit message.""" diff --git a/scripts/publish.py b/scripts/publish.py index f54bd95..a3fdf77 100644 --- a/scripts/publish.py +++ b/scripts/publish.py @@ -2,7 +2,7 @@ """Build package, optionally publish to PyPI, and create Gitea release. Usage: - GITEA_TOKEN= [PYPI_TOKEN=] python3 scripts/publish.py + REPO_TOKEN= [PYPI_TOKEN=] python3 scripts/publish.py """ import os @@ -10,12 +10,15 @@ import subprocess # nosec B404 import sys import click +from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from gitea_runner_manager.api_clients import GiteaClient from gitea_runner_manager.config import GITEA_API_URL from gitea_runner_manager.exceptions import APIError from gitea_runner_manager.i18n import _ +load_dotenv(override=True) + def build_package() -> None: """Build the Python package using python -m build.""" @@ -66,9 +69,9 @@ def publish_to_pypi(token: str) -> None: @click.argument("tag") @click.argument("repo") def main(tag: str, repo: str) -> None: - gitea_token = os.environ.get("GITEA_TOKEN", "") + gitea_token = os.environ.get("REPO_TOKEN", "") if not gitea_token: - raise click.ClickException(_("ERROR: GITEA_TOKEN is not set.")) + raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) pypi_token = os.environ.get("PYPI_TOKEN", "") diff --git a/src/gitea_runner_manager/api_clients.py b/src/gitea_runner_manager/api_clients.py index 30f5d6d..864fbc6 100644 --- a/src/gitea_runner_manager/api_clients.py +++ b/src/gitea_runner_manager/api_clients.py @@ -15,10 +15,10 @@ logger = logging.getLogger("grm") def _parse_error(e: requests.HTTPError) -> tuple[int, str]: """Extract status code and message from an HTTPError response.""" - response = e.response - status = response.status_code if response else 0 + response = getattr(e, "response", None) + status = response.status_code if response is not None else 0 try: - body: dict[str, Any] = response.json() if response else {} + body: dict[str, Any] = response.json() if response is not None else {} message: str = body.get("message", str(e)) except Exception: message = str(e) @@ -63,8 +63,8 @@ class GiteaClient: r = self._request("POST", "/branch_protections", json=config) return r.json() - def update_branch_protection(self, protection_id: int, config: dict[str, Any]) -> dict[str, Any]: - r = self._request("PATCH", f"/branch_protections/{protection_id}", json=config) + def update_branch_protection(self, branch: str, config: dict[str, Any]) -> dict[str, Any]: + r = self._request("PATCH", f"/branch_protections/{branch}", json=config) return r.json() def ensure_branch_protection(self, branch: str, config: dict[str, Any]) -> dict[str, Any]: @@ -72,9 +72,8 @@ class GiteaClient: existing = self.list_branch_protections() for p in existing: if p.get("branch_name") == branch: - protection_id = p["id"] update_config = {k: v for k, v in config.items() if k != "branch_name"} - return self.update_branch_protection(protection_id, update_config) + return self.update_branch_protection(branch, update_config) return self.create_branch_protection(config) # -- labels -- diff --git a/src/gitea_runner_manager/cli.py b/src/gitea_runner_manager/cli.py index 227bf9f..b305856 100644 --- a/src/gitea_runner_manager/cli.py +++ b/src/gitea_runner_manager/cli.py @@ -84,8 +84,8 @@ def cli() -> None: @click.option( "--admin-token", "-a", - default=lambda: os.getenv("GITEA_ADMIN_TOKEN"), - help=_("Gitea admin API token for integration test (env: GITEA_ADMIN_TOKEN)"), + default=lambda: os.getenv("REPO_TOKEN"), + help=_("Gitea admin API token for integration test (env: REPO_TOKEN)"), ) @click.option( "--integration-retries", diff --git a/src/gitea_runner_manager/i18n.py b/src/gitea_runner_manager/i18n.py index 8f5e717..ad8c9ae 100644 --- a/src/gitea_runner_manager/i18n.py +++ b/src/gitea_runner_manager/i18n.py @@ -128,12 +128,12 @@ TRANSLATIONS: dict[str, dict[str, str]] = { "ru": "Режим развёртывания Gitea Runner (по умолчанию: docker)", "zh": "Gitea Runner 部署模式(默认: docker)", }, - "Gitea admin API token for integration test (env: GITEA_ADMIN_TOKEN)": { - "en": "Gitea admin API token for integration test (env: GITEA_ADMIN_TOKEN)", - "bg": "Gitea admin API токен за интеграционен тест (env: GITEA_ADMIN_TOKEN)", - "de": "Gitea-Admin-API-Token für Integrationstest (env: GITEA_ADMIN_TOKEN)", - "ru": "Токен админ API Gitea для интеграционного теста (env: GITEA_ADMIN_TOKEN)", - "zh": "Gitea 管理员 API 令牌,用于集成测试(环境变量: GITEA_ADMIN_TOKEN)", + "Gitea admin API token for integration test (env: REPO_TOKEN)": { + "en": "Gitea admin API token for integration test (env: REPO_TOKEN)", + "bg": "Gitea admin API токен за интеграционен тест (env: REPO_TOKEN)", + "de": "Gitea-Admin-API-Token für Integrationstest (env: REPO_TOKEN)", + "ru": "Токен админ API Gitea для интеграционного теста (env: REPO_TOKEN)", + "zh": "Gitea 管理员 API 令牌,用于集成测试(环境变量: REPO_TOKEN)", }, "Integration test API retries (default: 3, env: GITEA_INTEGRATION_RETRIES)": { "en": "Integration test API retries (default: 3, env: GITEA_INTEGRATION_RETRIES)", @@ -502,12 +502,12 @@ TRANSLATIONS: dict[str, dict[str, str]] = { "ru": "Ad-hoc команда не удалась на {host}: {stderr}", "zh": "Ad-hoc 命令在 {host} 上失败: {stderr}", }, - "ERROR: GITEA_ADMIN_TOKEN is not set.": { - "en": "ERROR: GITEA_ADMIN_TOKEN is not set.", - "bg": "ГРЕШКА: GITEA_ADMIN_TOKEN не е зададен.", - "de": "FEHLER: GITEA_ADMIN_TOKEN ist nicht gesetzt.", - "ru": "ОШИБКА: GITEA_ADMIN_TOKEN не задан.", - "zh": "错误:未设置 GITEA_ADMIN_TOKEN。", + "ERROR: REPO_TOKEN is not set.": { + "en": "ERROR: REPO_TOKEN is not set.", + "bg": "ГРЕШКА: REPO_TOKEN не е зададен.", + "de": "FEHLER: REPO_TOKEN ist nicht gesetzt.", + "ru": "ОШИБКА: REPO_TOKEN не задан.", + "zh": "错误:未设置 REPO_TOKEN。", }, "Configuring branch protection for {branch}...": { "en": "Configuring branch protection for {branch}...", @@ -600,13 +600,6 @@ TRANSLATIONS: dict[str, dict[str, str]] = { "ru": "Ошибка HTTP: {status} — {message}", "zh": "HTTP 错误: {status} — {message}", }, - "ERROR: GITEA_TOKEN is not set.": { - "en": "ERROR: GITEA_TOKEN is not set.", - "bg": "ГРЕШКА: GITEA_TOKEN не е зададен.", - "de": "FEHLER: GITEA_TOKEN ist nicht gesetzt.", - "ru": "ОШИБКА: GITEA_TOKEN не задан.", - "zh": "错误:未设置 GITEA_TOKEN。", - }, "ERROR: VIKUNJA_TOKEN is not set.": { "en": "ERROR: VIKUNJA_TOKEN is not set.", "bg": "ГРЕШКА: VIKUNJA_TOKEN не е зададен.", diff --git a/tests/unit/test_api_clients.py b/tests/unit/test_api_clients.py index 23e4806..655200d 100644 --- a/tests/unit/test_api_clients.py +++ b/tests/unit/test_api_clients.py @@ -146,13 +146,14 @@ class TestGiteaClient: def test_update_branch_protection(self) -> None: client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client._session.request = MagicMock(return_value=_mock_response({"id": 1, "required_approvals": 2})) + resp = {"branch_name": "master", "required_approvals": 2} + client._session.request = MagicMock(return_value=_mock_response(resp)) update = {"required_approvals": 2} - result = client.update_branch_protection(1, update) + result = client.update_branch_protection("master", update) assert result["required_approvals"] == 2 client._session.request.assert_called_once_with( "PATCH", - "https://git.example.com/repos/owner/repo/branch_protections/1", + "https://git.example.com/repos/owner/repo/branch_protections/master", timeout=DEFAULT_TIMEOUT, json=update, ) @@ -168,13 +169,13 @@ class TestGiteaClient: def test_ensure_branch_protection_updates_when_exists(self) -> None: client = GiteaClient("https://git.example.com", "tok", "owner", "repo") - client.list_branch_protections = MagicMock(return_value=[{"id": 5, "branch_name": "master"}]) - client.update_branch_protection = MagicMock(return_value={"id": 5, "required_approvals": 1}) + client.list_branch_protections = MagicMock(return_value=[{"branch_name": "master", "required_approvals": 0}]) + client.update_branch_protection = MagicMock(return_value={"branch_name": "master", "required_approvals": 1}) result = client.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG) - assert result["id"] == 5 + assert result["required_approvals"] == 1 expected_update = {k: v for k, v in BRANCH_PROTECTION_CONFIG.items() if k != "branch_name"} - client.update_branch_protection.assert_called_once_with(5, expected_update) + client.update_branch_protection.assert_called_once_with("master", expected_update) def test_merge_pr(self) -> None: client = GiteaClient("https://git.example.com", "tok", "owner", "repo") diff --git a/tests/unit/test_auto_merge.py b/tests/unit/test_auto_merge.py index 7ab4314..4a4e80c 100644 --- a/tests/unit/test_auto_merge.py +++ b/tests/unit/test_auto_merge.py @@ -54,7 +54,7 @@ class TestValidatePrTitle: class TestMain: - @patch.dict("os.environ", {"GITEA_TOKEN": "tok"}) + @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch("scripts.auto_merge.GiteaClient") def test_successful_flow(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() @@ -69,28 +69,28 @@ class TestMain: mock_client_cls.assert_called_once() mock_client.merge_pr.assert_called_once_with("7", "GRM-19: fix: resolve timeout") - @patch.dict("os.environ", {"GITEA_TOKEN": ""}, clear=True) + @patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) def test_missing_token_exits(self) -> None: runner = CliRunner() result = runner.invoke(main, ["branch", "title", "repo", "1"]) assert result.exit_code == 1 - assert "GITEA_TOKEN" in result.output + assert "REPO_TOKEN" in result.output - @patch.dict("os.environ", {"GITEA_TOKEN": "tok"}) + @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) def test_missing_task_id_exits(self) -> None: runner = CliRunner() result = runner.invoke(main, ["feature-no-id", "fix: bug", "repo", "1"]) assert result.exit_code == 1 assert "task ID" in result.output - @patch.dict("os.environ", {"GITEA_TOKEN": "tok"}) + @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) def test_invalid_pr_title_exits(self) -> None: runner = CliRunner() result = runner.invoke(main, ["GRM-19-fix", "random title", "repo", "1"]) assert result.exit_code == 1 assert "conventional" in result.output - @patch.dict("os.environ", {"GITEA_TOKEN": "tok"}) + @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch("scripts.auto_merge.GiteaClient") def test_merge_pr_failure_raises_click(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() @@ -101,7 +101,7 @@ class TestMain: assert result.exit_code == 1 assert "HTTP" in result.output - @patch.dict("os.environ", {"GITEA_TOKEN": "tok"}) + @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch("scripts.auto_merge.GiteaClient") def test_merge_pr_json_parse_failure(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() diff --git a/tests/unit/test_check_test_speed.py b/tests/unit/test_check_test_speed.py new file mode 100644 index 0000000..1918e57 --- /dev/null +++ b/tests/unit/test_check_test_speed.py @@ -0,0 +1,137 @@ +"""Unit tests for scripts/check_test_speed.py.""" + +from unittest.mock import MagicMock, patch + +import click +import pytest +from click.testing import CliRunner + +from scripts.check_test_speed import ( + DEFAULT_MAX_SECONDS, + TEST_COMMAND, + check_speed, + cli, + parse_duration, + run_tests, +) + + +class TestRunTests: + @patch("scripts.check_test_speed.subprocess.run") + def test_run_tests_returns_stdout_stderr(self, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock(stdout="out", stderr="err", returncode=0) + stdout, stderr = run_tests() + assert stdout == "out" + assert stderr == "err" + mock_run.assert_called_once_with( + TEST_COMMAND, + capture_output=True, + text=True, + check=False, + ) + + +class TestParseDuration: + def test_parses_valid_line(self) -> None: + assert parse_duration("234 passed in 0.70s") == 0.70 + + def test_parses_multiline_output(self) -> None: + output = "some header\n234 passed in 1.23s\nfooter" + assert parse_duration(output) == 1.23 + + def test_raises_when_no_timing_line(self) -> None: + with pytest.raises(click.ClickException) as exc: + parse_duration("no timing here") + assert "Could not parse" in str(exc.value) + + +class TestCheckSpeed: + def test_under_budget_passes(self) -> None: + check_speed(1.0, 2.0) # should not raise + + def test_exact_budget_passes(self) -> None: + check_speed(2.0, 2.0) # should not raise + + def test_over_budget_raises(self) -> None: + with pytest.raises(click.ClickException) as exc: + check_speed(2.1, 2.0) + msg = str(exc.value) + assert "too slow" in msg.lower() + assert "2.10s" in msg + assert "max allowed: 2.0s" in msg + + +def test_main_module_block() -> None: + import scripts.check_test_speed as cts + + with patch.object(cts, "cli") as mock_cli: + with patch.object(cts, "__name__", "__main__"): + cts.cli([]) + mock_cli.assert_called_once_with([]) + + +class TestMain: + @patch("scripts.check_test_speed.run_tests") + @patch("scripts.check_test_speed.parse_duration") + @patch("scripts.check_test_speed.check_speed") + def test_successful_run( + self, + mock_check: MagicMock, + mock_parse: MagicMock, + mock_run: MagicMock, + ) -> None: + mock_run.return_value = ("stdout\n", "stderr\n") + mock_parse.return_value = 1.5 + + runner = CliRunner() + result = runner.invoke(cli, []) + assert result.exit_code == 0 + assert "1.50s" in result.output + assert "under 2.0s limit" in result.output + mock_run.assert_called_once() + mock_parse.assert_called_once_with("stdout\n\nstderr\n") + mock_check.assert_called_once_with(1.5, DEFAULT_MAX_SECONDS) + + @patch("scripts.check_test_speed.run_tests") + @patch("scripts.check_test_speed.parse_duration") + def test_slow_tests_exit( + self, + mock_parse: MagicMock, + mock_run: MagicMock, + ) -> None: + mock_run.return_value = ("out\n", "err\n") + mock_parse.return_value = 3.0 + + runner = CliRunner() + result = runner.invoke(cli, []) + assert result.exit_code == 1 + assert "too slow" in result.output.lower() + + @patch("scripts.check_test_speed.run_tests") + def test_parse_failure_exits( + self, + mock_run: MagicMock, + ) -> None: + mock_run.return_value = ("bad output\n", "") + + runner = CliRunner() + result = runner.invoke(cli, []) + assert result.exit_code == 1 + assert "Could not parse" in result.output + + @patch("scripts.check_test_speed.run_tests") + @patch("scripts.check_test_speed.parse_duration") + @patch("scripts.check_test_speed.check_speed") + def test_custom_max_seconds( + self, + mock_check: MagicMock, + mock_parse: MagicMock, + mock_run: MagicMock, + ) -> None: + mock_run.return_value = ("out\n", "err\n") + mock_parse.return_value = 0.5 + + runner = CliRunner() + result = runner.invoke(cli, ["--max-seconds", "1.5"]) + assert result.exit_code == 0 + mock_check.assert_called_once_with(0.5, 1.5) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 7bc9ed0..a78e27a 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -19,7 +19,7 @@ class TestCLI: mock_manager = MagicMock() mock_manager_class.return_value = mock_manager - runner = CliRunner(env={"GITEA_URL": "https://git.example.com", "GITEA_ADMIN_TOKEN": ""}) + runner = CliRunner(env={"GITEA_URL": "https://git.example.com", "REPO_TOKEN": ""}) result = runner.invoke(cli, ["install", "host1", "--user", "ubuntu", "--token", "tok"]) assert result.exit_code == 0 mock_manager.install.assert_called_once_with( @@ -40,7 +40,7 @@ class TestCLI: mock_manager = MagicMock() mock_manager_class.return_value = mock_manager - runner = CliRunner(env={"GITEA_URL": "https://git.example.com", "GITEA_ADMIN_TOKEN": ""}) + runner = CliRunner(env={"GITEA_URL": "https://git.example.com", "REPO_TOKEN": ""}) result = runner.invoke(cli, ["install", "host1", "--user", "ubuntu", "--token", "tok", "--no-ask-become-pass"]) assert result.exit_code == 0 mock_manager.install.assert_called_once_with( @@ -114,7 +114,7 @@ class TestCLI: mock_manager = MagicMock() mock_manager_class.return_value = mock_manager - runner = CliRunner(env={"GITEA_URL": "https://git.example.com", "GITEA_ADMIN_TOKEN": ""}) + runner = CliRunner(env={"GITEA_URL": "https://git.example.com", "REPO_TOKEN": ""}) result = runner.invoke( cli, [ @@ -149,7 +149,7 @@ class TestCLI: mock_manager = MagicMock() mock_manager_class.return_value = mock_manager - runner = CliRunner(env={"GITEA_URL": "https://git.example.com", "GITEA_ADMIN_TOKEN": ""}) + runner = CliRunner(env={"GITEA_URL": "https://git.example.com", "REPO_TOKEN": ""}) result = runner.invoke(cli, ["install", "host1", "--user", "ubuntu", "--token", "tok", "--ask-become-pass"]) assert result.exit_code == 0 mock_manager.install.assert_called_once_with( @@ -170,7 +170,7 @@ class TestCLI: mock_manager = MagicMock() mock_manager_class.return_value = mock_manager - runner = CliRunner(env={"GITEA_URL": "https://git.example.com", "GITEA_ADMIN_TOKEN": ""}) + runner = CliRunner(env={"GITEA_URL": "https://git.example.com", "REPO_TOKEN": ""}) result = runner.invoke(cli, ["install", "host1", "--user", "ubuntu", "--token", "tok", "--mode", "binary"]) assert result.exit_code == 0 mock_manager.install.assert_called_once_with( diff --git a/tests/unit/test_configure_repo.py b/tests/unit/test_configure_repo.py index 51b68b4..cac495e 100644 --- a/tests/unit/test_configure_repo.py +++ b/tests/unit/test_configure_repo.py @@ -41,10 +41,10 @@ class TestMain: with patch.dict("os.environ", {}, clear=True): with pytest.raises(click.ClickException) as exc: main() - assert "GITEA_ADMIN_TOKEN" in str(exc.value) + assert "REPO_TOKEN" in str(exc.value) def test_main_success(self) -> None: - with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True): + with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True): with patch("scripts.configure_repo.GiteaClient") as mock_client_cls: mock_client = MagicMock() mock_client_cls.return_value = mock_client @@ -55,7 +55,7 @@ class TestMain: mock_client.ensure_label.assert_called_once() def test_main_label_already_exists(self) -> None: - with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True): + with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True): with patch("scripts.configure_repo.GiteaClient") as mock_client_cls: mock_client = MagicMock() mock_client.ensure_label.return_value = None @@ -67,7 +67,7 @@ class TestMain: mock_client.ensure_label.assert_called_once() def test_main_api_error(self) -> None: - with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True): + with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True): with patch("scripts.configure_repo.GiteaClient") as mock_client_cls: mock_client = MagicMock() mock_client.ensure_branch_protection.side_effect = APIError(http.HTTPStatus.FORBIDDEN, "Forbidden") @@ -79,7 +79,7 @@ class TestMain: def test_main_module_block() -> None: - with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True): + with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True): with patch("scripts.configure_repo.GiteaClient") as mock_client_cls: mock_client = MagicMock() mock_client_cls.return_value = mock_client diff --git a/tests/unit/test_publish.py b/tests/unit/test_publish.py index 25613f1..21cdf8a 100644 --- a/tests/unit/test_publish.py +++ b/tests/unit/test_publish.py @@ -49,7 +49,7 @@ class TestPublishToPypi: class TestMain: - @patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) + @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch("scripts.publish.GiteaClient") @patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.build_package") @@ -67,7 +67,7 @@ class TestMain: mock_publish.assert_called_once_with("pypi-tok") mock_client_cls.return_value.create_release.assert_called_once() - @patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok"}, clear=True) + @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True) @patch("scripts.publish.GiteaClient") @patch("scripts.publish.build_package") def test_without_pypi( @@ -82,14 +82,14 @@ class TestMain: mock_client_cls.return_value.create_release.assert_called_once() assert "PYPI_TOKEN not set" in result.output - @patch.dict("os.environ", {"GITEA_TOKEN": ""}, clear=True) - def test_missing_gitea_token_exits(self) -> None: + @patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) + def test_missing_repo_token_exits(self) -> None: runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 1 - assert "GITEA_TOKEN" in result.output + assert "REPO_TOKEN" in result.output - @patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) + @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch("scripts.publish.GiteaClient") @patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.build_package") @@ -102,7 +102,7 @@ class TestMain: assert result.exit_code == 1 assert "build" in result.output - @patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) + @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch("scripts.publish.GiteaClient") @patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.build_package") @@ -115,7 +115,7 @@ class TestMain: assert result.exit_code == 1 assert "publish" in result.output - @patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) + @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch("scripts.publish.GiteaClient") @patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.build_package") @@ -132,7 +132,7 @@ class TestMain: assert result.exit_code == 1 assert "HTTP" in result.output - @patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) + @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch("scripts.publish.GiteaClient") @patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.build_package") diff --git a/tests/unit/test_validate_commit_msg.py b/tests/unit/test_validate_commit_msg.py index ce231a8..973f71d 100644 --- a/tests/unit/test_validate_commit_msg.py +++ b/tests/unit/test_validate_commit_msg.py @@ -146,6 +146,9 @@ def test_main_module_block() -> None: source = source.replace('if __name__ == "__main__":\n main()\n', "") namespace = dict(vcm.__dict__) exec(compile(source, vcm.__file__, "exec"), namespace) + # exec() redefines get_branch() from source, overwriting the mock. + # Restore the patched mock so main() uses it. + namespace["get_branch"] = vcm.get_branch namespace["main"]([msg_path], standalone_mode=False) os.unlink(msg_path)