GRM-24: feat: bandit integration (#1)
CI / lint (push) Has been cancelled
CI / unit-tests (push) Has been cancelled
CI / molecule-tests (push) Has been cancelled
Post-merge Vikunja update / vikunja (push) Has been cancelled

Co-authored-by: Emil Simeonov <emil@theliberatededge.org>
Reviewed-on: #1
This commit is contained in:
2026-06-19 21:17:39 +00:00
co-authored by Emil Simeonov
parent d8c31238bd
commit d949bd3444
21 changed files with 325 additions and 75 deletions
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 }}" \
+1 -1
View File
@@ -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 \
+6 -1
View File
@@ -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
+4
View File
@@ -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
Executable
+4
View File
@@ -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
+6 -3
View File
@@ -2,18 +2,21 @@
"""Auto-merge PR by extracting task ID from branch and validating PR title.
Usage:
GITEA_TOKEN=<token> python3 scripts/auto_merge.py <branch> <pr_title> <repo> <pr_number>
REPO_TOKEN=<token> python3 scripts/auto_merge.py <branch> <pr_title> <repo> <pr_number>
"""
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:
+92
View File
@@ -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
+7 -4
View File
@@ -2,7 +2,7 @@
"""Configure GRM repository: branch protection + labels via Gitea REST API.
Usage:
GITEA_ADMIN_TOKEN=<token> python3 scripts/configure_repo.py
REPO_TOKEN=<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
+3
View File
@@ -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."""
+6 -3
View File
@@ -2,7 +2,7 @@
"""Build package, optionally publish to PyPI, and create Gitea release.
Usage:
GITEA_TOKEN=<token> [PYPI_TOKEN=<token>] python3 scripts/publish.py <tag> <repo>
REPO_TOKEN=<token> [PYPI_TOKEN=<token>] python3 scripts/publish.py <tag> <repo>
"""
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", "")
+6 -7
View File
@@ -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 --
+2 -2
View File
@@ -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",
+12 -19
View File
@@ -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 не е зададен.",
+8 -7
View File
@@ -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")
+7 -7
View File
@@ -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()
+137
View File
@@ -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)
+5 -5
View File
@@ -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(
+5 -5
View File
@@ -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
+9 -9
View File
@@ -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")
+3
View File
@@ -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)