GRM-24: feat: bandit integration (#1)
Co-authored-by: Emil Simeonov <emil@theliberatededge.org> Reviewed-on: #1
This commit is contained in:
+1
-1
@@ -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.
|
||||
|
||||
@@ -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 }}" \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+4
@@ -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
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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", "")
|
||||
|
||||
|
||||
@@ -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 --
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 не е зададен.",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user