From fc7fc48b4a0aecb2be4798ed60d8e2778dc0742f Mon Sep 17 00:00:00 2001 From: emil User Date: Sun, 9 Aug 2026 00:59:55 +0000 Subject: [PATCH] DEVX-152: fix: remove dead translation keys and add missing one Co-authored-by: emil User --- AGENTS.md | 1 - README.md | 5 - docs/index.md | 6 +- docs/tech/architecture.md | 14 +-- docs/tech/ci-cd-workflow.md | 9 -- docs/user/cli-commands.md | 26 ------ src/devx/ci/doc_coverage.py | 1 - src/devx/ci/integration_guard.py | 58 ++++++++++-- src/devx/cli.py | 7 -- src/devx/translations.json | 54 ++--------- tests/unit/test_cli.py | 8 -- tests/unit/test_integration_guard.py | 132 +++++++++++++++++++++++++-- 12 files changed, 190 insertions(+), 131 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4386a2d..ecab142 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,7 +141,6 @@ src/devx/ └── molecule/ # Optional molecule testing helpers (for Ansible projects) ├── discover_runners.py # Dynamic Gitea runner discovery ├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role) - ├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast (--roles-root) ├── molecule_all.py # Run all molecule scenarios locally ├── start_docker.py # Ensure Docker daemon is running for molecule tests └── platforms.py # Supported molecule platforms diff --git a/README.md b/README.md index c04dbfe..0fba4d0 100644 --- a/README.md +++ b/README.md @@ -226,10 +226,6 @@ python -m devx.molecule.distribute_molecule --runner-index 1 --max-runners 3 python -m devx.molecule.distribute_molecule --list # list all scenarios python -m devx.molecule.distribute_molecule --list-platforms # list platforms -# Run molecule tests with cross-runner fail-fast -python -m devx.molecule.molecule_ci_guard pair1 pair2 -python -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2 - # Run all molecule scenarios locally (sequential) python -m devx.molecule.molecule_all python -m devx.molecule.molecule_all --bin .venv/bin @@ -303,7 +299,6 @@ devx --version | `devx molecule all` | Run all molecule scenarios on all supported platforms | | `devx molecule discover-runners` | Discover available Gitea Actions runners | | `devx molecule distribute` | Distribute molecule test pairs across parallel runners | -| `devx molecule guard` | Run molecule tests with CI failure polling | See [CLI Commands](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki/CLI-Commands) in the wiki for full command documentation with examples. diff --git a/docs/index.md b/docs/index.md index 962f8f9..caef7cf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -104,8 +104,8 @@ devx is a self-contained Python package under `src/devx/`: - **Dev tools** (`devx.tools`) — setup, install_tools, check_test_speed, configure_repo, generate_badges, generate_cliff_config, install_checkmake - **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible - roles: distribute_molecule, molecule_ci_guard, molecule_all, discover_runners, - start_docker, platforms + roles: distribute_molecule, molecule_all, discover_runners, start_docker, + platforms See [Architecture](Architecture) for the full package structure, module descriptions, design principles, and data flow diagrams. @@ -132,7 +132,7 @@ devx provides a `devx` CLI with three command groups: - `devx ci ` — CI/CD automation (17 commands) - `devx tools ` — Developer tools (9 commands) -- `devx molecule ` — Molecule testing (4 commands, optional) +- `devx molecule ` — Molecule testing (3 commands, optional) See [CLI Commands](CLI-Commands) for full command documentation with examples. diff --git a/docs/tech/architecture.md b/docs/tech/architecture.md index 3cc658c..e5c6864 100644 --- a/docs/tech/architecture.md +++ b/docs/tech/architecture.md @@ -50,7 +50,6 @@ src/devx/ ├── __init__.py ├── discover_runners.py # Dynamic Gitea runner discovery ├── distribute_molecule.py # Distribute scenarios across runners - ├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast ├── molecule_all.py # Run all molecule scenarios locally ├── start_docker.py # Ensure Docker is available for molecule └── platforms.py # Supported molecule platforms @@ -300,9 +299,9 @@ Distributes files matching a glob pattern across N parallel runners ### `integration_guard.py` -Runs pytest with the same cross-runner failure detection mechanism used by -`molecule_ci_guard`. If any other integration-tests matrix runner reports -failure, the current pytest subprocess is killed and this runner exits early. +Runs pytest with cross-runner failure detection. A background thread polls +the Gitea API. If any other integration-tests matrix runner reports failure, +the current pytest subprocess is killed and this runner exits early. ## Developer tools (`devx.tools`) @@ -382,13 +381,6 @@ the supported OS platform matrix. Supports `--roles-root` for multi-role repositories, `--list` to list scenarios, and `--list-platforms` to list platforms. -### `molecule_ci_guard.py` - -Runs molecule tests sequentially while polling the Gitea API for other runner -failures. If any other molecule matrix runner reports failure, the current -molecule subprocess is killed and this runner exits early. Supports both -single-role (4-part) and multi-role (5-part) pair encoding. - ### `molecule_all.py` Runs all molecule scenarios on all supported OS platforms sequentially. diff --git a/docs/tech/ci-cd-workflow.md b/docs/tech/ci-cd-workflow.md index 094dbbc..ef2dbde 100644 --- a/docs/tech/ci-cd-workflow.md +++ b/docs/tech/ci-cd-workflow.md @@ -478,15 +478,6 @@ python -m devx.molecule.distribute_molecule --list python -m devx.molecule.distribute_molecule --list-platforms ``` -### `molecule_ci_guard.py` - -Runs molecule tests sequentially while polling the Gitea API for other runner -failures. Aborts early if another runner fails the same job. - -```bash -python -m devx.molecule.molecule_ci_guard [--roles-root ] pair1 pair2 ... -``` - ### `validate_commit_msg.py` Validates commit messages. On feature branches: conventional commits only diff --git a/docs/user/cli-commands.md b/docs/user/cli-commands.md index 915c77c..8d90026 100644 --- a/docs/user/cli-commands.md +++ b/docs/user/cli-commands.md @@ -631,29 +631,3 @@ Options: - `--list-platforms` — list all platforms, one per line - `--roles-root ` — roles root directory for multi-role repos (default: `ansible/roles`) - -### `devx molecule guard` - -Run molecule tests sequentially with CI failure polling. A background thread -polls the Gitea API. If any other molecule matrix runner reports failure, the -current molecule subprocess is killed and this runner exits early with code 1. - -```bash -devx molecule guard pair1 pair2 pair3 -devx molecule guard --roles-root ansible/roles pair1 pair2 -``` - -Each pair is encoded as: -- **Single-role (4-part):** `scenario|platform_name|platform_image|platform_command` -- **Multi-role (5-part):** `role|scenario|platform_name|platform_image|platform_command` - -Options: -- `--roles-root ` — roles root directory for multi-role repos - -Environment variables: -- `GITEA_URL` — base URL of the Gitea instance -- `CI_GITEA_TOKEN` — API token with repo access -- `RUN_ID` — workflow run ID (`GITHUB_RUN_ID`) -- `JOB_NAME` — base job name (`GITHUB_JOB`) -- `MATRIX_INDEX` — current matrix index (runner-index) -- `GITEA_REPOSITORY` — repository in `owner/repo` format diff --git a/src/devx/ci/doc_coverage.py b/src/devx/ci/doc_coverage.py index e3ccbf7..2063c7b 100644 --- a/src/devx/ci/doc_coverage.py +++ b/src/devx/ci/doc_coverage.py @@ -52,7 +52,6 @@ REQUIRED_SCRIPTS = [ "detect_release_commit.py", "push_badges.py", "distribute_molecule.py", - "molecule_ci_guard.py", "validate_commit_msg.py", ] diff --git a/src/devx/ci/integration_guard.py b/src/devx/ci/integration_guard.py index 5fba8f5..9d1f952 100644 --- a/src/devx/ci/integration_guard.py +++ b/src/devx/ci/integration_guard.py @@ -1,10 +1,9 @@ #!/usr/bin/env python3 """Run integration tests with cross-runner failure detection. -Wraps ``pytest`` with the same Gitea API polling mechanism used by -``molecule_ci_guard``. If any other integration-tests matrix runner -reports failure, the current pytest subprocess is killed and this runner -exits early with code 1. +Wraps ``pytest`` with Gitea API polling. If any other integration-tests +matrix runner reports failure, the current pytest subprocess is killed +and this runner exits early with code 1. Usage:: @@ -35,17 +34,62 @@ import threading import time import click +import requests from devx.config import REPO_NAME, REPO_OWNER from devx.i18n import _ -from devx.molecule.molecule_ci_guard import ( - poll_for_other_failures, -) from devx.tokens import get_ci_token POLL_INTERVAL = 10 +def get_running_jobs(gitea_url: str, owner: str, repo: str, token: str, run_id: int) -> list[dict]: + """Return jobs for the given workflow run.""" + url = f"{gitea_url}/api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/jobs" + headers = {"Authorization": f"token {token}"} + response = requests.get(url, headers=headers, timeout=30) + response.raise_for_status() + data = response.json() + return data.get("jobs", []) + + +def any_other_runner_failed(jobs: list[dict], current_job_name: str, current_index: int) -> bool: + """Return True if any other matrix job has failed.""" + for job in jobs: + name = job.get("name", "") + if not name.startswith(current_job_name): + continue + if name == f"{current_job_name} ({current_index})" or name == current_job_name: + continue + if job.get("conclusion") == "failure": + return True + return False + + +def poll_for_other_failures( + gitea_url: str, + owner: str, + repo: str, + token: str, + run_id: int, + job_name: str, + current_index: int, + stop_event: threading.Event, + failed_event: threading.Event, +) -> None: + """Background thread: poll API and signal if another runner fails.""" + while not stop_event.is_set(): + try: + jobs = get_running_jobs(gitea_url, owner, repo, token, run_id) + if any_other_runner_failed(jobs, job_name, current_index): + click.echo(_("Another runner failed. Stopping this runner early.")) + failed_event.set() + return + except requests.RequestException as exc: + click.echo(_("API poll warning: {exc}", exc=exc)) + stop_event.wait(POLL_INTERVAL) + + @click.command(context_settings={"ignore_unknown_options": True}) @click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED, required=True) def cli(pytest_args: tuple[str, ...]) -> None: diff --git a/src/devx/cli.py b/src/devx/cli.py index 94a0c94..758e33f 100644 --- a/src/devx/cli.py +++ b/src/devx/cli.py @@ -301,13 +301,6 @@ def molecule_discover_runners(args: tuple[str, ...]) -> None: _run_module("devx.molecule.discover_runners", list(args)) -@molecule.command("guard") -@click.argument("args", nargs=-1) -def molecule_guard(args: tuple[str, ...]) -> None: - """Run molecule tests sequentially with CI failure polling.""" - _run_module("devx.molecule.molecule_ci_guard", list(args)) - - @molecule.command("all") @click.argument("args", nargs=-1) def molecule_all(args: tuple[str, ...]) -> None: diff --git a/src/devx/translations.json b/src/devx/translations.json index ac717cc..dc3f151 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -783,14 +783,6 @@ "ru": "Additional directory to scan (default: scripts, tests). Can be repeated.", "zh": "Additional directory to scan (default: scripts, tests). Can be repeated." }, - "All molecule tests passed.": { - "bg": "All molecule tests passed.", - "de": "All molecule tests passed.", - "en": "All molecule tests passed.", - "pl": "Wszystkie testy molecule zakończone pomyślnie.", - "ru": "All molecule tests passed.", - "zh": "All molecule tests passed." - }, "Allow empty tag (PR mode where SHA is concrete).": { "bg": "Позволи празен таг (PR режим, където SHA е конкретен).", "de": "Leeren Tag zulassen (PR-Modus, in dem SHA konkret ist).", @@ -799,13 +791,13 @@ "ru": "Разрешить пустой тег (режим PR, где SHA конкретен).", "zh": "允许空标签(SHA 为具体值的 PR 模式)。" }, - "Another molecule runner failed. Stopping this runner early.": { - "bg": "Another molecule runner failed. Stopping this runner early.", - "de": "Another molecule runner failed. Stopping this runner early.", - "en": "Another molecule runner failed. Stopping this runner early.", - "pl": "Inny runner molecule zakończył się niepowodzeniem. Wczesne zatrzymanie tego runnera.", - "ru": "Another molecule runner failed. Stopping this runner early.", - "zh": "Another molecule runner failed. Stopping this runner early." + "Another runner failed. Stopping this runner early.": { + "bg": "Друг runner се провали. Спиране на този runner по-рано.", + "de": "Ein anderer Runner ist fehlgeschlagen. Dieser Runner wird vorzeitig gestoppt.", + "en": "Another runner failed. Stopping this runner early.", + "pl": "Inny runner zakończył się niepowodzeniem. Wczesne zatrzymanie tego runnera.", + "ru": "Другой runner завершился с ошибкой. Останавливаю этот runner досрочно.", + "zh": "另一个 runner 失败。提前停止此 runner。" }, "Assigned {count} files to runner {runner_index}": { "bg": "Assigned {count} files to runner {runner_index}", @@ -1495,14 +1487,6 @@ "ru": "FAILED: {count} undocumented dependency/ies", "zh": "FAILED: {count} undocumented dependency/ies" }, - "FAILED: {pair} exited with code {code}": { - "bg": "FAILED: {pair} exited with code {code}", - "de": "FAILED: {pair} exited with code {code}", - "en": "FAILED: {pair} exited with code {code}", - "pl": "NIEUDANE: {pair} zakończone kodem {code}", - "ru": "FAILED: {pair} exited with code {code}", - "zh": "FAILED: {pair} exited with code {code}" - }, "Failed images: {names}": { "bg": "Failed images: {names}", "de": "Failed images: {names}", @@ -2263,14 +2247,6 @@ "ru": "PASS: All documentation checks passed!", "zh": "PASS: All documentation checks passed!" }, - "PASSED: {pair}": { - "bg": "PASSED: {pair}", - "de": "PASSED: {pair}", - "en": "PASSED: {pair}", - "pl": "UDANE: {pair}", - "ru": "PASSED: {pair}", - "zh": "PASSED: {pair}" - }, "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.": { "bg": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.", "de": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.", @@ -2743,14 +2719,6 @@ "ru": "Running: {cmd}", "zh": "Running: {cmd}" }, - "Running: {scenario} on {platform}": { - "bg": "Running: {scenario} on {platform}", - "de": "Running: {scenario} on {platform}", - "en": "Running: {scenario} on {platform}", - "pl": "Uruchamianie: {scenario} na {platform}", - "ru": "Running: {scenario} on {platform}", - "zh": "Running: {scenario} on {platform}" - }, "SSH key set up successfully": { "bg": "SSH ключът е настроен успешно", "de": "SSH-Schlüssel erfolgreich eingerichtet", @@ -3830,13 +3798,5 @@ "pl": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)", "ru": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)", "zh": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)" - }, - "Cleaning up: running molecule destroy for {scenario}": { - "en": "Cleaning up: running molecule destroy for {scenario}", - "bg": "Изчистване: изпълнение на molecule destroy за {scenario}", - "de": "Aufräumen: molecule destroy wird ausgeführt für {scenario}", - "pl": "Czyszczenie: uruchamianie molecule destroy dla {scenario}", - "ru": "Очистка: запуск molecule destroy для {scenario}", - "zh": "清理:正在为 {scenario} 运行 molecule destroy" } } diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index ab5de0a..4f05ea0 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -40,7 +40,6 @@ class TestCliGroups: result = runner.invoke(cli, ["molecule", "--help"]) assert result.exit_code == 0 assert "distribute" in result.output - assert "guard" in result.output assert "all" in result.output @@ -231,13 +230,6 @@ class TestMoleculeCommands: assert result.exit_code == 0 mock_run.assert_called_once_with("devx.molecule.discover_runners", []) - @patch("devx.cli._run_module") - def test_molecule_guard(self, mock_run: MagicMock) -> None: - runner = CliRunner() - result = runner.invoke(cli, ["molecule", "guard"]) - assert result.exit_code == 0 - mock_run.assert_called_once_with("devx.molecule.molecule_ci_guard", []) - @patch("devx.cli._run_module") def test_molecule_all(self, mock_run: MagicMock) -> None: runner = CliRunner() diff --git a/tests/unit/test_integration_guard.py b/tests/unit/test_integration_guard.py index 07607bf..b77a2a3 100644 --- a/tests/unit/test_integration_guard.py +++ b/tests/unit/test_integration_guard.py @@ -7,6 +7,7 @@ import subprocess # nosec B404 import time from unittest.mock import MagicMock, patch +import pytest from click.testing import CliRunner from devx.ci.integration_guard import cli @@ -111,9 +112,8 @@ class TestCli: clear=True, ), patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01), - patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01), patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen, - patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect), + patch("devx.ci.integration_guard.get_running_jobs", side_effect=get_jobs_side_effect), patch("os.killpg") as mock_killpg, patch("os.getpgid") as mock_getpgid, patch("time.sleep", side_effect=lambda x: real_sleep(0)), @@ -158,9 +158,8 @@ class TestCli: clear=True, ), patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01), - patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01), patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen, - patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect), + patch("devx.ci.integration_guard.get_running_jobs", side_effect=get_jobs_side_effect), patch("os.killpg", side_effect=ProcessLookupError("no such process")), patch("os.getpgid") as mock_getpgid, patch("time.sleep", side_effect=lambda x: real_sleep(0)), @@ -203,9 +202,8 @@ class TestCli: clear=True, ), patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01), - patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01), patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen, - patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect), + patch("devx.ci.integration_guard.get_running_jobs", side_effect=get_jobs_side_effect), patch("os.killpg") as mock_killpg, patch("os.getpgid") as mock_getpgid, patch("time.sleep", side_effect=lambda x: real_sleep(0)), @@ -289,3 +287,125 @@ def test_main_module_block() -> None: namespace = dict(ig.__dict__) exec(compile(source, ig.__file__, "exec"), namespace) assert callable(namespace["cli"]) + + +class TestGetRunningJobs: + def test_returns_jobs(self) -> None: + with patch("devx.ci.integration_guard.requests.get") as mock_get: + mock_response = MagicMock() + mock_response.json.return_value = { + "jobs": [ + {"name": "integration-tests (0)", "conclusion": "success"}, + {"name": "integration-tests (1)", "conclusion": "failure"}, + ] + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + from devx.ci.integration_guard import get_running_jobs + + jobs = get_running_jobs("https://gitea.example", "owner", "repo", "token", 123) + assert len(jobs) == 2 + mock_get.assert_called_once() + + def test_raises_on_request_error(self) -> None: + import requests + + with patch("devx.ci.integration_guard.requests.get") as mock_get: + mock_get.side_effect = requests.RequestException("boom") + with pytest.raises(requests.RequestException): + from devx.ci.integration_guard import get_running_jobs + + get_running_jobs("https://gitea.example", "owner", "repo", "token", 123) + + +class TestAnyOtherRunnerFailed: + def test_detects_other_failure(self) -> None: + from devx.ci.integration_guard import any_other_runner_failed + + jobs = [ + {"name": "integration-tests (0)", "conclusion": "success"}, + {"name": "integration-tests (1)", "conclusion": "failure"}, + {"name": "integration-tests (2)", "conclusion": "running"}, + ] + assert any_other_runner_failed(jobs, "integration-tests", 0) is True + + def test_ignores_current_runner(self) -> None: + from devx.ci.integration_guard import any_other_runner_failed + + jobs = [ + {"name": "integration-tests (0)", "conclusion": "failure"}, + {"name": "integration-tests (1)", "conclusion": "success"}, + ] + assert any_other_runner_failed(jobs, "integration-tests", 0) is False + + def test_ignores_non_matching_jobs(self) -> None: + from devx.ci.integration_guard import any_other_runner_failed + + jobs = [ + {"name": "quality", "conclusion": "failure"}, + {"name": "integration-tests (1)", "conclusion": "success"}, + ] + assert any_other_runner_failed(jobs, "integration-tests", 0) is False + + +class TestPollForOtherFailures: + def test_sets_failed_event_when_other_runner_fails(self) -> None: + from devx.ci.integration_guard import poll_for_other_failures + + stop_event = MagicMock() + failed_event = MagicMock() + + def side_effect(*args, **kwargs): + if stop_event.wait.call_count < 1: + return [ + {"name": "integration-tests (0)", "conclusion": "success"}, + {"name": "integration-tests (1)", "conclusion": "failure"}, + ] + return [] + + with patch("devx.ci.integration_guard.get_running_jobs") as mock_get_jobs: + mock_get_jobs.side_effect = side_effect + stop_event.is_set.side_effect = [False, False] + stop_event.wait.return_value = True + + poll_for_other_failures( + "https://gitea.example", + "owner", + "repo", + "token", + 123, + "integration-tests", + 0, + stop_event, + failed_event, + ) + + failed_event.set.assert_called_once() + + def test_poll_warns_on_api_error(self) -> None: + import requests + + from devx.ci.integration_guard import poll_for_other_failures + + stop_event = MagicMock() + failed_event = MagicMock() + + with patch("devx.ci.integration_guard.get_running_jobs") as mock_get_jobs: + mock_get_jobs.side_effect = requests.RequestException("boom") + stop_event.is_set.side_effect = [False, True] + stop_event.wait.return_value = True + + poll_for_other_failures( + "https://gitea.example", + "owner", + "repo", + "token", + 123, + "integration-tests", + 0, + stop_event, + failed_event, + ) + + failed_event.set.assert_not_called()