From 23183df7c7d249166b49f67a47f408a29387a525 Mon Sep 17 00:00:00 2001 From: emil Date: Tue, 23 Jun 2026 13:37:10 +0000 Subject: [PATCH] DEVX-12: feat: add opentofu helpers, CLI entry points, shared utility, and CI improvements --- .gitea/workflows/ci.yml | 2 +- .taskid | 2 +- AGENTS.md | 16 +- docs/user/cli-commands.md | 12 + src/devx/__init__.py | 2 +- src/devx/ci/_shared.py | 18 + src/devx/ci/classify_changes.py | 14 +- src/devx/ci/distribute_files.py | 121 ++ src/devx/ci/integration_guard.py | 138 ++ src/devx/ci/merge_junit.py | 97 + src/devx/ci/notify_failure.py | 63 +- src/devx/ci/publish.py | 38 +- src/devx/ci/push_badges.py | 35 +- src/devx/ci/release.py | 9 +- src/devx/cli.py | 21 + src/devx/molecule/distribute_molecule.py | 137 +- src/devx/molecule/molecule_ci_guard.py | 139 +- src/devx/opentofu.py | 113 ++ src/devx/translations.json | 2345 +++++++++++----------- tests/unit/test_cli.py | 23 + tests/unit/test_distribute_files.py | 154 ++ tests/unit/test_distribute_molecule.py | 200 ++ tests/unit/test_integration_guard.py | 308 +++ tests/unit/test_merge_junit.py | 91 + tests/unit/test_molecule_ci_guard.py | 243 +++ tests/unit/test_notify_failure.py | 104 +- tests/unit/test_opentofu.py | 192 ++ tests/unit/test_publish.py | 17 + tests/unit/test_push_badges.py | 64 + tests/unit/test_release.py | 15 +- 30 files changed, 3514 insertions(+), 1219 deletions(-) create mode 100644 src/devx/ci/_shared.py create mode 100644 src/devx/ci/distribute_files.py create mode 100644 src/devx/ci/integration_guard.py create mode 100644 src/devx/ci/merge_junit.py create mode 100644 src/devx/opentofu.py create mode 100644 tests/unit/test_distribute_files.py create mode 100644 tests/unit/test_integration_guard.py create mode 100644 tests/unit/test_merge_junit.py create mode 100644 tests/unit/test_opentofu.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f344384..b76c792 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: PYTHONPATH: src run: | . .venv/bin/activate - python3 -m devx.tools.check_test_speed --max-seconds 10 + python3 -m devx.tools.check_test_speed --max-seconds 60 - name: Documentation coverage check env: PYTHONPATH: src diff --git a/.taskid b/.taskid index 8cf990a..365bfc7 100644 --- a/.taskid +++ b/.taskid @@ -1 +1 @@ -DEVX-10 +DEVX-12 diff --git a/AGENTS.md b/AGENTS.md index 45c6afa..d0acb5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,16 +55,20 @@ src/devx/ ├── translations.json # Translation strings (en, bg) ├── ci/ # CI/CD automation modules (run by workflows) │ ├── release.py # Automated versioning, tagging, changelog -│ ├── publish.py # Build and publish to Gitea PyPI registry +│ ├── publish.py # Build and publish to Gitea PyPI registry (--skip-build for non-Python repos) │ ├── auto_merge.py # Squash-merge PRs with task ID validation +│ ├── _shared.py # Shared utilities (get_latest_tag) │ ├── classify_changes.py # User-facing vs workflow-only change detection │ ├── detect_release_commit.py # Detect release commits on master │ ├── validate_commit_msg.py # Conventional commit validation │ ├── pr_review.py # Automated PR review │ ├── post_merge.py # Vikunja task updates after merge │ ├── sync_wiki.py # Sync documentation to Gitea wiki -│ ├── push_badges.py # Generate and push quality badges -│ ├── notify_failure.py # Create Gitea issues on CI failures +│ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures) +│ ├── notify_failure.py # Create Gitea issues on CI failures (--auto-login) +│ ├── merge_junit.py # Merge JUnit XML reports from parallel runners +│ ├── distribute_files.py # Distribute files across parallel runners +│ ├── integration_guard.py # Run pytest with cross-runner fail-fast + JUnit output │ ├── check_translations.py # Translation completeness check │ └── doc_coverage.py # Documentation coverage check ├── tools/ # Developer tooling modules (run locally or by CI) @@ -73,7 +77,13 @@ src/devx/ │ ├── check_test_speed.py # Measure unit test execution time │ ├── configure_repo.py # Branch protection and label setup │ └── generate_badges.py # Badge SVG generation +├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field) └── molecule/ # Optional molecule testing helpers (for Ansible projects) + ├── discover_runners.py # Dynamic Gitea runner discovery + ├── distribute_molecule.py # Distribute molecule scenarios across runners (--roles-root for multi-role) + ├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast + JUnit output (--roles-root, --junit-output) + ├── molecule_all.py # Run all molecule scenarios locally + └── platforms.py # Supported molecule platforms ``` ### Key Design Principles diff --git a/docs/user/cli-commands.md b/docs/user/cli-commands.md index 0b97316..55a437b 100644 --- a/docs/user/cli-commands.md +++ b/docs/user/cli-commands.md @@ -24,10 +24,22 @@ Detect whether the latest git commit is a release commit (`release: vX.Y.Z [skip Discover available Gitea Actions runners for dynamic job distribution. +### `devx ci distribute-files` + +Distribute files across parallel runners (round-robin). Used for splitting test suites or workloads across CI runners. + ### `devx ci doc-coverage` Check documentation coverage for CLI commands and major modules. +### `devx ci integration-guard` + +Run pytest with cross-runner failure detection and JUnit XML output. Monitors other runners for failures and aborts early if a critical failure is detected. + +### `devx ci merge-junit` + +Merge multiple JUnit XML reports from parallel runners into a single consolidated report. + ### `devx ci notify-failure` Create a Gitea issue when a CI workflow fails. diff --git a/src/devx/__init__.py b/src/devx/__init__.py index cdd83f0..2fb6329 100644 --- a/src/devx/__init__.py +++ b/src/devx/__init__.py @@ -1,3 +1,3 @@ """devx — reusable development and CI/CD tools for oblachno-oss projects.""" -__version__ = "0.5.0" +__version__ = "0.6.0" diff --git a/src/devx/ci/_shared.py b/src/devx/ci/_shared.py new file mode 100644 index 0000000..867fbbb --- /dev/null +++ b/src/devx/ci/_shared.py @@ -0,0 +1,18 @@ +"""Shared utilities for CI modules.""" + +from __future__ import annotations + +import subprocess # nosec B404 + + +def get_latest_tag() -> str: + """Get the latest git tag, or empty string if none exists.""" + result = subprocess.run( # nosec B603 B607 + ["git", "describe", "--tags", "--abbrev=0"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return "" + return result.stdout.strip() diff --git a/src/devx/ci/classify_changes.py b/src/devx/ci/classify_changes.py index 21f2957..1581dbc 100644 --- a/src/devx/ci/classify_changes.py +++ b/src/devx/ci/classify_changes.py @@ -138,6 +138,7 @@ from typing import Any import click +from devx.ci._shared import get_latest_tag from devx.i18n import _ # --------------------------------------------------------------------------- @@ -494,19 +495,6 @@ def get_changed_files(base: str, head: str) -> list[str]: return output.split("\n") -def get_latest_tag() -> str: - """Get the latest git tag, or empty string if none exists.""" - result = subprocess.run( # nosec B603 B607 - ["git", "describe", "--tags", "--abbrev=0"], - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - return "" - return result.stdout.strip() - - # --------------------------------------------------------------------------- # Backward-compatible API (used by release.py and CI workflows) # --------------------------------------------------------------------------- diff --git a/src/devx/ci/distribute_files.py b/src/devx/ci/distribute_files.py new file mode 100644 index 0000000..3451967 --- /dev/null +++ b/src/devx/ci/distribute_files.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Distribute a list of files across N parallel runners (round-robin). + +Generic file-based test distribution for CI matrix jobs. Discovers files +matching a glob pattern, sorts them for deterministic ordering, then +assigns them round-robin to *max_runners* groups. The assigned group for +*runner_index* is written to ``$GITHUB_ENV`` for use by subsequent steps. + +Usage:: + + python3 -m devx.ci.distribute_files \\ + --pattern "tests/integration/test_*.py" \\ + --runner-index 1 \\ + --max-runners 3 \\ + --github-env --skip-if-excess +""" + +from __future__ import annotations + +import glob +import os + +import click + +from devx.i18n import _ + +DEFAULT_MAX_RUNNERS = 3 + + +def discover_files(pattern: str) -> list[str]: + """Return sorted list of file paths matching *pattern*.""" + return sorted(glob.glob(pattern)) + + +def distribute(files: list[str], max_runners: int) -> list[list[str]]: + """Split *files* into *max_runners* balanced groups (round-robin).""" + groups: list[list[str]] = [[] for _ in range(max_runners)] + for i, f in enumerate(files): + groups[i % max_runners].append(f) + return groups + + +def files_for_runner(files: list[str], runner_index: int, max_runners: int) -> list[str]: + """Return the subset of files assigned to *runner_index* (0-based).""" + groups = distribute(files, max_runners) + if runner_index < 0 or runner_index >= len(groups): + raise click.ClickException( + _("Runner index {index} out of range (0..{max})", index=runner_index, max=max_runners - 1) + ) + return groups[runner_index] + + +def _write_github_env(key: str, value: str) -> None: + gh_env = os.environ.get("GITHUB_ENV") + if not gh_env: + raise click.ClickException("GITHUB_ENV environment variable is not set") + with open(gh_env, "a") as f: # noqa: PTH123 + f.write(f"{key}={value}\n") + + +@click.command() +@click.option("--pattern", required=True, help="Glob pattern for files to distribute.") +@click.option( + "--runner-index", + type=int, + default=None, + help="One-based runner index. If omitted, prints all groups.", +) +@click.option( + "--max-runners", + type=int, + default=DEFAULT_MAX_RUNNERS, + show_default=True, + help="Total number of parallel runners.", +) +@click.option( + "--github-env", + is_flag=True, + default=False, + help="Write ASSIGNED_FILES and SKIP to $GITHUB_ENV.", +) +@click.option( + "--skip-if-excess", + is_flag=True, + default=False, + help="With --github-env: write SKIP=true when runner-index exceeds max-runners.", +) +def main(pattern: str, runner_index: int | None, max_runners: int, github_env: bool, skip_if_excess: bool) -> None: + files = discover_files(pattern) + + if runner_index is None: + groups = distribute(files, max_runners) + for i, group in enumerate(groups): + labels = " ".join(group) if group else "(none)" + click.echo(f"Runner {i}: {labels}") + return + + if skip_if_excess and github_env and runner_index > max_runners: + click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}") + _write_github_env("ASSIGNED_FILES", "") + _write_github_env("SKIP", "true") + return + + if runner_index < 1: + raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)") + + zero_based = runner_index - 1 + assigned = files_for_runner(files, zero_based, max_runners) + encoded = "\n".join(assigned) + + if github_env: + _write_github_env("ASSIGNED_FILES", encoded) + _write_github_env("SKIP", "false") + click.echo(f"Assigned {len(assigned)} files to runner {runner_index}") + return + + click.echo(encoded) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/src/devx/ci/integration_guard.py b/src/devx/ci/integration_guard.py new file mode 100644 index 0000000..ab26dec --- /dev/null +++ b/src/devx/ci/integration_guard.py @@ -0,0 +1,138 @@ +#!/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. + +JUnit XML is generated via pytest's ``--junitxml`` flag (passed through +to the pytest invocation). + +Usage:: + + python3 -m devx.ci.integration_guard \\ + --junit-output junit-results/runner-1.xml \\ + -- test_file1.py test_file2.py + + # With pytest options + python3 -m devx.ci.integration_guard \\ + --junit-output junit-results/runner-1.xml \\ + -- -x -v --tb=short test_file1.py + +Environment variables: + GITEA_URL Base URL of the Gitea instance. + REPO_TOKEN API token with repo access. + RUN_ID Workflow run ID (GITHUB_RUN_ID). + JOB_NAME Base job name (GITHUB_JOB), e.g. "integration-tests". + MATRIX_INDEX Current matrix index (runner-index). + GITEA_REPOSITORY Repository in "owner/repo" format. +""" + +from __future__ import annotations + +import contextlib +import os +import signal +import subprocess # nosec B404 +import sys +import threading +import time + +import click + +from devx.i18n import _ +from devx.molecule.molecule_ci_guard import ( + poll_for_other_failures, +) + +POLL_INTERVAL = 10 + + +@click.command(context_settings={"ignore_unknown_options": True}) +@click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED, required=True) +@click.option( + "--junit-output", + default=None, + help="Path for JUnit XML output (passed to pytest as --junitxml).", +) +def cli(pytest_args: tuple[str, ...], junit_output: str | None) -> None: + """Run pytest with cross-runner failure detection.""" + gitea_url = os.environ.get("GITEA_URL", "") + token = os.environ.get("REPO_TOKEN", "") + run_id = int(os.environ.get("RUN_ID", "0")) + job_name = os.environ.get("JOB_NAME", "integration-tests") + current_index = int(os.environ.get("MATRIX_INDEX", "0")) + repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx") + owner, _sep, repo = repository.partition("/") + if not owner or not repo: + owner, repo = "oblachno-oss", "devx" + + if not all([gitea_url, token, run_id]): + click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.")) + + stop_event = threading.Event() + failed_event = threading.Event() + + if gitea_url and token and run_id: + poller = threading.Thread( + target=poll_for_other_failures, + args=( + gitea_url, + owner, + repo, + token, + run_id, + job_name, + current_index, + stop_event, + failed_event, + ), + daemon=True, + ) + poller.start() + + cmd = [sys.executable, "-m", "pytest"] + if junit_output: + cmd.extend(["--junitxml", junit_output]) + cmd.extend(pytest_args) + + click.echo(f"Running: {' '.join(cmd)}") + + process = subprocess.Popen( # nosec B603 + cmd, + preexec_fn=os.setsid, + ) + + try: + while process.poll() is None: + if failed_event.is_set(): + with contextlib.suppress(ProcessLookupError): + os.killpg(os.getpgid(process.pid), signal.SIGTERM) + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + with contextlib.suppress(ProcessLookupError): + os.killpg(os.getpgid(process.pid), signal.SIGKILL) + process.wait() + click.echo(_("Integration tests cancelled — another runner failed.")) + sys.exit(1) + time.sleep(1) + except KeyboardInterrupt: + with contextlib.suppress(ProcessLookupError): + os.killpg(os.getpgid(process.pid), signal.SIGTERM) + process.wait() + sys.exit(1) + finally: + stop_event.set() + + rc = process.returncode + if rc != 0: + click.echo(_("Integration tests failed with exit code {code}", code=rc)) + else: + click.echo(_("Integration tests passed.")) + sys.exit(rc) + + +if __name__ == "__main__": # pragma: no cover + cli() diff --git a/src/devx/ci/merge_junit.py b/src/devx/ci/merge_junit.py new file mode 100644 index 0000000..8e26b81 --- /dev/null +++ b/src/devx/ci/merge_junit.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Merge multiple JUnit XML reports into a single report. + +Used by CI workflows to consolidate JUnit XML files produced by +parallel matrix runners into a single merged report for archival +and dashboard consumption. + +Usage:: + + python3 -m devx.ci.merge_junit \\ + --pattern "junit-results/runner-*.xml" \\ + --output junit-merged.xml + +Exit code is non-zero if any merged test suite reports failures, +making this suitable as a CI gating step after matrix jobs. +""" + +from __future__ import annotations + +import glob +import sys +import xml.etree.ElementTree as ET # nosec B405 + +import click + +from devx.i18n import _ + + +def merge_files(pattern: str) -> tuple[ET.Element, int, int]: + """Merge JUnit XML files matching *pattern* into a single ```` element. + + Returns ``(merged_element, total_tests, total_failures)``. + If no files match, returns an empty ```` with zero counts. + """ + files = sorted(glob.glob(pattern)) + merged = ET.Element("testsuites") + total_tests = 0 + total_failures = 0 + + for f in files: + tree = ET.parse(f) # nosec B314 + suite = tree.getroot() + # Handle both (wrapper) and (single) roots + if suite.tag == "testsuites": + for child in suite: + merged.append(child) + total_tests += int(child.get("tests", 0)) + total_failures += int(child.get("failures", 0)) + else: + merged.append(suite) + total_tests += int(suite.get("tests", 0)) + total_failures += int(suite.get("failures", 0)) + + merged.set("tests", str(total_tests)) + merged.set("failures", str(total_failures)) + return merged, total_tests, total_failures + + +@click.command() +@click.option( + "--pattern", + default="junit-results/runner-*.xml", + show_default=True, + help="Glob pattern for input JUnit XML files.", +) +@click.option( + "--output", + default="junit-merged.xml", + show_default=True, + help="Output path for the merged JUnit XML file.", +) +def main(pattern: str, output: str) -> None: + merged, total_tests, total_failures = merge_files(pattern) + + if total_tests == 0: + click.echo(_("No JUnit reports found matching {pattern} — skipping merge.", pattern=pattern)) + return + + ET.indent(merged) + tree = ET.ElementTree(merged) + tree.write(output, encoding="UTF-8", xml_declaration=True) + click.echo( + _( + "Merged {count} reports: {tests} tests, {failures} failures → {output}", + count=len(glob.glob(pattern)), + tests=total_tests, + failures=total_failures, + output=output, + ) + ) + + if total_failures > 0: + sys.exit(1) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/src/devx/ci/notify_failure.py b/src/devx/ci/notify_failure.py index 79d6356..0a4efc0 100644 --- a/src/devx/ci/notify_failure.py +++ b/src/devx/ci/notify_failure.py @@ -10,13 +10,20 @@ Usage: --repo \ --run-id \ --workflow \ - --commit + --commit \ + --auto-login + +With ``--auto-login``, the script configures the tea CLI login profile +from ``REPO_TOKEN`` and ``DEVX_GITEA_API_URL`` before creating the issue, +eliminating the need for a separate ``tea login add`` step in the workflow. """ from __future__ import annotations import logging import os +import shutil +import subprocess # nosec B404 import click from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] @@ -30,6 +37,49 @@ load_dotenv() logger = logging.getLogger("devx") +def _configure_tea_login(login_name: str = "devx") -> None: + """Configure tea CLI login from REPO_TOKEN and DEVX_GITEA_API_URL. + + Idempotent: if a login with the same name already exists, it is not re-added. + Skips silently if tea is not installed or REPO_TOKEN is not set. + """ + tea_bin = shutil.which("tea") + if tea_bin is None: + click.echo("notify_failure: tea not installed — skipping login configuration.") + return + + token = os.environ.get("REPO_TOKEN", "") + if not token: + click.echo("notify_failure: REPO_TOKEN not set — skipping login configuration.") + return + + gitea_url = GITEA_API_URL.replace("/api/v1", "") + + result = subprocess.run( # nosec B603 + [tea_bin, "login", "list", "--output", "simple"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0 and login_name in result.stdout: + click.echo(f"notify_failure: tea login '{login_name}' already configured.") + return + + click.echo(f"notify_failure: configuring tea login '{login_name}' for {gitea_url}...") + subprocess.run( # nosec B603 + [tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token], + capture_output=True, + text=True, + check=False, + ) + subprocess.run( # nosec B603 + [tea_bin, "login", "default", login_name], + capture_output=True, + text=True, + check=False, + ) + + def _create_issue_via_tea(repo: str, title: str, body: str) -> int: """Create issue via tea CLI. Returns issue index. @@ -62,11 +112,20 @@ def _create_issue_via_tea(repo: str, title: str, body: str) -> int: @click.option("--run-id", required=True, help="CI run ID.") @click.option("--workflow", required=True, help="Workflow name.") @click.option("--commit", required=True, help="Commit SHA.") -def main(repo: str, run_id: str, workflow: str, commit: str) -> None: +@click.option( + "--auto-login", + is_flag=True, + default=False, + help="Configure tea CLI login from REPO_TOKEN before creating the issue.", +) +def main(repo: str, run_id: str, workflow: str, commit: str, auto_login: bool) -> None: token = os.environ.get("REPO_TOKEN", "") if not token: raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) + if auto_login: + _configure_tea_login() + title = f"[CI] {workflow} workflow failed (run #{run_id})" body = ( f"The **{workflow}** workflow failed.\n\n" diff --git a/src/devx/ci/publish.py b/src/devx/ci/publish.py index 0f8e875..748c9af 100644 --- a/src/devx/ci/publish.py +++ b/src/devx/ci/publish.py @@ -164,7 +164,14 @@ def _default_gitea_registry_url() -> str: "or a URL derived from GITEA_API_URL. When set, publishes to Gitea PyPI " "instead of standard PyPI (unless PYPI_TOKEN is also set).", ) -def main(tag: str, repo: str, registry_url: str | None) -> None: +@click.option( + "--skip-build", + is_flag=True, + default=False, + help="Skip package build and PyPI publish (for non-Python repos that only " + "need a Gitea release with git-cliff notes).", +) +def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> None: gitea_token = os.environ.get("REPO_TOKEN", "") if not gitea_token: raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) @@ -177,21 +184,24 @@ def main(tag: str, repo: str, registry_url: str | None) -> None: if not registry_url: registry_url = _default_gitea_registry_url() - build_package() + if not skip_build: + build_package() - if pypi_token: - # Standard PyPI flow takes precedence when PYPI_TOKEN is set - publish_to_pypi(pypi_token) - elif registry_url: - # Gitea PyPI registry flow - publish_to_gitea_registry(registry_url, gitea_token) - else: - click.echo( - _( - "PYPI_TOKEN not set and no registry URL configured — " - "skipping PyPI publish. No worries, we'll just create the Gitea release." + if pypi_token: + # Standard PyPI flow takes precedence when PYPI_TOKEN is set + publish_to_pypi(pypi_token) + elif registry_url: + # Gitea PyPI registry flow + publish_to_gitea_registry(registry_url, gitea_token) + else: + click.echo( + _( + "PYPI_TOKEN not set and no registry URL configured — " + "skipping PyPI publish. No worries, we'll just create the Gitea release." + ) ) - ) + else: + click.echo(_("--skip-build: skipping package build and PyPI publish.")) tea = TeaCLI(repo=repo) release_body = generate_release_notes(tag) diff --git a/src/devx/ci/push_badges.py b/src/devx/ci/push_badges.py index 0e56819..1cbdd4b 100644 --- a/src/devx/ci/push_badges.py +++ b/src/devx/ci/push_badges.py @@ -17,9 +17,11 @@ Usage:: from __future__ import annotations +import contextlib import re import subprocess # nosec B404 import sys +import time from pathlib import Path from typing import Any @@ -160,13 +162,34 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None) default=False, help="Skip updating README with cache-busting URLs (for local testing).", ) -def main(output_dir: str, branch: str, no_readme_update: bool) -> None: +@click.option( + "--retries", + default=1, + type=int, + help="Number of attempts on git push failures (default: 1, no retry). " + "Between attempts, fetches latest master and waits 10s.", +) +def main(output_dir: str, branch: str, no_readme_update: bool, retries: int) -> None: """Generate badges and push them to the badges branch.""" - fetch_latest_master(branch) - generate_badges(output_dir) - badges_sha = push_to_badges_branch(output_dir) - if not no_readme_update: - update_readme_with_badge_sha(badges_sha) + last_error: Exception | None = None + for attempt in range(1, retries + 1): + try: + fetch_latest_master(branch) + generate_badges(output_dir) + badges_sha = push_to_badges_branch(output_dir) + if not no_readme_update: + update_readme_with_badge_sha(badges_sha) + return + except (subprocess.CalledProcessError, RuntimeError) as exc: + last_error = exc + if attempt < retries: + click.echo(f"Badge push attempt {attempt}/{retries} failed — retrying: {exc}") + time.sleep(10) + with contextlib.suppress(subprocess.CalledProcessError): + fetch_latest_master(branch) + else: + click.echo(f"Badge push failed after {retries} attempts: {exc}") + raise click.ClickException(f"Badge push failed after {retries} attempts: {last_error}") if __name__ == "__main__": # pragma: no cover diff --git a/src/devx/ci/release.py b/src/devx/ci/release.py index ea19520..9227957 100644 --- a/src/devx/ci/release.py +++ b/src/devx/ci/release.py @@ -43,6 +43,7 @@ import sys import click from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] +from devx.ci._shared import get_latest_tag from devx.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=. from devx.i18n import _ @@ -72,14 +73,6 @@ def run_cmd(args: list[str], check: bool = True, capture: bool = True) -> subpro return result -def get_latest_tag() -> str: - """Get the latest git tag, or empty string if none exists.""" - result = run_cmd(["git", "describe", "--tags", "--abbrev=0"], check=False) - if result.returncode != 0: - return "" - return result.stdout.strip() - - def tag_exists(tag: str) -> bool: """Check if a git tag already exists.""" result = run_cmd(["git", "tag", "-l", tag], check=False) diff --git a/src/devx/cli.py b/src/devx/cli.py index 09615fd..7aea6aa 100644 --- a/src/devx/cli.py +++ b/src/devx/cli.py @@ -151,6 +151,27 @@ def ci_validate_commit_msg(args: tuple[str, ...]) -> None: _run_module("devx.ci.validate_commit_msg", list(args)) +@ci.command("distribute-files") +@click.argument("args", nargs=-1) +def ci_distribute_files(args: tuple[str, ...]) -> None: + """Distribute files across parallel runners (round-robin).""" + _run_module("devx.ci.distribute_files", list(args)) + + +@ci.command("merge-junit") +@click.argument("args", nargs=-1) +def ci_merge_junit(args: tuple[str, ...]) -> None: + """Merge multiple JUnit XML reports into a single report.""" + _run_module("devx.ci.merge_junit", list(args)) + + +@ci.command("integration-guard") +@click.argument("args", nargs=-1) +def ci_integration_guard(args: tuple[str, ...]) -> None: + """Run pytest with cross-runner failure detection and JUnit output.""" + _run_module("devx.ci.integration_guard", list(args)) + + @cli.group() def tools() -> None: """Development tool commands.""" diff --git a/src/devx/molecule/distribute_molecule.py b/src/devx/molecule/distribute_molecule.py index f40a8fd..c878192 100644 --- a/src/devx/molecule/distribute_molecule.py +++ b/src/devx/molecule/distribute_molecule.py @@ -29,6 +29,7 @@ from devx.molecule.platforms import PLATFORMS DEFAULT_MAX_RUNNERS = 3 MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule") +DEFAULT_ROLES_ROOT = Path("ansible/roles") @dataclass(frozen=True) @@ -52,6 +53,31 @@ class TestPair: ) +@dataclass(frozen=True) +class MultiRoleTestPair: + """A (role, scenario, platform) combination for multi-role projects.""" + + role: str + scenario: str + platform: dict[str, str] + + def encode(self) -> str: + """Serialize to a pipe-delimited string: ``role|scenario|platform_name|image|command``.""" + return ( + f"{self.role}|{self.scenario}|{self.platform['name']}|{self.platform['image']}|{self.platform['command']}" + ) + + @staticmethod + def decode(encoded: str) -> MultiRoleTestPair: + """Deserialize from a pipe-delimited string.""" + parts = encoded.split("|") + return MultiRoleTestPair( + role=parts[0], + scenario=parts[1], + platform={"name": parts[2], "image": parts[3], "command": parts[4]}, + ) + + def discover_scenarios(root: Path | None = None) -> list[str]: """Return sorted list of molecule scenario directory names.""" if root is None: @@ -62,6 +88,33 @@ def discover_scenarios(root: Path | None = None) -> list[str]: return sorted(scenarios) +def discover_multi_role_scenarios(roles_root: Path | None = None) -> list[tuple[str, str]]: + """Discover (role, scenario) pairs across all roles under *roles_root*. + + Scans ``roles_root/*/molecule/*/`` for scenario directories, skipping + ``common`` and directories starting with ``_``. Returns a sorted list of + ``(role_name, scenario_name)`` tuples. + """ + if roles_root is None: + roles_root = DEFAULT_ROLES_ROOT + if not roles_root.is_dir(): + raise click.ClickException(_("Roles directory not found: {path}", path=str(roles_root))) + pairs: list[tuple[str, str]] = [] + for role_dir in sorted(roles_root.iterdir()): + if not role_dir.is_dir(): + continue + mol_dir = role_dir / "molecule" + if not mol_dir.is_dir(): + continue + for scenario_dir in mol_dir.iterdir(): + if not scenario_dir.is_dir(): + continue + if scenario_dir.name.startswith("_") or scenario_dir.name == "common": + continue + pairs.append((role_dir.name, scenario_dir.name)) + return pairs + + def build_pairs(scenarios: list[str], platforms: list[dict[str, str]] | None = None) -> list[TestPair]: """Build the full cross-product of scenarios and platforms.""" if platforms is None: @@ -69,6 +122,36 @@ def build_pairs(scenarios: list[str], platforms: list[dict[str, str]] | None = N return [TestPair(s, p) for s in scenarios for p in platforms] +def build_multi_role_pairs( + role_scenarios: list[tuple[str, str]], + platforms: list[dict[str, str]] | None = None, +) -> list[MultiRoleTestPair]: + """Build the full cross-product of (role, scenario) pairs and platforms.""" + if platforms is None: + platforms = PLATFORMS + return [MultiRoleTestPair(r, s, p) for r, s in role_scenarios for p in platforms] + + +def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]: + """Split *pairs* into *max_runners* balanced groups (round-robin).""" + groups: list[list[MultiRoleTestPair]] = [[] for _ in range(max_runners)] + for i, pair in enumerate(pairs): + groups[i % max_runners].append(pair) + return groups + + +def multi_role_pairs_for_runner( + pairs: list[MultiRoleTestPair], runner_index: int, max_runners: int +) -> list[MultiRoleTestPair]: + """Return the subset of multi-role pairs assigned to *runner_index* (0-based).""" + groups = distribute_multi_role(pairs, max_runners) + if runner_index < 0 or runner_index >= len(groups): + raise click.ClickException( + _("Runner index {index} out of range (0..{max})", index=runner_index, max=max_runners - 1) + ) + return groups[runner_index] + + def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]: """Split *pairs* into *max_runners* balanced groups (round-robin).""" groups: list[list[TestPair]] = [[] for _ in range(max_runners)] @@ -142,6 +225,19 @@ def _write_github_env(key: str, value: str) -> None: default=False, help="With --github-env: write SKIP=true when runner-index exceeds max-runners.", ) +@click.option( + "--molecule-root", + type=click.Path(exists=True, file_okay=False, path_type=Path), + default=None, + help="Custom molecule directory (single-role mode). Default: ansible/roles/gitea-runner/molecule.", +) +@click.option( + "--roles-root", + type=click.Path(exists=True, file_okay=False, path_type=Path), + default=None, + help="Roles directory for multi-role discovery (scans */molecule/*/). " + "Use this for projects with multiple Ansible roles. Default: disabled (single-role mode).", +) def cli( runner_index: int | None, max_runners: int, @@ -149,8 +245,47 @@ def cli( list_platforms: bool, github_env: bool, skip_if_excess: bool, + molecule_root: Path | None, + roles_root: Path | None, ) -> None: - scenarios = discover_scenarios() + # Multi-role mode: discover (role, scenario) pairs across all roles + if roles_root is not None: + role_scenarios = discover_multi_role_scenarios(roles_root) + if list_all: + for role, scenario in role_scenarios: + click.echo(f"{role}|{scenario}") + return + if list_platforms: + for p in PLATFORMS: + click.echo(f"{p['name']}|{p['image']}|{p['command']}") + return + pairs_mr = build_multi_role_pairs(role_scenarios) + if runner_index is None: + groups = distribute_multi_role(pairs_mr, max_runners) + for i, group in enumerate(groups): + labels = " ".join(p.encode() for p in group) if group else "(none)" + click.echo(f"Runner {i}: {labels}") + return + if skip_if_excess and github_env and runner_index > max_runners: + click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}") + _write_github_env("TEST_PAIRS", "") + _write_github_env("SKIP", "true") + return + if runner_index < 1: + raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)") + zero_based = runner_index - 1 + assigned = multi_role_pairs_for_runner(pairs_mr, zero_based, max_runners) + encoded = " ".join(p.encode() for p in assigned) + if github_env: + _write_github_env("TEST_PAIRS", encoded) + _write_github_env("SKIP", "false") + click.echo(f"Assigned pairs: {encoded}") + return + click.echo(encoded) + return + + # Single-role mode (default or --molecule-root) + scenarios = discover_scenarios(molecule_root) if list_all: for s in scenarios: click.echo(s) diff --git a/src/devx/molecule/molecule_ci_guard.py b/src/devx/molecule/molecule_ci_guard.py index b513f5e..4ca4fea 100644 --- a/src/devx/molecule/molecule_ci_guard.py +++ b/src/devx/molecule/molecule_ci_guard.py @@ -1,7 +1,11 @@ #!/usr/bin/env python3 """Run molecule tests sequentially while polling Gitea for other runner failures. -Each pair is encoded as ``scenario|platform_name|platform_image|platform_command``. +Each pair is encoded as one of: + +- **Single-role (4-part):** ``scenario|platform_name|platform_image|platform_command`` +- **Multi-role (5-part):** ``role|scenario|platform_name|platform_image|platform_command`` + Pairs are executed one at a time (molecule scenarios share temp directories and Docker networks, so parallel execution within a single runner is unsafe). @@ -9,8 +13,17 @@ 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. -Usage: - python3 -m devx.molecule.molecule_ci_guard ... +JUnit XML is generated when ``--junit-output`` is provided, recording each +pair as a testcase with pass/fail status and elapsed time. + +Usage:: + + # Single-role (grm-style) + python3 -m devx.molecule.molecule_ci_guard pair1 pair2 ... + # Multi-role (infra-style) + python3 -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2 ... + # With JUnit output + python3 -m devx.molecule.molecule_ci_guard --junit-output junit-results/runner-1.xml pair1 pair2 ... Environment variables: GITEA_URL Base URL of the Gitea instance. @@ -30,6 +43,7 @@ import subprocess # nosec B404 import sys import threading import time +import xml.etree.ElementTree as ET # nosec B405 from pathlib import Path import click @@ -95,9 +109,23 @@ def build_molecule_cmd(scenario: str) -> list[str]: return cmd +def parse_pair(pair: str) -> tuple[str, str, str, str, str]: + """Parse a pair string into (role, scenario, platform_name, platform_image, platform_command). + + Supports both 4-part (single-role) and 5-part (multi-role) formats. + For 4-part pairs, role is empty (caller uses default role dir). + """ + parts = pair.split("|") + if len(parts) == 4: + return "", parts[0], parts[1], parts[2], parts[3] + if len(parts) == 5: + return parts[0], parts[1], parts[2], parts[3], parts[4] + raise click.ClickException(f"Invalid pair format: {pair!r} (expected 4 or 5 pipe-delimited parts)") + + def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]: """Build environment for a single molecule pair.""" - scenario, platform_name, platform_image, platform_command = pair.split("|") + _role, _scenario, platform_name, platform_image, platform_command = parse_pair(pair) env = base_env.copy() env["MOLECULE_PLATFORM_NAME"] = platform_name env["MOLECULE_PLATFORM_IMAGE"] = platform_image @@ -109,9 +137,66 @@ def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]: return env +def resolve_role_dir(role: str, roles_root: Path | None, repo_root: Path) -> Path: + """Resolve the working directory for a molecule pair. + + For multi-role pairs (role non-empty), uses ``roles_root/role``. + For single-role pairs, uses ``repo_root/ansible/roles/gitea-runner``. + """ + if role: + if roles_root is None: + roles_root = repo_root / "ansible" / "roles" + return roles_root / role + return repo_root / "ansible" / "roles" / "gitea-runner" + + +def write_junit_report( + output_path: str, + testcases: list[dict], + runner_index: int, +) -> None: + """Write a JUnit XML report from collected test case results. + + Each testcase dict has: role, scenario, time (float), passed (bool), error (str|None). + """ + suite = ET.Element( + "testsuite", + name=f"molecule-runner-{runner_index}", + tests=str(len(testcases)), + failures=str(sum(1 for tc in testcases if not tc["passed"])), + ) + for tc in testcases: + classname = tc["role"] if tc["role"] else "molecule" + elem = ET.SubElement( + suite, + "testcase", + classname=classname, + name=tc["scenario"], + time=f"{tc['time']:.1f}", + ) + if not tc["passed"]: + fail = ET.SubElement(elem, "failure") + fail.text = tc.get("error") or "molecule test failed" + tree = ET.ElementTree(suite) + ET.indent(tree) + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + tree.write(output_path, encoding="UTF-8", xml_declaration=True) + + @click.command() @click.argument("pairs", nargs=-1, required=True) -def cli(pairs: tuple[str, ...]) -> None: +@click.option( + "--junit-output", + default=None, + help="Path to write JUnit XML report (e.g. junit-results/runner-1.xml).", +) +@click.option( + "--roles-root", + type=click.Path(exists=True, file_okay=False, path_type=Path), + default=None, + help="Root directory for multi-role pairs (e.g. ansible/roles). Required when pairs use 5-part format.", +) +def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | None) -> None: """Run molecule pairs sequentially, stop if another CI runner fails.""" gitea_url = os.environ.get("GITEA_URL", "") token = os.environ.get("REPO_TOKEN", "") @@ -119,7 +204,7 @@ def cli(pairs: tuple[str, ...]) -> None: job_name = os.environ.get("JOB_NAME", "molecule-tests") current_index = int(os.environ.get("MATRIX_INDEX", "0")) repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx") - owner, sep, repo = repository.partition("/") + owner, _sep, repo = repository.partition("/") if not owner or not repo: owner, repo = "oblachno-oss", "devx" @@ -127,7 +212,6 @@ def cli(pairs: tuple[str, ...]) -> None: click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.")) repo_root = Path(__file__).resolve().parent.parent.parent.parent - role_dir = repo_root / "ansible" / "roles" / "gitea-runner" base_env = os.environ.copy() base_env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock") @@ -154,24 +238,24 @@ def cli(pairs: tuple[str, ...]) -> None: ) poller.start() + testcases: list[dict] = [] + try: for pair in pairs: if failed_event.is_set(): sys.exit(1) - parts = pair.split("|") - if len(parts) < 2: - raise click.ClickException(f"Invalid pair format: {pair!r} (expected at least 2 pipe-delimited parts)") - scenario = parts[0] - platform_name = parts[1] + role, scenario, platform_name, _img, _cmd = parse_pair(pair) click.echo(_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name)) cmd = build_molecule_cmd(scenario) env = build_env_for_pair(pair, base_env) + cwd = resolve_role_dir(role, roles_root, repo_root) + start = time.time() process = subprocess.Popen( # nosec B603 cmd, - cwd=str(role_dir), + cwd=str(cwd), env=env, preexec_fn=os.setsid, ) @@ -187,6 +271,18 @@ def cli(pairs: tuple[str, ...]) -> None: with contextlib.suppress(ProcessLookupError): os.killpg(os.getpgid(process.pid), signal.SIGKILL) process.wait() + elapsed = time.time() - start + testcases.append( + { + "role": role, + "scenario": scenario, + "time": elapsed, + "passed": False, + "error": "Cancelled — another runner failed", + } + ) + if junit_output: + write_junit_report(junit_output, testcases, current_index) sys.exit(1) time.sleep(1) except KeyboardInterrupt: @@ -196,13 +292,30 @@ def cli(pairs: tuple[str, ...]) -> None: sys.exit(1) rc = process.returncode + elapsed = time.time() - start + passed = rc == 0 + + testcases.append( + { + "role": role, + "scenario": scenario, + "time": elapsed, + "passed": passed, + "error": f"Exit code: {rc}" if not passed else None, + } + ) + if rc != 0: click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc)) + if junit_output: + write_junit_report(junit_output, testcases, current_index) sys.exit(rc) click.echo(_("PASSED: {pair}", pair=pair)) click.echo(_("All molecule tests passed.")) + if junit_output: + write_junit_report(junit_output, testcases, current_index) finally: stop_event.set() diff --git a/src/devx/opentofu.py b/src/devx/opentofu.py new file mode 100644 index 0000000..8792c99 --- /dev/null +++ b/src/devx/opentofu.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""OpenTofu output helpers for CI/CD deployment scripts. + +Provides reusable functions for extracting values from ``tofu output`` +in a structured way. This eliminates duplicated ``subprocess.run`` +boilerplate across deployment and smoke-test scripts. + +Typical usage:: + + from devx.opentofu import get_tofu_output, get_tofu_vm_ip + + vms = get_tofu_output("customer_vms", cwd="tofu/environments/staging", + env={"HCLOUD_TOKEN": token}) + ip = get_tofu_vm_ip("customer_vms", "oblachno", cwd="tofu/environments/staging", + env={"HCLOUD_TOKEN": token}) +""" + +from __future__ import annotations + +import json +import subprocess # nosec B404 +from pathlib import Path +from typing import Any + + +def get_tofu_output( + output_name: str, + cwd: str | Path | None = None, + env: dict[str, str] | None = None, +) -> Any: + """Run ``tofu output -json `` and return parsed JSON. + + Args: + output_name: The OpenTofu output name to query (e.g. ``customer_vms``). + cwd: Directory to run the command in (the tofu env directory). + env: Environment variables for the subprocess (e.g. ``{"HCLOUD_TOKEN": ...}``). + If ``None``, inherits the current environment. + + Returns: + Parsed JSON value from the tofu output. + + Raises: + RuntimeError: If ``tofu output`` exits with a non-zero code. + json.JSONDecodeError: If stdout is not valid JSON. + """ + result = subprocess.run( # nosec B603, B607 + ["tofu", "output", "-json", output_name], + cwd=str(cwd) if cwd else None, + capture_output=True, + text=True, + check=False, + env=env, + ) + if result.returncode != 0: + raise RuntimeError(f"tofu output failed: {result.stderr}") + return json.loads(result.stdout) + + +def get_tofu_vm_ip( + output_name: str, + vm_key: str, + cwd: str | Path | None = None, + env: dict[str, str] | None = None, + ip_field: str = "ipv4", +) -> str: + """Extract a VM IPv4 address from a tofu output map. + + The output is expected to be a JSON object mapping VM names to objects + containing an IP field (default ``ipv4``):: + + {"staging": {"ipv4": "1.2.3.4", ...}, ...} + + Args: + output_name: The tofu output name (e.g. ``customer_vms``). + vm_key: The key inside the output map (e.g. ``"staging"``). + cwd: Directory to run the command in. + env: Environment variables for the subprocess. + ip_field: The field name for the IP address (default ``ipv4``). + + Returns: + The IP address string, or empty string if not found. + """ + data = get_tofu_output(output_name, cwd=cwd, env=env) + if not isinstance(data, dict): + return "" + return str(data.get(vm_key, {}).get(ip_field, "")) + + +def get_tofu_vm_field( + output_name: str, + vm_key: str, + field: str, + cwd: str | Path | None = None, + env: dict[str, str] | None = None, +) -> str: + """Extract an arbitrary field from a VM entry in tofu output. + + Like :func:`get_tofu_vm_ip` but for any field (e.g. ``volume_linux_device``). + + Args: + output_name: The tofu output name. + vm_key: The key inside the output map. + field: The field name to extract. + cwd: Directory to run the command in. + env: Environment variables for the subprocess. + + Returns: + The field value as a string, or empty string if not found. + """ + data = get_tofu_output(output_name, cwd=cwd, env=env) + if not isinstance(data, dict): + return "" + return str(data.get(vm_key, {}).get(field, "")) diff --git a/src/devx/translations.json b/src/devx/translations.json index b34e4ec..0237e5e 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -1,1150 +1,1199 @@ { - "\n=== Summary ===": { - "en": "\n=== Summary ===", - "bg": "\n=== Summary ===", - "de": "\n=== Summary ===", - "ru": "\n=== Summary ===", - "zh": "\n=== Summary ===" - }, - "\nAll documentation coverage checks passed!": { - "en": "\nAll documentation coverage checks passed!", - "bg": "\nAll documentation coverage checks passed!", - "de": "\nAll documentation coverage checks passed!", - "ru": "\nAll documentation coverage checks passed!", - "zh": "\nAll documentation coverage checks passed!" - }, - "\nCHANGELOG version ordering:": { - "en": "\nCHANGELOG version ordering:", - "bg": "\nCHANGELOG version ordering:", - "de": "\nCHANGELOG version ordering:", - "ru": "\nCHANGELOG version ordering:", - "zh": "\nCHANGELOG version ordering:" - }, - "\nChecking CI script documentation in ci-cd-workflow.md...": { - "en": "\nChecking CI script documentation in ci-cd-workflow.md...", - "bg": "\nChecking CI script documentation in ci-cd-workflow.md...", - "de": "\nChecking CI script documentation in ci-cd-workflow.md...", - "ru": "\nChecking CI script documentation in ci-cd-workflow.md...", - "zh": "\nChecking CI script documentation in ci-cd-workflow.md..." - }, - "\nChecking module documentation in architecture.md...": { - "en": "\nChecking module documentation in architecture.md...", - "bg": "\nChecking module documentation in architecture.md...", - "de": "\nChecking module documentation in architecture.md...", - "ru": "\nChecking module documentation in architecture.md...", - "zh": "\nChecking module documentation in architecture.md..." - }, - "\nDoc coverage: {covered}/{total} ({pct}%)": { - "en": "\nDoc coverage: {covered}/{total} ({pct}%)", - "bg": "\nDoc coverage: {covered}/{total} ({pct}%)", - "de": "\nDoc coverage: {covered}/{total} ({pct}%)", - "ru": "\nDoc coverage: {covered}/{total} ({pct}%)", - "zh": "\nDoc coverage: {covered}/{total} ({pct}%)" - }, - "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}": { - "en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}", - "bg": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}", - "de": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}", - "ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}", - "zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}" - }, - "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": { - "en": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.", - "bg": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.", - "de": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.", - "ru": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.", - "zh": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce." - }, - "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.": { - "en": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.", - "bg": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.", - "de": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.", - "ru": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.", - "zh": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report." - }, - "\nIntegrity check FAILED ({count} issues):": { - "en": "\nIntegrity check FAILED ({count} issues):", - "bg": "\nIntegrity check FAILED ({count} issues):", - "de": "\nIntegrity check FAILED ({count} issues):", - "ru": "\nIntegrity check FAILED ({count} issues):", - "zh": "\nIntegrity check FAILED ({count} issues):" - }, - "\nIntegrity check passed — all {count} pages verified.": { - "en": "\nIntegrity check passed — all {count} pages verified.", - "bg": "\nIntegrity check passed — all {count} pages verified.", - "de": "\nIntegrity check passed — all {count} pages verified.", - "ru": "\nIntegrity check passed — all {count} pages verified.", - "zh": "\nIntegrity check passed — all {count} pages verified." - }, - "\nLatest tag: {tag}": { - "en": "\nLatest tag: {tag}", - "bg": "\nLatest tag: {tag}", - "de": "\nLatest tag: {tag}", - "ru": "\nLatest tag: {tag}", - "zh": "\nLatest tag: {tag}" - }, - "\nMissing documentation:": { - "en": "\nMissing documentation:", - "bg": "\nMissing documentation:", - "de": "\nMissing documentation:", - "ru": "\nMissing documentation:", - "zh": "\nMissing documentation:" - }, - "\nResult: {status}": { - "en": "\nResult: {status}", - "bg": "\nResult: {status}", - "de": "\nResult: {status}", - "ru": "\nResult: {status}", - "zh": "\nResult: {status}" - }, - "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).": { - "en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).", - "bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).", - "de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).", - "ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).", - "zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)." - }, - "\nRunning full wiki integrity check...": { - "en": "\nRunning full wiki integrity check...", - "bg": "\nRunning full wiki integrity check...", - "de": "\nRunning full wiki integrity check...", - "ru": "\nRunning full wiki integrity check...", - "zh": "\nRunning full wiki integrity check..." - }, - "\nTag → Commit alignment:": { - "en": "\nTag → Commit alignment:", - "bg": "\nTag → Commit alignment:", - "de": "\nTag → Commit alignment:", - "ru": "\nTag → Commit alignment:", - "zh": "\nTag → Commit alignment:" - }, - "\nUntagged release commits:": { - "en": "\nUntagged release commits:", - "bg": "\nUntagged release commits:", - "de": "\nUntagged release commits:", - "ru": "\nUntagged release commits:", - "zh": "\nUntagged release commits:" - }, - "\nUser-facing changes ({count}):": { - "en": "\nUser-facing changes ({count}):", - "bg": "\nUser-facing changes ({count}):", - "de": "\nUser-facing changes ({count}):", - "ru": "\nUser-facing changes ({count}):", - "zh": "\nUser-facing changes ({count}):" - }, - "\nVerification FAILED: {failures} page(s) have empty or mismatched content!": { - "en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!", - "bg": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!", - "de": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!", - "ru": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!", - "zh": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!" - }, - "\nVerification passed — all wiki pages have correct content.": { - "en": "\nVerification passed — all wiki pages have correct content.", - "bg": "\nVerification passed — all wiki pages have correct content.", - "de": "\nVerification passed — all wiki pages have correct content.", - "ru": "\nVerification passed — all wiki pages have correct content.", - "zh": "\nVerification passed — all wiki pages have correct content." - }, - "\nVerifying wiki pages have content...": { - "en": "\nVerifying wiki pages have content...", - "bg": "\nVerifying wiki pages have content...", - "de": "\nVerifying wiki pages have content...", - "ru": "\nVerifying wiki pages have content...", - "zh": "\nVerifying wiki pages have content..." - }, - "\nWorkflow-only changes ({count}):": { - "en": "\nWorkflow-only changes ({count}):", - "bg": "\nWorkflow-only changes ({count}):", - "de": "\nWorkflow-only changes ({count}):", - "ru": "\nWorkflow-only changes ({count}):", - "zh": "\nWorkflow-only changes ({count}):" - }, - "\n[dry-run] Changelog:\n{changelog}": { - "en": "\n[dry-run] Changelog:\n{changelog}", - "bg": "\n[dry-run] Changelog:\n{changelog}", - "de": "\n[dry-run] Changelog:\n{changelog}", - "ru": "\n[dry-run] Changelog:\n{changelog}", - "zh": "\n[dry-run] Changelog:\n{changelog}" - }, - "\n{label} files changed ({count}):": { - "en": "\n{label} files changed ({count}):", - "bg": "\n{label} files changed ({count}):", - "de": "\n{label} files changed ({count}):", - "ru": "\n{label} files changed ({count}):", - "zh": "\n{label} files changed ({count}):" - }, - "\n{tag} files ({count}):": { - "en": "\n{tag} files ({count}):", - "bg": "\n{tag} files ({count}):", - "de": "\n{tag} files ({count}):", - "ru": "\n{tag} files ({count}):", - "zh": "\n{tag} files ({count}):" - }, - " - Auto-delete branch after merge: yes": { - "en": " - Auto-delete branch after merge: yes", - "bg": " - Автоматично изтриване на клон след сливане: да", - "de": " - Branch nach Merge automatisch löschen: ja", - "ru": " - Автоудаление ветки после слияния: да", - "zh": " - 合并后自动删除分支: 是" - }, - " - Block outdated branches: yes": { - "en": " - Block outdated branches: yes", - "bg": " - Блокиране на остарели клонове: да", - "de": " - Veraltete Branches blockieren: ja", - "ru": " - Блокировать устаревшие ветки: да", - "zh": " - 阻止过时分支: 是" - }, - " - Block rejected reviews: yes": { - "en": " - Block rejected reviews: yes", - "bg": " - Блокиране на отхвърлени рецензии: да", - "de": " - Abgelehnte Reviews blockieren: ja", - "ru": " - Блокировать отклонённые ревью: да", - "zh": " - 阻止被拒绝的审查: 是" - }, - " - Direct pushes: BLOCKED (require PR, whitelisted users can push)": { - "en": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)", - "bg": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)", - "de": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)", - "ru": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)", - "zh": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)" - }, - " - Dismiss stale approvals: yes": { - "en": " - Dismiss stale approvals: yes", - "bg": " - Анулиране на остарели одобрения: да", - "de": " - Veraltete Genehmigungen ablehnen: ja", - "ru": " - Отклонять устаревшие одобрения: да", - "zh": " - 忽略过时审批: 是" - }, - " - Required approvals: {count}": { - "en": " - Required approvals: {count}", - "bg": " - Необходими одобрения: {count}", - "de": " - Erforderliche Genehmigungen: {count}", - "ru": " - Требуемые одобрения: {count}", - "zh": " - 必需审批数: {count}" - }, - " - Required status checks: {checks}": { - "en": " - Required status checks: {checks}", - "bg": " - Необходими проверки на състоянието: {checks}", - "de": " - Erforderliche Status-Checks: {checks}", - "ru": " - Требуемые проверки статуса: {checks}", - "zh": " - 必需状态检查: {checks}" - }, - " Created: {title}": { - "en": " Created: {title}", - "bg": " Created: {title}", - "de": " Created: {title}", - "ru": " Created: {title}", - "zh": " Created: {title}" - }, - " FAIL: {title} — content mismatch or empty!": { - "en": " FAIL: {title} — content mismatch or empty!", - "bg": " FAIL: {title} — content mismatch or empty!", - "de": " FAIL: {title} — content mismatch or empty!", - "ru": " FAIL: {title} — content mismatch or empty!", - "zh": " FAIL: {title} — content mismatch or empty!" - }, - " MISSING: devx {cmd}": { - "en": " MISSING: devx {cmd}", - "bg": " ЛИПСВА: devx {cmd}", - "de": " FEHLT: devx {cmd}", - "ru": " ОТСУТСТВУЕТ: devx {cmd}", - "zh": " 缺失: devx {cmd}" - }, - " MISSING: {module}": { - "en": " MISSING: {module}", - "bg": " MISSING: {module}", - "de": " MISSING: {module}", - "ru": " MISSING: {module}", - "zh": " MISSING: {module}" - }, - " MISSING: {script}": { - "en": " MISSING: {script}", - "bg": " MISSING: {script}", - "de": " MISSING: {script}", - "ru": " MISSING: {script}", - "zh": " MISSING: {script}" - }, - " OK: devx {cmd}": { - "en": " OK: devx {cmd}", - "bg": " ОК: devx {cmd}", - "de": " OK: devx {cmd}", - "ru": " ОК: devx {cmd}", - "zh": " 正常: devx {cmd}" - }, - " OK: {module}": { - "en": " OK: {module}", - "bg": " OK: {module}", - "de": " OK: {module}", - "ru": " OK: {module}", - "zh": " OK: {module}" - }, - " OK: {script}": { - "en": " OK: {script}", - "bg": " OK: {script}", - "de": " OK: {script}", - "ru": " OK: {script}", - "zh": " OK: {script}" - }, - " OK: {title} ({chars} chars)": { - "en": " OK: {title} ({chars} chars)", - "bg": " OK: {title} ({chars} chars)", - "de": " OK: {title} ({chars} chars)", - "ru": " OK: {title} ({chars} chars)", - "zh": " OK: {title} ({chars} chars)" - }, - " Updated: {title}": { - "en": " Updated: {title}", - "bg": " Updated: {title}", - "de": " Updated: {title}", - "ru": " Updated: {title}", - "zh": " Updated: {title}" - }, - "=== Release Alignment Verification ===\n": { - "en": "=== Release Alignment Verification ===\n", - "bg": "=== Release Alignment Verification ===\n", - "de": "=== Release Alignment Verification ===\n", - "ru": "=== Release Alignment Verification ===\n", - "zh": "=== Release Alignment Verification ===\n" - }, - "API poll warning: {exc}": { - "en": "API poll warning: {exc}", - "bg": "API poll warning: {exc}", - "de": "API poll warning: {exc}", - "ru": "API poll warning: {exc}", - "zh": "API poll warning: {exc}" - }, - "All molecule tests passed.": { - "en": "All molecule tests passed.", - "bg": "All molecule tests passed.", - "de": "All molecule tests passed.", - "ru": "All molecule tests passed.", - "zh": "All molecule tests passed." - }, - "Another molecule runner failed. Stopping this runner early.": { - "en": "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.", - "ru": "Another molecule runner failed. Stopping this runner early.", - "zh": "Another molecule runner failed. Stopping this runner early." - }, - "Bumping version: {current} -> v{new_version}": { - "en": "Bumping version: {current} -> v{new_version}", - "bg": "Bumping version: {current} -> v{new_version}", - "de": "Bumping version: {current} -> v{new_version}", - "ru": "Bumping version: {current} -> v{new_version}", - "zh": "Bumping version: {current} -> v{new_version}" - }, - "Checking CLI command documentation...": { - "en": "Checking CLI command documentation...", - "bg": "Checking CLI command documentation...", - "de": "Checking CLI command documentation...", - "ru": "Checking CLI command documentation...", - "zh": "Checking CLI command documentation..." - }, - "Command failed ({cmd}): {stderr}": { - "en": "Command failed ({cmd}): {stderr}", - "bg": "Command failed ({cmd}): {stderr}", - "de": "Command failed ({cmd}): {stderr}", - "ru": "Command failed ({cmd}): {stderr}", - "zh": "Command failed ({cmd}): {stderr}" - }, - "Comparing {base}..{head} ({count} files changed)": { - "en": "Comparing {base}..{head} ({count} files changed)", - "bg": "Comparing {base}..{head} ({count} files changed)", - "de": "Comparing {base}..{head} ({count} files changed)", - "ru": "Comparing {base}..{head} ({count} files changed)", - "zh": "Comparing {base}..{head} ({count} files changed)" - }, - "Configuring branch protection for {branch}...": { - "en": "Configuring branch protection for {branch}...", - "bg": "Конфигуриране на защита на клона {branch}...", - "de": "Konfiguriere Branch-Schutz für {branch}...", - "ru": "Настройка защиты ветки {branch}...", - "zh": "正在配置 {branch} 的分支保护..." - }, - "Configuring repository settings...": { - "en": "Configuring repository settings...", - "bg": "Конфигуриране на настройките на хранилището...", - "de": "Repository-Einstellungen konfigurieren...", - "ru": "Настройка параметров репозитория...", - "zh": "正在配置仓库设置..." - }, - "Could not extract conventional commit message from PR commits.": { - "en": "Could not extract conventional commit message from PR commits.", - "bg": "Could not extract conventional commit message from PR commits.", - "de": "Could not extract conventional commit message from PR commits.", - "ru": "Could not extract conventional commit message from PR commits.", - "zh": "Could not extract conventional commit message from PR commits." - }, - "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": { - "en": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.", - "bg": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.", - "de": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.", - "ru": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.", - "zh": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task." - }, - "Could not find __version__ in {file}": { - "en": "Could not find __version__ in {file}", - "bg": "Could not find __version__ in {file}", - "de": "Could not find __version__ in {file}", - "ru": "Could not find __version__ in {file}", - "zh": "Could not find __version__ in {file}" - }, - "Could not parse test execution time from output.": { - "en": "Could not parse test execution time from output.", - "bg": "Could not parse test execution time from output.", - "de": "Could not parse test execution time from output.", - "ru": "Could not parse test execution time from output.", - "zh": "Could not parse test execution time from output." - }, - "Created issue #{issue_id}: {title}": { - "en": "Created issue #{issue_id}: {title}", - "bg": "Created issue #{issue_id}: {title}", - "de": "Created issue #{issue_id}: {title}", - "ru": "Created issue #{issue_id}: {title}", - "zh": "Created issue #{issue_id}: {title}" - }, - "Created release commit.": { - "en": "Created release commit.", - "bg": "Created release commit.", - "de": "Created release commit.", - "ru": "Created release commit.", - "zh": "Created release commit." - }, - "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": { - "en": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", - "bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", - "de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", - "ru": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", - "zh": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently." - }, - "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。" - }, - "ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.": { - "en": "ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.", - "bg": "ГРЕШКА: Името на хранилището не е указано. Използвайте --repo или задайте DEVX_REPO_NAME.", - "de": "FEHLER: Repository-Name nicht angegeben. Verwenden Sie --repo oder setzen Sie DEVX_REPO_NAME.", - "ru": "ОШИБКА: Имя репозитория не указано. Используйте --repo или задайте DEVX_REPO_NAME.", - "zh": "错误:未指定仓库名称。请使用 --repo 或设置 DEVX_REPO_NAME。" - }, - "ERROR: Tag consistency check failed. Existing tags are misaligned:": { - "en": "ERROR: Tag consistency check failed. Existing tags are misaligned:", - "bg": "ERROR: Tag consistency check failed. Existing tags are misaligned:", - "de": "ERROR: Tag consistency check failed. Existing tags are misaligned:", - "ru": "ERROR: Tag consistency check failed. Existing tags are misaligned:", - "zh": "ERROR: Tag consistency check failed. Existing tags are misaligned:" - }, - "ERROR: VIKUNJA_TOKEN is not set.": { - "en": "ERROR: VIKUNJA_TOKEN is not set.", - "bg": "ГРЕШКА: VIKUNJA_TOKEN не е зададен.", - "de": "FEHLER: VIKUNJA_TOKEN ist nicht gesetzt.", - "ru": "ОШИБКА: VIKUNJA_TOKEN не задан.", - "zh": "错误:未设置 VIKUNJA_TOKEN。" - }, - "ERROR: mapping.json not found at {path}": { - "en": "ERROR: mapping.json not found at {path}", - "bg": "ERROR: mapping.json not found at {path}", - "de": "ERROR: mapping.json not found at {path}", - "ru": "ERROR: mapping.json not found at {path}", - "zh": "ERROR: mapping.json not found at {path}" - }, - "FAILED: {pair} exited with code {code}": { - "en": "FAILED: {pair} exited with code {code}", - "bg": "FAILED: {pair} exited with code {code}", - "de": "FAILED: {pair} exited with code {code}", - "ru": "FAILED: {pair} exited with code {code}", - "zh": "FAILED: {pair} exited with code {code}" - }, - "Failed to create issue via tea: {error}": { - "en": "Failed to create issue via tea: {error}", - "bg": "Failed to create issue via tea: {error}", - "de": "Failed to create issue via tea: {error}", - "ru": "Failed to create issue via tea: {error}", - "zh": "Failed to create issue via tea: {error}" - }, - "Found {count} existing wiki pages.": { - "en": "Found {count} existing wiki pages.", - "bg": "Found {count} existing wiki pages.", - "de": "Found {count} existing wiki pages.", - "ru": "Found {count} existing wiki pages.", - "zh": "Found {count} existing wiki pages." - }, - "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": { - "en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", - "bg": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", - "de": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", - "ru": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", - "zh": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation." - }, - "Generated {file} with prefix '{prefix}'.": { - "en": "Generated {file} with prefix '{prefix}'.", - "bg": "Generated {file} with prefix '{prefix}'.", - "de": "Generated {file} with prefix '{prefix}'.", - "ru": "Generated {file} with prefix '{prefix}'.", - "zh": "Generated {file} with prefix '{prefix}'." - }, - "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.": { - "en": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", - "bg": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", - "de": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", - "ru": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", - "zh": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag." - }, - "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.": { - "en": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.", - "bg": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.", - "de": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.", - "ru": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.", - "zh": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment." - }, - "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.": { - "en": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.", - "bg": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.", - "de": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.", - "ru": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.", - "zh": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping." - }, - "HTTP error: {status} — {message}": { - "en": "HTTP error: {status} — {message}", - "bg": "HTTP грешка: {status} — {message}", - "de": "HTTP-Fehler: {status} — {message}", - "ru": "Ошибка HTTP: {status} — {message}", - "zh": "HTTP 错误: {status} — {message}" - }, - "HTTP {status} Forbidden — your token lacks admin rights.\nMake sure the token belongs to a repo owner or organisation admin.\nAlternatively, configure branch protection manually in Settings → Branches.": { - "en": "HTTP {status} Forbidden — your token lacks admin rights.\nMake sure the token belongs to a repo owner or organisation admin.\nAlternatively, configure branch protection manually in Settings → Branches.", - "bg": "HTTP {status} Забранено — вашият токен няма администраторски права.\nУверете се, че токенът принадлежи на собственик на хранилище или администратор на организация.\nАлтернативно, конфигурирайте защитата на клона ръчно в Настройки → Клонове.", - "de": "HTTP {status} Verboten — Ihr Token hat keine Admin-Rechte.\nStellen Sie sicher, dass das Token einem Repository-Besitzer oder Organisations-Admin gehört.\nAlternativ können Sie den Branch-Schutz manuell unter Einstellungen → Branches konfigurieren.", - "ru": "HTTP {status} Запрещено — у вашего токена нет прав администратора.\nУбедитесь, что токен принадлежит владельцу репозитория или администратору организации.\nЛибо настройте защиту ветки вручную в разделе Настройки → Ветки.", - "zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。" - }, - "Head branch is behind master. Pulling and rebasing...": { - "en": "Head branch is behind master. Pulling and rebasing...", - "bg": "Head branch is behind master. Pulling and rebasing...", - "de": "Head branch is behind master. Pulling and rebasing...", - "ru": "Head branch is behind master. Pulling and rebasing...", - "zh": "Head branch is behind master. Pulling and rebasing..." - }, - "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": { - "en": "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}", - "bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}", - "de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}", - "ru": "Инфраструктурный коммит (без ID задачи DEVX-N), пропуск обновления Vikunja: {msg}", - "zh": "基础设施提交(无 DEVX-N 任务 ID),跳过 Vikunja 更新: {msg}" - }, - "Lint failed — refusing to release. Fix lint errors first.\n{stderr}": { - "en": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}", - "bg": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}", - "de": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}", - "ru": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}", - "zh": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}" - }, - "Lint passed.": { - "en": "Lint passed.", - "bg": "Lint passed.", - "de": "Lint passed.", - "ru": "Lint passed.", - "zh": "Lint passed." - }, - "Mapped file {file} is empty. Update the content or remove from mapping.json.": { - "en": "Mapped file {file} is empty. Update the content or remove from mapping.json.", - "bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.", - "de": "Mapped file {file} is empty. Update the content or remove from mapping.json.", - "ru": "Mapped file {file} is empty. Update the content or remove from mapping.json.", - "zh": "Mapped file {file} is empty. Update the content or remove from mapping.json." - }, - "Mapped file {file} not found. Update mapping.json or create the file.": { - "en": "Mapped file {file} not found. Update mapping.json or create the file.", - "bg": "Mapped file {file} not found. Update mapping.json or create the file.", - "de": "Mapped file {file} not found. Update mapping.json or create the file.", - "ru": "Mapped file {file} not found. Update mapping.json or create the file.", - "zh": "Mapped file {file} not found. Update mapping.json or create the file." - }, - "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.": { - "en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.", - "bg": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.", - "de": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.", - "ru": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.", - "zh": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually." - }, - "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": { - "en": "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.", - "bg": "Сливането неуспешно с HTTP {status}: {message}\nПроверете дали PR е готов и имате права за сливане.", - "de": "Merge fehlgeschlagen mit HTTP {status}: {message}\nBitte prüfen Sie, ob der PR bereit ist und Sie Merge-Rechte haben.", - "ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.", - "zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。" - }, - "Module {mod} has no main() function": { - "en": "Module {mod} has no main() function", - "bg": "Модул {mod} няма функция main()", - "de": "Modul {mod} hat keine main()-Funktion", - "ru": "Модуль {mod} не имеет функции main()", - "zh": "模块 {mod} 没有 main() 函数" - }, - "Molecule directory not found: {path}": { - "en": "Molecule directory not found: {path}", - "bg": "Директорията на molecule не е намерена: {path}", - "de": "Molecule-Verzeichnis nicht gefunden: {path}", - "ru": "Директория molecule не найдена: {path}", - "zh": "未找到 molecule 目录: {path}" - }, - "Nice! Gitea release {tag} created.": { - "en": "Nice! Gitea release {tag} created.", - "bg": "Отлично! Gitea release {tag} е създаден.", - "de": "Prima! Gitea-Release {tag} erstellt.", - "ru": "Отлично! Gitea release {tag} создан.", - "zh": "不错!Gitea release {tag} 已创建。" - }, - "Nice! PR #{pr_number} squash-merged with title: {merge_title}": { - "en": "Nice! PR #{pr_number} squash-merged with title: {merge_title}", - "bg": "Отлично! PR #{pr_number} е squash-merge-нат със заглавие: {merge_title}", - "de": "Prima! PR #{pr_number} wurde mit Titel {merge_title} squash-gemergt.", - "ru": "Отлично! PR #{pr_number} squash-merge с заголовком: {merge_title}", - "zh": "不错!PR #{pr_number} 已 squash 合并,标题: {merge_title}" - }, - "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.": { - "en": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.", - "bg": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.", - "de": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.", - "ru": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.", - "zh": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered." - }, - "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.": { - "en": "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.", - "bg": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) е обновена и маркирана като готова.", - "de": "Prima! Vikunja-Aufgabe {task_id} (ID {vikunja_id}) aktualisiert und als erledigt markiert.", - "ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.", - "zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。" - }, - "No changes between {base} and {head}.": { - "en": "No changes between {base} and {head}.", - "bg": "No changes between {base} and {head}.", - "de": "No changes between {base} and {head}.", - "ru": "No changes between {base} and {head}.", - "zh": "No changes between {base} and {head}." - }, - "No staged changes — version and changelog already up to date.": { - "en": "No staged changes — version and changelog already up to date.", - "bg": "No staged changes — version and changelog already up to date.", - "de": "No staged changes — version and changelog already up to date.", - "ru": "No staged changes — version and changelog already up to date.", - "zh": "No staged changes — version and changelog already up to date." - }, - "No tags found — treating all changes as user-facing.": { - "en": "No tags found — treating all changes as user-facing.", - "bg": "No tags found — treating all changes as user-facing.", - "de": "No tags found — treating all changes as user-facing.", - "ru": "No tags found — treating all changes as user-facing.", - "zh": "No tags found — treating all changes as user-facing." - }, - "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.": { - "en": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", - "bg": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", - "de": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", - "ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", - "zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID." - }, - "No unreleased changes found. Nothing to release.": { - "en": "No unreleased changes found. Nothing to release.", - "bg": "No unreleased changes found. Nothing to release.", - "de": "No unreleased changes found. Nothing to release.", - "ru": "No unreleased changes found. Nothing to release.", - "zh": "No unreleased changes found. Nothing to release." - }, - "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.": { - "en": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.", - "bg": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.", - "de": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.", - "ru": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.", - "zh": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release." - }, - "Note: Self-approval not allowed. Posting COMMENT instead.": { - "en": "Note: Self-approval not allowed. Posting COMMENT instead.", - "bg": "Note: Self-approval not allowed. Posting COMMENT instead.", - "de": "Note: Self-approval not allowed. Posting COMMENT instead.", - "ru": "Note: Self-approval not allowed. Posting COMMENT instead.", - "zh": "Note: Self-approval not allowed. Posting COMMENT instead." - }, - "Oops! Commit message must follow conventional commit format.\n Expected: : \n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE": { - "en": "Oops! Commit message must follow conventional commit format.\n Expected: : \n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", - "bg": "Опа! Съобщението за commit трябва да следва конвенционален формат.\n Очаква се: : \n Получено: {subject}\n Разрешени типове: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", - "de": "Ups! Commit-Nachricht muss dem konventionellen Commit-Format folgen.\n Erwartet: : \n Erhalten: {subject}\n Erlaubte Typen: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", - "ru": "Ой! Сообщение коммита должно соответствовать формату conventional commit.\n Ожидается: : \n Получено: {subject}\n Допустимые типы: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", - "zh": "哎呀!提交消息必须遵循 conventional commit 格式。\n 预期格式: : \n 实际: {subject}\n 允许的类型: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE" - }, - "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": { - "en": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.", - "bg": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.", - "de": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.", - "ru": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.", - "zh": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI." - }, - "Oops! Gitea PyPI registry publish failed:\n{stderr}": { - "en": "Oops! Gitea PyPI registry publish failed:\n{stderr}", - "bg": "Опа! Публикуването в Gitea PyPI registry неуспешно:\n{stderr}", - "de": "Ups! Veröffentlichung in der Gitea PyPI-Registry fehlgeschlagen:\n{stderr}", - "ru": "Ой! Публикация в Gitea PyPI registry не удалась:\n{stderr}", - "zh": "哎呀!Gitea PyPI registry 发布失败:\n{stderr}" - }, - "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}": { - "en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}", - "bg": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}", - "de": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}", - "ru": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}", - "zh": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}" - }, - "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}": { - "en": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}", - "bg": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}", - "de": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}", - "ru": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}", - "zh": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}" - }, - "Oops! No task ID found in .taskid file or branch name '{branch}'.": { - "en": "Oops! No task ID found in .taskid file or branch name '{branch}'.", - "bg": "Oops! No task ID found in .taskid file or branch name '{branch}'.", - "de": "Oops! No task ID found in .taskid file or branch name '{branch}'.", - "ru": "Oops! No task ID found in .taskid file or branch name '{branch}'.", - "zh": "Oops! No task ID found in .taskid file or branch name '{branch}'." - }, - "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}": { - "en": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}", - "bg": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}", - "de": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}", - "ru": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}", - "zh": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}" - }, - "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}": { - "en": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}", - "bg": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}", - "de": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}", - "ru": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}", - "zh": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}" - }, - "Oops! Package build failed:\n{stderr}": { - "en": "Oops! Package build failed:\n{stderr}", - "bg": "Опа! Сборката на пакета неуспешна:\n{stderr}", - "de": "Ups! Paket-Build fehlgeschlagen:\n{stderr}", - "ru": "Ой! Сборка пакета не удалась:\n{stderr}", - "zh": "哎呀!包构建失败:\n{stderr}" - }, - "Oops! PyPI publish failed:\n{stderr}": { - "en": "Oops! PyPI publish failed:\n{stderr}", - "bg": "Опа! Публикуването в PyPI неуспешно:\n{stderr}", - "de": "Ups! PyPI-Veröffentlichung fehlgeschlagen:\n{stderr}", - "ru": "Ой! Публикация в PyPI не удалась:\n{stderr}", - "zh": "哎呀!PyPI 发布失败:\n{stderr}" - }, - "PASSED: {pair}": { - "en": "PASSED: {pair}", - "bg": "PASSED: {pair}", - "de": "PASSED: {pair}", - "ru": "PASSED: {pair}", - "zh": "PASSED: {pair}" - }, - "PR number must be an integer, got: {pr_number}": { - "en": "PR number must be an integer, got: {pr_number}", - "bg": "PR number must be an integer, got: {pr_number}", - "de": "PR number must be an integer, got: {pr_number}", - "ru": "PR number must be an integer, got: {pr_number}", - "zh": "PR number must be an integer, got: {pr_number}" - }, - "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": { - "en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", - "bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", - "de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", - "ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", - "zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}" - }, - "PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.": { - "en": "PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.", - "bg": "PYPI_TOKEN не е зададен и няма конфигуриран URL на registry — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.", - "de": "PYPI_TOKEN nicht gesetzt und keine Registry-URL konfiguriert — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.", - "ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.", - "zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。" - }, - "Published to Gitea PyPI registry.": { - "en": "Published to Gitea PyPI registry.", - "bg": "Публикувано в Gitea PyPI registry.", - "de": "In der Gitea PyPI-Registry veröffentlicht.", - "ru": "Опубликовано в Gitea PyPI registry.", - "zh": "已发布到 Gitea PyPI registry。" - }, - "Published to PyPI.": { - "en": "Published to PyPI.", - "bg": "Публикувано в PyPI.", - "de": "In PyPI veröffentlicht.", - "ru": "Опубликовано в PyPI.", - "zh": "已发布到 PyPI。" - }, - "Pushed release commit to master.": { - "en": "Pushed release commit to master.", - "bg": "Pushed release commit to master.", - "de": "Pushed release commit to master.", - "ru": "Pushed release commit to master.", - "zh": "Pushed release commit to master." - }, - "Rebased and pushed. Retrying merge...": { - "en": "Rebased and pushed. Retrying merge...", - "bg": "Rebased and pushed. Retrying merge...", - "de": "Rebased and pushed. Retrying merge...", - "ru": "Rebased and pushed. Retrying merge...", - "zh": "Rebased and pushed. Retrying merge..." - }, - "Release creation failed: {error}": { - "en": "Release creation failed: {error}", - "bg": "Release creation failed: {error}", - "de": "Release creation failed: {error}", - "ru": "Release creation failed: {error}", - "zh": "Release creation failed: {error}" - }, - "Release must be run on master, currently on '{branch}'.": { - "en": "Release must be run on master, currently on '{branch}'.", - "bg": "Release must be run on master, currently on '{branch}'.", - "de": "Release must be run on master, currently on '{branch}'.", - "ru": "Release must be run on master, currently on '{branch}'.", - "zh": "Release must be run on master, currently on '{branch}'." - }, - "Repo must be in 'owner/name' format, got: {repo}": { - "en": "Repo must be in 'owner/name' format, got: {repo}", - "bg": "Repo must be in 'owner/name' format, got: {repo}", - "de": "Repo must be in 'owner/name' format, got: {repo}", - "ru": "Repo must be in 'owner/name' format, got: {repo}", - "zh": "Repo must be in 'owner/name' format, got: {repo}" - }, - "Repository configuration complete.": { - "en": "Repository configuration complete.", - "bg": "Конфигурирането на хранилището е завършено.", - "de": "Repository-Konfiguration abgeschlossen.", - "ru": "Конфигурация репозитория завершена.", - "zh": "仓库配置完成。" - }, - "Runner index {index} out of range (0..{max})": { - "en": "Runner index {index} out of range (0..{max})", - "bg": "Индексът на runner {index} е извън диапазона (0..{max})", - "de": "Runner-Index {index} außerhalb des Bereichs (0..{max})", - "ru": "Индекс runner {index} вне диапазона (0..{max})", - "zh": "Runner 索引 {index} 超出范围 (0..{max})" - }, - "Running lint checks...": { - "en": "Running lint checks...", - "bg": "Running lint checks...", - "de": "Running lint checks...", - "ru": "Running lint checks...", - "zh": "Running lint checks..." - }, - "Running tests...": { - "en": "Running tests...", - "bg": "Running tests...", - "de": "Running tests...", - "ru": "Running tests...", - "zh": "Running tests..." - }, - "Running: {scenario} on {platform}": { - "en": "Running: {scenario} on {platform}", - "bg": "Running: {scenario} on {platform}", - "de": "Running: {scenario} on {platform}", - "ru": "Running: {scenario} on {platform}", - "zh": "Running: {scenario} on {platform}" - }, - "Skipping commit push — no staged changes.": { - "en": "Skipping commit push — no staged changes.", - "bg": "Skipping commit push — no staged changes.", - "de": "Skipping commit push — no staged changes.", - "ru": "Skipping commit push — no staged changes.", - "zh": "Skipping commit push — no staged changes." - }, - "Syncing {count} documentation pages to wiki...": { - "en": "Syncing {count} documentation pages to wiki...", - "bg": "Syncing {count} documentation pages to wiki...", - "de": "Syncing {count} documentation pages to wiki...", - "ru": "Syncing {count} documentation pages to wiki...", - "zh": "Syncing {count} documentation pages to wiki..." - }, - "Tag consistency check failed.": { - "en": "Tag consistency check failed.", - "bg": "Tag consistency check failed.", - "de": "Tag consistency check failed.", - "ru": "Tag consistency check failed.", - "zh": "Tag consistency check failed." - }, - "Tag v{version} already existed. Publish workflow should already have been triggered.": { - "en": "Tag v{version} already existed. Publish workflow should already have been triggered.", - "bg": "Tag v{version} already existed. Publish workflow should already have been triggered.", - "de": "Tag v{version} already existed. Publish workflow should already have been triggered.", - "ru": "Tag v{version} already existed. Publish workflow should already have been triggered.", - "zh": "Tag v{version} already existed. Publish workflow should already have been triggered." - }, - "Tag {tag} already exists and points to HEAD. Skipping creation.": { - "en": "Tag {tag} already exists and points to HEAD. Skipping creation.", - "bg": "Tag {tag} already exists and points to HEAD. Skipping creation.", - "de": "Tag {tag} already exists and points to HEAD. Skipping creation.", - "ru": "Tag {tag} already exists and points to HEAD. Skipping creation.", - "zh": "Tag {tag} already exists and points to HEAD. Skipping creation." - }, - "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.": { - "en": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.", - "bg": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.", - "de": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.", - "ru": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.", - "zh": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details." - }, - "Task ID: {task_id}": { - "en": "Task ID: {task_id}", - "bg": "Task ID: {task_id}", - "de": "Task ID: {task_id}", - "ru": "Task ID: {task_id}", - "zh": "Task ID: {task_id}" - }, - "Tests failed — refusing to release. Fix test failures first.\n{stderr}": { - "en": "Tests failed — refusing to release. Fix test failures first.\n{stderr}", - "bg": "Tests failed — refusing to release. Fix test failures first.\n{stderr}", - "de": "Tests failed — refusing to release. Fix test failures first.\n{stderr}", - "ru": "Tests failed — refusing to release. Fix test failures first.\n{stderr}", - "zh": "Tests failed — refusing to release. Fix test failures first.\n{stderr}" - }, - "Tests passed.": { - "en": "Tests passed.", - "bg": "Tests passed.", - "de": "Tests passed.", - "ru": "Tests passed.", - "zh": "Tests passed." - }, - "Unit tests passed in {duration:.2f}s (under {max}s limit).": { - "en": "Unit tests passed in {duration:.2f}s (under {max}s limit).", - "bg": "Unit tests passed in {duration:.2f}s (under {max}s limit).", - "de": "Unit tests passed in {duration:.2f}s (under {max}s limit).", - "ru": "Unit tests passed in {duration:.2f}s (under {max}s limit).", - "zh": "Unit tests passed in {duration:.2f}s (under {max}s limit)." - }, - "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.": { - "en": "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.", - "bg": "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.", - "de": "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.", - "ru": "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.", - "zh": "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." - }, - "Unknown check category '{check}'. Available: all, user-facing{tags}": { - "en": "Unknown check category '{check}'. Available: all, user-facing{tags}", - "bg": "Unknown check category '{check}'. Available: all, user-facing{tags}", - "de": "Unknown check category '{check}'. Available: all, user-facing{tags}", - "ru": "Unknown check category '{check}'. Available: all, user-facing{tags}", - "zh": "Unknown check category '{check}'. Available: all, user-facing{tags}" - }, - "Updated version in {init}": { - "en": "Updated version in {init}", - "bg": "Updated version in {init}", - "de": "Updated version in {init}", - "ru": "Updated version in {init}", - "zh": "Updated version in {init}" - }, - "Updated {changelog_file}": { - "en": "Updated {changelog_file}", - "bg": "Updated {changelog_file}", - "de": "Updated {changelog_file}", - "ru": "Updated {changelog_file}", - "zh": "Updated {changelog_file}" - }, - "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.": { - "en": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", - "bg": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", - "de": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", - "ru": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", - "zh": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles." - }, - "Version file: {file}": { - "en": "Version file: {file}", - "bg": "Version file: {file}", - "de": "Version file: {file}", - "ru": "Version file: {file}", - "zh": "Version file: {file}" - }, - "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.": { - "en": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", - "bg": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", - "de": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", - "ru": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", - "zh": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update." - }, - "WARNING: --skip-tests passed — skipping test verification.": { - "en": "WARNING: --skip-tests passed — skipping test verification.", - "bg": "WARNING: --skip-tests passed — skipping test verification.", - "de": "WARNING: --skip-tests passed — skipping test verification.", - "ru": "WARNING: --skip-tests passed — skipping test verification.", - "zh": "WARNING: --skip-tests passed — skipping test verification." - }, - "Warning: could not fetch tags from origin.": { - "en": "Warning: could not fetch tags from origin.", - "bg": "Warning: could not fetch tags from origin.", - "de": "Warning: could not fetch tags from origin.", - "ru": "Warning: could not fetch tags from origin.", - "zh": "Warning: could not fetch tags from origin." - }, - "Wiki integrity check failed — {count} issue(s)": { - "en": "Wiki integrity check failed — {count} issue(s)", - "bg": "Wiki integrity check failed — {count} issue(s)", - "de": "Wiki integrity check failed — {count} issue(s)", - "ru": "Wiki integrity check failed — {count} issue(s)", - "zh": "Wiki integrity check failed — {count} issue(s)" - }, - "Wiki verification failed — {failures} page(s) empty or mismatched": { - "en": "Wiki verification failed — {failures} page(s) empty or mismatched", - "bg": "Wiki verification failed — {failures} page(s) empty or mismatched", - "de": "Wiki verification failed — {failures} page(s) empty or mismatched", - "ru": "Wiki verification failed — {failures} page(s) empty or mismatched", - "zh": "Wiki verification failed — {failures} page(s) empty or mismatched" - }, - "[dry-run] Would commit: release: v{version}": { - "en": "[dry-run] Would commit: release: v{version}", - "bg": "[dry-run] Would commit: release: v{version}", - "de": "[dry-run] Would commit: release: v{version}", - "ru": "[dry-run] Would commit: release: v{version}", - "zh": "[dry-run] Would commit: release: v{version}" - }, - "[dry-run] Would create tag: v{version}": { - "en": "[dry-run] Would create tag: v{version}", - "bg": "[dry-run] Would create tag: v{version}", - "de": "[dry-run] Would create tag: v{version}", - "ru": "[dry-run] Would create tag: v{version}", - "zh": "[dry-run] Would create tag: v{version}" - }, - "[dry-run] Would create tag: {tag}": { - "en": "[dry-run] Would create tag: {tag}", - "bg": "[dry-run] Would create tag: {tag}", - "de": "[dry-run] Would create tag: {tag}", - "ru": "[dry-run] Would create tag: {tag}", - "zh": "[dry-run] Would create tag: {tag}" - }, - "[dry-run] Would push commit to master": { - "en": "[dry-run] Would push commit to master", - "bg": "[dry-run] Would push commit to master", - "de": "[dry-run] Would push commit to master", - "ru": "[dry-run] Would push commit to master", - "zh": "[dry-run] Would push commit to master" - }, - "[dry-run] Would sync page: {title} ({chars} chars)": { - "en": "[dry-run] Would sync page: {title} ({chars} chars)", - "bg": "[dry-run] Would sync page: {title} ({chars} chars)", - "de": "[dry-run] Would sync page: {title} ({chars} chars)", - "ru": "[dry-run] Would sync page: {title} ({chars} chars)", - "zh": "[dry-run] Would sync page: {title} ({chars} chars)" - }, - "[dry-run] Would update {changelog_file}": { - "en": "[dry-run] Would update {changelog_file}", - "bg": "[dry-run] Would update {changelog_file}", - "de": "[dry-run] Would update {changelog_file}", - "ru": "[dry-run] Would update {changelog_file}", - "zh": "[dry-run] Would update {changelog_file}" - }, - "[dry-run] Would update {init}": { - "en": "[dry-run] Would update {init}", - "bg": "[dry-run] Would update {init}", - "de": "[dry-run] Would update {init}", - "ru": "[dry-run] Would update {init}", - "zh": "[dry-run] Would update {init}" - }, - "active": { - "en": "active", - "bg": "активен", - "de": "aktiv", - "ru": "активен", - "zh": "活跃" - }, - "completed": { - "en": "completed", - "bg": "завършен", - "de": "abgeschlossen", - "ru": "завершён", - "zh": "已完成" - }, - "failed": { - "en": "failed", - "bg": "неуспешен", - "de": "fehlgeschlagen", - "ru": "неудачный", - "zh": "失败" - }, - "git command failed ({cmd}): {stderr}": { - "en": "git command failed ({cmd}): {stderr}", - "bg": "git command failed ({cmd}): {stderr}", - "de": "git command failed ({cmd}): {stderr}", - "ru": "git command failed ({cmd}): {stderr}", - "zh": "git command failed ({cmd}): {stderr}" - }, - "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.": { - "en": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", - "bg": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", - "de": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", - "ru": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", - "zh": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history." - }, - "git-cliff returned empty version.": { - "en": "git-cliff returned empty version.", - "bg": "git-cliff returned empty version.", - "de": "git-cliff returned empty version.", - "ru": "git-cliff returned empty version.", - "zh": "git-cliff returned empty version." - }, - "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).": { - "en": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", - "bg": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", - "de": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", - "ru": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", - "zh": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1)." - }, - "in_progress": { - "en": "in progress", - "bg": "в процес", - "de": "in Bearbeitung", - "ru": "в процессе", - "zh": "进行中" - }, - "inactive": { - "en": "inactive", - "bg": "неактивен", - "de": "inaktiv", - "ru": "неактивен", - "zh": "未激活" - }, - "mapping.json keys and values must be strings, got {k}={v}": { - "en": "mapping.json keys and values must be strings, got {k}={v}", - "bg": "mapping.json keys and values must be strings, got {k}={v}", - "de": "mapping.json keys and values must be strings, got {k}={v}", - "ru": "mapping.json keys and values must be strings, got {k}={v}", - "zh": "mapping.json keys and values must be strings, got {k}={v}" - }, - "mapping.json must be a dict of file-path -> page-title, got {type}": { - "en": "mapping.json must be a dict of file-path -> page-title, got {type}", - "bg": "mapping.json must be a dict of file-path -> page-title, got {type}", - "de": "mapping.json must be a dict of file-path -> page-title, got {type}", - "ru": "mapping.json must be a dict of file-path -> page-title, got {type}", - "zh": "mapping.json must be a dict of file-path -> page-title, got {type}" - }, - "pending": { - "en": "pending", - "bg": "в очакване", - "de": "ausstehend", - "ru": "ожидает", - "zh": "待处理" - }, - "unknown": { - "en": "unknown", - "bg": "неизвестен", - "de": "unbekannt", - "ru": "неизвестно", - "zh": "未知" - }, - "{file} already exists. Use --force to overwrite.": { - "en": "{file} already exists. Use --force to overwrite.", - "bg": "{file} already exists. Use --force to overwrite.", - "de": "{file} already exists. Use --force to overwrite.", - "ru": "{file} already exists. Use --force to overwrite.", - "zh": "{file} already exists. Use --force to overwrite." - } + "\n=== Summary ===": { + "en": "\n=== Summary ===", + "bg": "\n=== Summary ===", + "de": "\n=== Summary ===", + "ru": "\n=== Summary ===", + "zh": "\n=== Summary ===" + }, + "\nAll documentation coverage checks passed!": { + "en": "\nAll documentation coverage checks passed!", + "bg": "\nAll documentation coverage checks passed!", + "de": "\nAll documentation coverage checks passed!", + "ru": "\nAll documentation coverage checks passed!", + "zh": "\nAll documentation coverage checks passed!" + }, + "\nCHANGELOG version ordering:": { + "en": "\nCHANGELOG version ordering:", + "bg": "\nCHANGELOG version ordering:", + "de": "\nCHANGELOG version ordering:", + "ru": "\nCHANGELOG version ordering:", + "zh": "\nCHANGELOG version ordering:" + }, + "\nChecking CI script documentation in ci-cd-workflow.md...": { + "en": "\nChecking CI script documentation in ci-cd-workflow.md...", + "bg": "\nChecking CI script documentation in ci-cd-workflow.md...", + "de": "\nChecking CI script documentation in ci-cd-workflow.md...", + "ru": "\nChecking CI script documentation in ci-cd-workflow.md...", + "zh": "\nChecking CI script documentation in ci-cd-workflow.md..." + }, + "\nChecking module documentation in architecture.md...": { + "en": "\nChecking module documentation in architecture.md...", + "bg": "\nChecking module documentation in architecture.md...", + "de": "\nChecking module documentation in architecture.md...", + "ru": "\nChecking module documentation in architecture.md...", + "zh": "\nChecking module documentation in architecture.md..." + }, + "\nDoc coverage: {covered}/{total} ({pct}%)": { + "en": "\nDoc coverage: {covered}/{total} ({pct}%)", + "bg": "\nDoc coverage: {covered}/{total} ({pct}%)", + "de": "\nDoc coverage: {covered}/{total} ({pct}%)", + "ru": "\nDoc coverage: {covered}/{total} ({pct}%)", + "zh": "\nDoc coverage: {covered}/{total} ({pct}%)" + }, + "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}": { + "en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}", + "bg": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}", + "de": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}", + "ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}", + "zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}" + }, + "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": { + "en": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.", + "bg": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.", + "de": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.", + "ru": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.", + "zh": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce." + }, + "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.": { + "en": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.", + "bg": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.", + "de": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.", + "ru": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.", + "zh": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report." + }, + "\nIntegrity check FAILED ({count} issues):": { + "en": "\nIntegrity check FAILED ({count} issues):", + "bg": "\nIntegrity check FAILED ({count} issues):", + "de": "\nIntegrity check FAILED ({count} issues):", + "ru": "\nIntegrity check FAILED ({count} issues):", + "zh": "\nIntegrity check FAILED ({count} issues):" + }, + "\nIntegrity check passed — all {count} pages verified.": { + "en": "\nIntegrity check passed — all {count} pages verified.", + "bg": "\nIntegrity check passed — all {count} pages verified.", + "de": "\nIntegrity check passed — all {count} pages verified.", + "ru": "\nIntegrity check passed — all {count} pages verified.", + "zh": "\nIntegrity check passed — all {count} pages verified." + }, + "\nLatest tag: {tag}": { + "en": "\nLatest tag: {tag}", + "bg": "\nLatest tag: {tag}", + "de": "\nLatest tag: {tag}", + "ru": "\nLatest tag: {tag}", + "zh": "\nLatest tag: {tag}" + }, + "\nMissing documentation:": { + "en": "\nMissing documentation:", + "bg": "\nMissing documentation:", + "de": "\nMissing documentation:", + "ru": "\nMissing documentation:", + "zh": "\nMissing documentation:" + }, + "\nResult: {status}": { + "en": "\nResult: {status}", + "bg": "\nResult: {status}", + "de": "\nResult: {status}", + "ru": "\nResult: {status}", + "zh": "\nResult: {status}" + }, + "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).": { + "en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).", + "bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).", + "de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).", + "ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).", + "zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)." + }, + "\nRunning full wiki integrity check...": { + "en": "\nRunning full wiki integrity check...", + "bg": "\nRunning full wiki integrity check...", + "de": "\nRunning full wiki integrity check...", + "ru": "\nRunning full wiki integrity check...", + "zh": "\nRunning full wiki integrity check..." + }, + "\nTag → Commit alignment:": { + "en": "\nTag → Commit alignment:", + "bg": "\nTag → Commit alignment:", + "de": "\nTag → Commit alignment:", + "ru": "\nTag → Commit alignment:", + "zh": "\nTag → Commit alignment:" + }, + "\nUntagged release commits:": { + "en": "\nUntagged release commits:", + "bg": "\nUntagged release commits:", + "de": "\nUntagged release commits:", + "ru": "\nUntagged release commits:", + "zh": "\nUntagged release commits:" + }, + "\nUser-facing changes ({count}):": { + "en": "\nUser-facing changes ({count}):", + "bg": "\nUser-facing changes ({count}):", + "de": "\nUser-facing changes ({count}):", + "ru": "\nUser-facing changes ({count}):", + "zh": "\nUser-facing changes ({count}):" + }, + "\nVerification FAILED: {failures} page(s) have empty or mismatched content!": { + "en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!", + "bg": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!", + "de": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!", + "ru": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!", + "zh": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!" + }, + "\nVerification passed — all wiki pages have correct content.": { + "en": "\nVerification passed — all wiki pages have correct content.", + "bg": "\nVerification passed — all wiki pages have correct content.", + "de": "\nVerification passed — all wiki pages have correct content.", + "ru": "\nVerification passed — all wiki pages have correct content.", + "zh": "\nVerification passed — all wiki pages have correct content." + }, + "\nVerifying wiki pages have content...": { + "en": "\nVerifying wiki pages have content...", + "bg": "\nVerifying wiki pages have content...", + "de": "\nVerifying wiki pages have content...", + "ru": "\nVerifying wiki pages have content...", + "zh": "\nVerifying wiki pages have content..." + }, + "\nWorkflow-only changes ({count}):": { + "en": "\nWorkflow-only changes ({count}):", + "bg": "\nWorkflow-only changes ({count}):", + "de": "\nWorkflow-only changes ({count}):", + "ru": "\nWorkflow-only changes ({count}):", + "zh": "\nWorkflow-only changes ({count}):" + }, + "\n[dry-run] Changelog:\n{changelog}": { + "en": "\n[dry-run] Changelog:\n{changelog}", + "bg": "\n[dry-run] Changelog:\n{changelog}", + "de": "\n[dry-run] Changelog:\n{changelog}", + "ru": "\n[dry-run] Changelog:\n{changelog}", + "zh": "\n[dry-run] Changelog:\n{changelog}" + }, + "\n{label} files changed ({count}):": { + "en": "\n{label} files changed ({count}):", + "bg": "\n{label} files changed ({count}):", + "de": "\n{label} files changed ({count}):", + "ru": "\n{label} files changed ({count}):", + "zh": "\n{label} files changed ({count}):" + }, + "\n{tag} files ({count}):": { + "en": "\n{tag} files ({count}):", + "bg": "\n{tag} files ({count}):", + "de": "\n{tag} files ({count}):", + "ru": "\n{tag} files ({count}):", + "zh": "\n{tag} files ({count}):" + }, + " - Auto-delete branch after merge: yes": { + "en": " - Auto-delete branch after merge: yes", + "bg": " - Автоматично изтриване на клон след сливане: да", + "de": " - Branch nach Merge automatisch löschen: ja", + "ru": " - Автоудаление ветки после слияния: да", + "zh": " - 合并后自动删除分支: 是" + }, + " - Block outdated branches: yes": { + "en": " - Block outdated branches: yes", + "bg": " - Блокиране на остарели клонове: да", + "de": " - Veraltete Branches blockieren: ja", + "ru": " - Блокировать устаревшие ветки: да", + "zh": " - 阻止过时分支: 是" + }, + " - Block rejected reviews: yes": { + "en": " - Block rejected reviews: yes", + "bg": " - Блокиране на отхвърлени рецензии: да", + "de": " - Abgelehnte Reviews blockieren: ja", + "ru": " - Блокировать отклонённые ревью: да", + "zh": " - 阻止被拒绝的审查: 是" + }, + " - Direct pushes: BLOCKED (require PR, whitelisted users can push)": { + "en": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)", + "bg": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)", + "de": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)", + "ru": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)", + "zh": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)" + }, + " - Dismiss stale approvals: yes": { + "en": " - Dismiss stale approvals: yes", + "bg": " - Анулиране на остарели одобрения: да", + "de": " - Veraltete Genehmigungen ablehnen: ja", + "ru": " - Отклонять устаревшие одобрения: да", + "zh": " - 忽略过时审批: 是" + }, + " - Required approvals: {count}": { + "en": " - Required approvals: {count}", + "bg": " - Необходими одобрения: {count}", + "de": " - Erforderliche Genehmigungen: {count}", + "ru": " - Требуемые одобрения: {count}", + "zh": " - 必需审批数: {count}" + }, + " - Required status checks: {checks}": { + "en": " - Required status checks: {checks}", + "bg": " - Необходими проверки на състоянието: {checks}", + "de": " - Erforderliche Status-Checks: {checks}", + "ru": " - Требуемые проверки статуса: {checks}", + "zh": " - 必需状态检查: {checks}" + }, + " Created: {title}": { + "en": " Created: {title}", + "bg": " Created: {title}", + "de": " Created: {title}", + "ru": " Created: {title}", + "zh": " Created: {title}" + }, + " FAIL: {title} — content mismatch or empty!": { + "en": " FAIL: {title} — content mismatch or empty!", + "bg": " FAIL: {title} — content mismatch or empty!", + "de": " FAIL: {title} — content mismatch or empty!", + "ru": " FAIL: {title} — content mismatch or empty!", + "zh": " FAIL: {title} — content mismatch or empty!" + }, + " MISSING: devx {cmd}": { + "en": " MISSING: devx {cmd}", + "bg": " ЛИПСВА: devx {cmd}", + "de": " FEHLT: devx {cmd}", + "ru": " ОТСУТСТВУЕТ: devx {cmd}", + "zh": " 缺失: devx {cmd}" + }, + " MISSING: {module}": { + "en": " MISSING: {module}", + "bg": " MISSING: {module}", + "de": " MISSING: {module}", + "ru": " MISSING: {module}", + "zh": " MISSING: {module}" + }, + " MISSING: {script}": { + "en": " MISSING: {script}", + "bg": " MISSING: {script}", + "de": " MISSING: {script}", + "ru": " MISSING: {script}", + "zh": " MISSING: {script}" + }, + " OK: devx {cmd}": { + "en": " OK: devx {cmd}", + "bg": " ОК: devx {cmd}", + "de": " OK: devx {cmd}", + "ru": " ОК: devx {cmd}", + "zh": " 正常: devx {cmd}" + }, + " OK: {module}": { + "en": " OK: {module}", + "bg": " OK: {module}", + "de": " OK: {module}", + "ru": " OK: {module}", + "zh": " OK: {module}" + }, + " OK: {script}": { + "en": " OK: {script}", + "bg": " OK: {script}", + "de": " OK: {script}", + "ru": " OK: {script}", + "zh": " OK: {script}" + }, + " OK: {title} ({chars} chars)": { + "en": " OK: {title} ({chars} chars)", + "bg": " OK: {title} ({chars} chars)", + "de": " OK: {title} ({chars} chars)", + "ru": " OK: {title} ({chars} chars)", + "zh": " OK: {title} ({chars} chars)" + }, + " Updated: {title}": { + "en": " Updated: {title}", + "bg": " Updated: {title}", + "de": " Updated: {title}", + "ru": " Updated: {title}", + "zh": " Updated: {title}" + }, + "=== Release Alignment Verification ===\n": { + "en": "=== Release Alignment Verification ===\n", + "bg": "=== Release Alignment Verification ===\n", + "de": "=== Release Alignment Verification ===\n", + "ru": "=== Release Alignment Verification ===\n", + "zh": "=== Release Alignment Verification ===\n" + }, + "API poll warning: {exc}": { + "en": "API poll warning: {exc}", + "bg": "API poll warning: {exc}", + "de": "API poll warning: {exc}", + "ru": "API poll warning: {exc}", + "zh": "API poll warning: {exc}" + }, + "All molecule tests passed.": { + "en": "All molecule tests passed.", + "bg": "All molecule tests passed.", + "de": "All molecule tests passed.", + "ru": "All molecule tests passed.", + "zh": "All molecule tests passed." + }, + "Another molecule runner failed. Stopping this runner early.": { + "en": "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.", + "ru": "Another molecule runner failed. Stopping this runner early.", + "zh": "Another molecule runner failed. Stopping this runner early." + }, + "Bumping version: {current} -> v{new_version}": { + "en": "Bumping version: {current} -> v{new_version}", + "bg": "Bumping version: {current} -> v{new_version}", + "de": "Bumping version: {current} -> v{new_version}", + "ru": "Bumping version: {current} -> v{new_version}", + "zh": "Bumping version: {current} -> v{new_version}" + }, + "Checking CLI command documentation...": { + "en": "Checking CLI command documentation...", + "bg": "Checking CLI command documentation...", + "de": "Checking CLI command documentation...", + "ru": "Checking CLI command documentation...", + "zh": "Checking CLI command documentation..." + }, + "Command failed ({cmd}): {stderr}": { + "en": "Command failed ({cmd}): {stderr}", + "bg": "Command failed ({cmd}): {stderr}", + "de": "Command failed ({cmd}): {stderr}", + "ru": "Command failed ({cmd}): {stderr}", + "zh": "Command failed ({cmd}): {stderr}" + }, + "Comparing {base}..{head} ({count} files changed)": { + "en": "Comparing {base}..{head} ({count} files changed)", + "bg": "Comparing {base}..{head} ({count} files changed)", + "de": "Comparing {base}..{head} ({count} files changed)", + "ru": "Comparing {base}..{head} ({count} files changed)", + "zh": "Comparing {base}..{head} ({count} files changed)" + }, + "Configuring branch protection for {branch}...": { + "en": "Configuring branch protection for {branch}...", + "bg": "Конфигуриране на защита на клона {branch}...", + "de": "Konfiguriere Branch-Schutz für {branch}...", + "ru": "Настройка защиты ветки {branch}...", + "zh": "正在配置 {branch} 的分支保护..." + }, + "Configuring repository settings...": { + "en": "Configuring repository settings...", + "bg": "Конфигуриране на настройките на хранилището...", + "de": "Repository-Einstellungen konfigurieren...", + "ru": "Настройка параметров репозитория...", + "zh": "正在配置仓库设置..." + }, + "Could not extract conventional commit message from PR commits.": { + "en": "Could not extract conventional commit message from PR commits.", + "bg": "Could not extract conventional commit message from PR commits.", + "de": "Could not extract conventional commit message from PR commits.", + "ru": "Could not extract conventional commit message from PR commits.", + "zh": "Could not extract conventional commit message from PR commits." + }, + "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": { + "en": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.", + "bg": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.", + "de": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.", + "ru": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.", + "zh": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task." + }, + "Could not find __version__ in {file}": { + "en": "Could not find __version__ in {file}", + "bg": "Could not find __version__ in {file}", + "de": "Could not find __version__ in {file}", + "ru": "Could not find __version__ in {file}", + "zh": "Could not find __version__ in {file}" + }, + "Could not parse test execution time from output.": { + "en": "Could not parse test execution time from output.", + "bg": "Could not parse test execution time from output.", + "de": "Could not parse test execution time from output.", + "ru": "Could not parse test execution time from output.", + "zh": "Could not parse test execution time from output." + }, + "Created issue #{issue_id}: {title}": { + "en": "Created issue #{issue_id}: {title}", + "bg": "Created issue #{issue_id}: {title}", + "de": "Created issue #{issue_id}: {title}", + "ru": "Created issue #{issue_id}: {title}", + "zh": "Created issue #{issue_id}: {title}" + }, + "Created release commit.": { + "en": "Created release commit.", + "bg": "Created release commit.", + "de": "Created release commit.", + "ru": "Created release commit.", + "zh": "Created release commit." + }, + "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": { + "en": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", + "bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", + "de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", + "ru": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", + "zh": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently." + }, + "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。" + }, + "ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.": { + "en": "ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.", + "bg": "ГРЕШКА: Името на хранилището не е указано. Използвайте --repo или задайте DEVX_REPO_NAME.", + "de": "FEHLER: Repository-Name nicht angegeben. Verwenden Sie --repo oder setzen Sie DEVX_REPO_NAME.", + "ru": "ОШИБКА: Имя репозитория не указано. Используйте --repo или задайте DEVX_REPO_NAME.", + "zh": "错误:未指定仓库名称。请使用 --repo 或设置 DEVX_REPO_NAME。" + }, + "ERROR: Tag consistency check failed. Existing tags are misaligned:": { + "en": "ERROR: Tag consistency check failed. Existing tags are misaligned:", + "bg": "ERROR: Tag consistency check failed. Existing tags are misaligned:", + "de": "ERROR: Tag consistency check failed. Existing tags are misaligned:", + "ru": "ERROR: Tag consistency check failed. Existing tags are misaligned:", + "zh": "ERROR: Tag consistency check failed. Existing tags are misaligned:" + }, + "ERROR: VIKUNJA_TOKEN is not set.": { + "en": "ERROR: VIKUNJA_TOKEN is not set.", + "bg": "ГРЕШКА: VIKUNJA_TOKEN не е зададен.", + "de": "FEHLER: VIKUNJA_TOKEN ist nicht gesetzt.", + "ru": "ОШИБКА: VIKUNJA_TOKEN не задан.", + "zh": "错误:未设置 VIKUNJA_TOKEN。" + }, + "ERROR: mapping.json not found at {path}": { + "en": "ERROR: mapping.json not found at {path}", + "bg": "ERROR: mapping.json not found at {path}", + "de": "ERROR: mapping.json not found at {path}", + "ru": "ERROR: mapping.json not found at {path}", + "zh": "ERROR: mapping.json not found at {path}" + }, + "FAILED: {pair} exited with code {code}": { + "en": "FAILED: {pair} exited with code {code}", + "bg": "FAILED: {pair} exited with code {code}", + "de": "FAILED: {pair} exited with code {code}", + "ru": "FAILED: {pair} exited with code {code}", + "zh": "FAILED: {pair} exited with code {code}" + }, + "Failed to create issue via tea: {error}": { + "en": "Failed to create issue via tea: {error}", + "bg": "Failed to create issue via tea: {error}", + "de": "Failed to create issue via tea: {error}", + "ru": "Failed to create issue via tea: {error}", + "zh": "Failed to create issue via tea: {error}" + }, + "Found {count} existing wiki pages.": { + "en": "Found {count} existing wiki pages.", + "bg": "Found {count} existing wiki pages.", + "de": "Found {count} existing wiki pages.", + "ru": "Found {count} existing wiki pages.", + "zh": "Found {count} existing wiki pages." + }, + "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": { + "en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", + "bg": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", + "de": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", + "ru": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", + "zh": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation." + }, + "Generated {file} with prefix '{prefix}'.": { + "en": "Generated {file} with prefix '{prefix}'.", + "bg": "Generated {file} with prefix '{prefix}'.", + "de": "Generated {file} with prefix '{prefix}'.", + "ru": "Generated {file} with prefix '{prefix}'.", + "zh": "Generated {file} with prefix '{prefix}'." + }, + "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.": { + "en": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", + "bg": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", + "de": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", + "ru": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", + "zh": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag." + }, + "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.": { + "en": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.", + "bg": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.", + "de": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.", + "ru": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.", + "zh": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment." + }, + "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.": { + "en": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.", + "bg": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.", + "de": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.", + "ru": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.", + "zh": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping." + }, + "HTTP error: {status} — {message}": { + "en": "HTTP error: {status} — {message}", + "bg": "HTTP грешка: {status} — {message}", + "de": "HTTP-Fehler: {status} — {message}", + "ru": "Ошибка HTTP: {status} — {message}", + "zh": "HTTP 错误: {status} — {message}" + }, + "HTTP {status} Forbidden — your token lacks admin rights.\nMake sure the token belongs to a repo owner or organisation admin.\nAlternatively, configure branch protection manually in Settings → Branches.": { + "en": "HTTP {status} Forbidden — your token lacks admin rights.\nMake sure the token belongs to a repo owner or organisation admin.\nAlternatively, configure branch protection manually in Settings → Branches.", + "bg": "HTTP {status} Забранено — вашият токен няма администраторски права.\nУверете се, че токенът принадлежи на собственик на хранилище или администратор на организация.\nАлтернативно, конфигурирайте защитата на клона ръчно в Настройки → Клонове.", + "de": "HTTP {status} Verboten — Ihr Token hat keine Admin-Rechte.\nStellen Sie sicher, dass das Token einem Repository-Besitzer oder Organisations-Admin gehört.\nAlternativ können Sie den Branch-Schutz manuell unter Einstellungen → Branches konfigurieren.", + "ru": "HTTP {status} Запрещено — у вашего токена нет прав администратора.\nУбедитесь, что токен принадлежит владельцу репозитория или администратору организации.\nЛибо настройте защиту ветки вручную в разделе Настройки → Ветки.", + "zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。" + }, + "Head branch is behind master. Pulling and rebasing...": { + "en": "Head branch is behind master. Pulling and rebasing...", + "bg": "Head branch is behind master. Pulling and rebasing...", + "de": "Head branch is behind master. Pulling and rebasing...", + "ru": "Head branch is behind master. Pulling and rebasing...", + "zh": "Head branch is behind master. Pulling and rebasing..." + }, + "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": { + "en": "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}", + "bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}", + "de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}", + "ru": "Инфраструктурный коммит (без ID задачи DEVX-N), пропуск обновления Vikunja: {msg}", + "zh": "基础设施提交(无 DEVX-N 任务 ID),跳过 Vikunja 更新: {msg}" + }, + "Lint failed — refusing to release. Fix lint errors first.\n{stderr}": { + "en": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}", + "bg": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}", + "de": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}", + "ru": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}", + "zh": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}" + }, + "Lint passed.": { + "en": "Lint passed.", + "bg": "Lint passed.", + "de": "Lint passed.", + "ru": "Lint passed.", + "zh": "Lint passed." + }, + "Mapped file {file} is empty. Update the content or remove from mapping.json.": { + "en": "Mapped file {file} is empty. Update the content or remove from mapping.json.", + "bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.", + "de": "Mapped file {file} is empty. Update the content or remove from mapping.json.", + "ru": "Mapped file {file} is empty. Update the content or remove from mapping.json.", + "zh": "Mapped file {file} is empty. Update the content or remove from mapping.json." + }, + "Mapped file {file} not found. Update mapping.json or create the file.": { + "en": "Mapped file {file} not found. Update mapping.json or create the file.", + "bg": "Mapped file {file} not found. Update mapping.json or create the file.", + "de": "Mapped file {file} not found. Update mapping.json or create the file.", + "ru": "Mapped file {file} not found. Update mapping.json or create the file.", + "zh": "Mapped file {file} not found. Update mapping.json or create the file." + }, + "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.": { + "en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.", + "bg": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.", + "de": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.", + "ru": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.", + "zh": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually." + }, + "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": { + "en": "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.", + "bg": "Сливането неуспешно с HTTP {status}: {message}\nПроверете дали PR е готов и имате права за сливане.", + "de": "Merge fehlgeschlagen mit HTTP {status}: {message}\nBitte prüfen Sie, ob der PR bereit ist und Sie Merge-Rechte haben.", + "ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.", + "zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。" + }, + "Module {mod} has no main() function": { + "en": "Module {mod} has no main() function", + "bg": "Модул {mod} няма функция main()", + "de": "Modul {mod} hat keine main()-Funktion", + "ru": "Модуль {mod} не имеет функции main()", + "zh": "模块 {mod} 没有 main() 函数" + }, + "Molecule directory not found: {path}": { + "en": "Molecule directory not found: {path}", + "bg": "Директорията на molecule не е намерена: {path}", + "de": "Molecule-Verzeichnis nicht gefunden: {path}", + "ru": "Директория molecule не найдена: {path}", + "zh": "未找到 molecule 目录: {path}" + }, + "Nice! Gitea release {tag} created.": { + "en": "Nice! Gitea release {tag} created.", + "bg": "Отлично! Gitea release {tag} е създаден.", + "de": "Prima! Gitea-Release {tag} erstellt.", + "ru": "Отлично! Gitea release {tag} создан.", + "zh": "不错!Gitea release {tag} 已创建。" + }, + "Nice! PR #{pr_number} squash-merged with title: {merge_title}": { + "en": "Nice! PR #{pr_number} squash-merged with title: {merge_title}", + "bg": "Отлично! PR #{pr_number} е squash-merge-нат със заглавие: {merge_title}", + "de": "Prima! PR #{pr_number} wurde mit Titel {merge_title} squash-gemergt.", + "ru": "Отлично! PR #{pr_number} squash-merge с заголовком: {merge_title}", + "zh": "不错!PR #{pr_number} 已 squash 合并,标题: {merge_title}" + }, + "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.": { + "en": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.", + "bg": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.", + "de": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.", + "ru": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.", + "zh": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered." + }, + "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.": { + "en": "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.", + "bg": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) е обновена и маркирана като готова.", + "de": "Prima! Vikunja-Aufgabe {task_id} (ID {vikunja_id}) aktualisiert und als erledigt markiert.", + "ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.", + "zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。" + }, + "No changes between {base} and {head}.": { + "en": "No changes between {base} and {head}.", + "bg": "No changes between {base} and {head}.", + "de": "No changes between {base} and {head}.", + "ru": "No changes between {base} and {head}.", + "zh": "No changes between {base} and {head}." + }, + "No staged changes — version and changelog already up to date.": { + "en": "No staged changes — version and changelog already up to date.", + "bg": "No staged changes — version and changelog already up to date.", + "de": "No staged changes — version and changelog already up to date.", + "ru": "No staged changes — version and changelog already up to date.", + "zh": "No staged changes — version and changelog already up to date." + }, + "No tags found — treating all changes as user-facing.": { + "en": "No tags found — treating all changes as user-facing.", + "bg": "No tags found — treating all changes as user-facing.", + "de": "No tags found — treating all changes as user-facing.", + "ru": "No tags found — treating all changes as user-facing.", + "zh": "No tags found — treating all changes as user-facing." + }, + "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.": { + "en": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", + "bg": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", + "de": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", + "ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", + "zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID." + }, + "No unreleased changes found. Nothing to release.": { + "en": "No unreleased changes found. Nothing to release.", + "bg": "No unreleased changes found. Nothing to release.", + "de": "No unreleased changes found. Nothing to release.", + "ru": "No unreleased changes found. Nothing to release.", + "zh": "No unreleased changes found. Nothing to release." + }, + "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.": { + "en": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.", + "bg": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.", + "de": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.", + "ru": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.", + "zh": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release." + }, + "Note: Self-approval not allowed. Posting COMMENT instead.": { + "en": "Note: Self-approval not allowed. Posting COMMENT instead.", + "bg": "Note: Self-approval not allowed. Posting COMMENT instead.", + "de": "Note: Self-approval not allowed. Posting COMMENT instead.", + "ru": "Note: Self-approval not allowed. Posting COMMENT instead.", + "zh": "Note: Self-approval not allowed. Posting COMMENT instead." + }, + "Oops! Commit message must follow conventional commit format.\n Expected: : \n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE": { + "en": "Oops! Commit message must follow conventional commit format.\n Expected: : \n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", + "bg": "Опа! Съобщението за commit трябва да следва конвенционален формат.\n Очаква се: : \n Получено: {subject}\n Разрешени типове: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", + "de": "Ups! Commit-Nachricht muss dem konventionellen Commit-Format folgen.\n Erwartet: : \n Erhalten: {subject}\n Erlaubte Typen: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", + "ru": "Ой! Сообщение коммита должно соответствовать формату conventional commit.\n Ожидается: : \n Получено: {subject}\n Допустимые типы: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", + "zh": "哎呀!提交消息必须遵循 conventional commit 格式。\n 预期格式: : \n 实际: {subject}\n 允许的类型: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE" + }, + "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": { + "en": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.", + "bg": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.", + "de": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.", + "ru": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.", + "zh": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI." + }, + "Oops! Gitea PyPI registry publish failed:\n{stderr}": { + "en": "Oops! Gitea PyPI registry publish failed:\n{stderr}", + "bg": "Опа! Публикуването в Gitea PyPI registry неуспешно:\n{stderr}", + "de": "Ups! Veröffentlichung in der Gitea PyPI-Registry fehlgeschlagen:\n{stderr}", + "ru": "Ой! Публикация в Gitea PyPI registry не удалась:\n{stderr}", + "zh": "哎呀!Gitea PyPI registry 发布失败:\n{stderr}" + }, + "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}": { + "en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}", + "bg": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}", + "de": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}", + "ru": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}", + "zh": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: : \n Got: {subject}" + }, + "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}": { + "en": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}", + "bg": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}", + "de": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}", + "ru": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}", + "zh": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}" + }, + "Oops! No task ID found in .taskid file or branch name '{branch}'.": { + "en": "Oops! No task ID found in .taskid file or branch name '{branch}'.", + "bg": "Oops! No task ID found in .taskid file or branch name '{branch}'.", + "de": "Oops! No task ID found in .taskid file or branch name '{branch}'.", + "ru": "Oops! No task ID found in .taskid file or branch name '{branch}'.", + "zh": "Oops! No task ID found in .taskid file or branch name '{branch}'." + }, + "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}": { + "en": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}", + "bg": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}", + "de": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}", + "ru": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}", + "zh": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}" + }, + "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}": { + "en": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}", + "bg": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}", + "de": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}", + "ru": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}", + "zh": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}" + }, + "Oops! Package build failed:\n{stderr}": { + "en": "Oops! Package build failed:\n{stderr}", + "bg": "Опа! Сборката на пакета неуспешна:\n{stderr}", + "de": "Ups! Paket-Build fehlgeschlagen:\n{stderr}", + "ru": "Ой! Сборка пакета не удалась:\n{stderr}", + "zh": "哎呀!包构建失败:\n{stderr}" + }, + "Oops! PyPI publish failed:\n{stderr}": { + "en": "Oops! PyPI publish failed:\n{stderr}", + "bg": "Опа! Публикуването в PyPI неуспешно:\n{stderr}", + "de": "Ups! PyPI-Veröffentlichung fehlgeschlagen:\n{stderr}", + "ru": "Ой! Публикация в PyPI не удалась:\n{stderr}", + "zh": "哎呀!PyPI 发布失败:\n{stderr}" + }, + "PASSED: {pair}": { + "en": "PASSED: {pair}", + "bg": "PASSED: {pair}", + "de": "PASSED: {pair}", + "ru": "PASSED: {pair}", + "zh": "PASSED: {pair}" + }, + "PR number must be an integer, got: {pr_number}": { + "en": "PR number must be an integer, got: {pr_number}", + "bg": "PR number must be an integer, got: {pr_number}", + "de": "PR number must be an integer, got: {pr_number}", + "ru": "PR number must be an integer, got: {pr_number}", + "zh": "PR number must be an integer, got: {pr_number}" + }, + "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": { + "en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", + "bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", + "de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", + "ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", + "zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}" + }, + "PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.": { + "en": "PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.", + "bg": "PYPI_TOKEN не е зададен и няма конфигуриран URL на registry — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.", + "de": "PYPI_TOKEN nicht gesetzt und keine Registry-URL konfiguriert — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.", + "ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.", + "zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。" + }, + "Published to Gitea PyPI registry.": { + "en": "Published to Gitea PyPI registry.", + "bg": "Публикувано в Gitea PyPI registry.", + "de": "In der Gitea PyPI-Registry veröffentlicht.", + "ru": "Опубликовано в Gitea PyPI registry.", + "zh": "已发布到 Gitea PyPI registry。" + }, + "Published to PyPI.": { + "en": "Published to PyPI.", + "bg": "Публикувано в PyPI.", + "de": "In PyPI veröffentlicht.", + "ru": "Опубликовано в PyPI.", + "zh": "已发布到 PyPI。" + }, + "Pushed release commit to master.": { + "en": "Pushed release commit to master.", + "bg": "Pushed release commit to master.", + "de": "Pushed release commit to master.", + "ru": "Pushed release commit to master.", + "zh": "Pushed release commit to master." + }, + "Rebased and pushed. Retrying merge...": { + "en": "Rebased and pushed. Retrying merge...", + "bg": "Rebased and pushed. Retrying merge...", + "de": "Rebased and pushed. Retrying merge...", + "ru": "Rebased and pushed. Retrying merge...", + "zh": "Rebased and pushed. Retrying merge..." + }, + "Release creation failed: {error}": { + "en": "Release creation failed: {error}", + "bg": "Release creation failed: {error}", + "de": "Release creation failed: {error}", + "ru": "Release creation failed: {error}", + "zh": "Release creation failed: {error}" + }, + "Release must be run on master, currently on '{branch}'.": { + "en": "Release must be run on master, currently on '{branch}'.", + "bg": "Release must be run on master, currently on '{branch}'.", + "de": "Release must be run on master, currently on '{branch}'.", + "ru": "Release must be run on master, currently on '{branch}'.", + "zh": "Release must be run on master, currently on '{branch}'." + }, + "Repo must be in 'owner/name' format, got: {repo}": { + "en": "Repo must be in 'owner/name' format, got: {repo}", + "bg": "Repo must be in 'owner/name' format, got: {repo}", + "de": "Repo must be in 'owner/name' format, got: {repo}", + "ru": "Repo must be in 'owner/name' format, got: {repo}", + "zh": "Repo must be in 'owner/name' format, got: {repo}" + }, + "Repository configuration complete.": { + "en": "Repository configuration complete.", + "bg": "Конфигурирането на хранилището е завършено.", + "de": "Repository-Konfiguration abgeschlossen.", + "ru": "Конфигурация репозитория завершена.", + "zh": "仓库配置完成。" + }, + "Runner index {index} out of range (0..{max})": { + "en": "Runner index {index} out of range (0..{max})", + "bg": "Индексът на runner {index} е извън диапазона (0..{max})", + "de": "Runner-Index {index} außerhalb des Bereichs (0..{max})", + "ru": "Индекс runner {index} вне диапазона (0..{max})", + "zh": "Runner 索引 {index} 超出范围 (0..{max})" + }, + "Running lint checks...": { + "en": "Running lint checks...", + "bg": "Running lint checks...", + "de": "Running lint checks...", + "ru": "Running lint checks...", + "zh": "Running lint checks..." + }, + "Running tests...": { + "en": "Running tests...", + "bg": "Running tests...", + "de": "Running tests...", + "ru": "Running tests...", + "zh": "Running tests..." + }, + "Running: {scenario} on {platform}": { + "en": "Running: {scenario} on {platform}", + "bg": "Running: {scenario} on {platform}", + "de": "Running: {scenario} on {platform}", + "ru": "Running: {scenario} on {platform}", + "zh": "Running: {scenario} on {platform}" + }, + "Skipping commit push — no staged changes.": { + "en": "Skipping commit push — no staged changes.", + "bg": "Skipping commit push — no staged changes.", + "de": "Skipping commit push — no staged changes.", + "ru": "Skipping commit push — no staged changes.", + "zh": "Skipping commit push — no staged changes." + }, + "Syncing {count} documentation pages to wiki...": { + "en": "Syncing {count} documentation pages to wiki...", + "bg": "Syncing {count} documentation pages to wiki...", + "de": "Syncing {count} documentation pages to wiki...", + "ru": "Syncing {count} documentation pages to wiki...", + "zh": "Syncing {count} documentation pages to wiki..." + }, + "Tag consistency check failed.": { + "en": "Tag consistency check failed.", + "bg": "Tag consistency check failed.", + "de": "Tag consistency check failed.", + "ru": "Tag consistency check failed.", + "zh": "Tag consistency check failed." + }, + "Tag v{version} already existed. Publish workflow should already have been triggered.": { + "en": "Tag v{version} already existed. Publish workflow should already have been triggered.", + "bg": "Tag v{version} already existed. Publish workflow should already have been triggered.", + "de": "Tag v{version} already existed. Publish workflow should already have been triggered.", + "ru": "Tag v{version} already existed. Publish workflow should already have been triggered.", + "zh": "Tag v{version} already existed. Publish workflow should already have been triggered." + }, + "Tag {tag} already exists and points to HEAD. Skipping creation.": { + "en": "Tag {tag} already exists and points to HEAD. Skipping creation.", + "bg": "Tag {tag} already exists and points to HEAD. Skipping creation.", + "de": "Tag {tag} already exists and points to HEAD. Skipping creation.", + "ru": "Tag {tag} already exists and points to HEAD. Skipping creation.", + "zh": "Tag {tag} already exists and points to HEAD. Skipping creation." + }, + "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.": { + "en": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.", + "bg": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.", + "de": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.", + "ru": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.", + "zh": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details." + }, + "Task ID: {task_id}": { + "en": "Task ID: {task_id}", + "bg": "Task ID: {task_id}", + "de": "Task ID: {task_id}", + "ru": "Task ID: {task_id}", + "zh": "Task ID: {task_id}" + }, + "Tests failed — refusing to release. Fix test failures first.\n{stderr}": { + "en": "Tests failed — refusing to release. Fix test failures first.\n{stderr}", + "bg": "Tests failed — refusing to release. Fix test failures first.\n{stderr}", + "de": "Tests failed — refusing to release. Fix test failures first.\n{stderr}", + "ru": "Tests failed — refusing to release. Fix test failures first.\n{stderr}", + "zh": "Tests failed — refusing to release. Fix test failures first.\n{stderr}" + }, + "Tests passed.": { + "en": "Tests passed.", + "bg": "Tests passed.", + "de": "Tests passed.", + "ru": "Tests passed.", + "zh": "Tests passed." + }, + "Unit tests passed in {duration:.2f}s (under {max}s limit).": { + "en": "Unit tests passed in {duration:.2f}s (under {max}s limit).", + "bg": "Unit tests passed in {duration:.2f}s (under {max}s limit).", + "de": "Unit tests passed in {duration:.2f}s (under {max}s limit).", + "ru": "Unit tests passed in {duration:.2f}s (under {max}s limit).", + "zh": "Unit tests passed in {duration:.2f}s (under {max}s limit)." + }, + "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.": { + "en": "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.", + "bg": "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.", + "de": "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.", + "ru": "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.", + "zh": "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." + }, + "Unknown check category '{check}'. Available: all, user-facing{tags}": { + "en": "Unknown check category '{check}'. Available: all, user-facing{tags}", + "bg": "Unknown check category '{check}'. Available: all, user-facing{tags}", + "de": "Unknown check category '{check}'. Available: all, user-facing{tags}", + "ru": "Unknown check category '{check}'. Available: all, user-facing{tags}", + "zh": "Unknown check category '{check}'. Available: all, user-facing{tags}" + }, + "Updated version in {init}": { + "en": "Updated version in {init}", + "bg": "Updated version in {init}", + "de": "Updated version in {init}", + "ru": "Updated version in {init}", + "zh": "Updated version in {init}" + }, + "Updated {changelog_file}": { + "en": "Updated {changelog_file}", + "bg": "Updated {changelog_file}", + "de": "Updated {changelog_file}", + "ru": "Updated {changelog_file}", + "zh": "Updated {changelog_file}" + }, + "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.": { + "en": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", + "bg": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", + "de": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", + "ru": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", + "zh": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles." + }, + "Version file: {file}": { + "en": "Version file: {file}", + "bg": "Version file: {file}", + "de": "Version file: {file}", + "ru": "Version file: {file}", + "zh": "Version file: {file}" + }, + "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.": { + "en": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", + "bg": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", + "de": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", + "ru": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", + "zh": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update." + }, + "WARNING: --skip-tests passed — skipping test verification.": { + "en": "WARNING: --skip-tests passed — skipping test verification.", + "bg": "WARNING: --skip-tests passed — skipping test verification.", + "de": "WARNING: --skip-tests passed — skipping test verification.", + "ru": "WARNING: --skip-tests passed — skipping test verification.", + "zh": "WARNING: --skip-tests passed — skipping test verification." + }, + "Warning: could not fetch tags from origin.": { + "en": "Warning: could not fetch tags from origin.", + "bg": "Warning: could not fetch tags from origin.", + "de": "Warning: could not fetch tags from origin.", + "ru": "Warning: could not fetch tags from origin.", + "zh": "Warning: could not fetch tags from origin." + }, + "Wiki integrity check failed — {count} issue(s)": { + "en": "Wiki integrity check failed — {count} issue(s)", + "bg": "Wiki integrity check failed — {count} issue(s)", + "de": "Wiki integrity check failed — {count} issue(s)", + "ru": "Wiki integrity check failed — {count} issue(s)", + "zh": "Wiki integrity check failed — {count} issue(s)" + }, + "Wiki verification failed — {failures} page(s) empty or mismatched": { + "en": "Wiki verification failed — {failures} page(s) empty or mismatched", + "bg": "Wiki verification failed — {failures} page(s) empty or mismatched", + "de": "Wiki verification failed — {failures} page(s) empty or mismatched", + "ru": "Wiki verification failed — {failures} page(s) empty or mismatched", + "zh": "Wiki verification failed — {failures} page(s) empty or mismatched" + }, + "[dry-run] Would commit: release: v{version}": { + "en": "[dry-run] Would commit: release: v{version}", + "bg": "[dry-run] Would commit: release: v{version}", + "de": "[dry-run] Would commit: release: v{version}", + "ru": "[dry-run] Would commit: release: v{version}", + "zh": "[dry-run] Would commit: release: v{version}" + }, + "[dry-run] Would create tag: v{version}": { + "en": "[dry-run] Would create tag: v{version}", + "bg": "[dry-run] Would create tag: v{version}", + "de": "[dry-run] Would create tag: v{version}", + "ru": "[dry-run] Would create tag: v{version}", + "zh": "[dry-run] Would create tag: v{version}" + }, + "[dry-run] Would create tag: {tag}": { + "en": "[dry-run] Would create tag: {tag}", + "bg": "[dry-run] Would create tag: {tag}", + "de": "[dry-run] Would create tag: {tag}", + "ru": "[dry-run] Would create tag: {tag}", + "zh": "[dry-run] Would create tag: {tag}" + }, + "[dry-run] Would push commit to master": { + "en": "[dry-run] Would push commit to master", + "bg": "[dry-run] Would push commit to master", + "de": "[dry-run] Would push commit to master", + "ru": "[dry-run] Would push commit to master", + "zh": "[dry-run] Would push commit to master" + }, + "[dry-run] Would sync page: {title} ({chars} chars)": { + "en": "[dry-run] Would sync page: {title} ({chars} chars)", + "bg": "[dry-run] Would sync page: {title} ({chars} chars)", + "de": "[dry-run] Would sync page: {title} ({chars} chars)", + "ru": "[dry-run] Would sync page: {title} ({chars} chars)", + "zh": "[dry-run] Would sync page: {title} ({chars} chars)" + }, + "[dry-run] Would update {changelog_file}": { + "en": "[dry-run] Would update {changelog_file}", + "bg": "[dry-run] Would update {changelog_file}", + "de": "[dry-run] Would update {changelog_file}", + "ru": "[dry-run] Would update {changelog_file}", + "zh": "[dry-run] Would update {changelog_file}" + }, + "[dry-run] Would update {init}": { + "en": "[dry-run] Would update {init}", + "bg": "[dry-run] Would update {init}", + "de": "[dry-run] Would update {init}", + "ru": "[dry-run] Would update {init}", + "zh": "[dry-run] Would update {init}" + }, + "active": { + "en": "active", + "bg": "активен", + "de": "aktiv", + "ru": "активен", + "zh": "活跃" + }, + "completed": { + "en": "completed", + "bg": "завършен", + "de": "abgeschlossen", + "ru": "завершён", + "zh": "已完成" + }, + "failed": { + "en": "failed", + "bg": "неуспешен", + "de": "fehlgeschlagen", + "ru": "неудачный", + "zh": "失败" + }, + "git command failed ({cmd}): {stderr}": { + "en": "git command failed ({cmd}): {stderr}", + "bg": "git command failed ({cmd}): {stderr}", + "de": "git command failed ({cmd}): {stderr}", + "ru": "git command failed ({cmd}): {stderr}", + "zh": "git command failed ({cmd}): {stderr}" + }, + "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.": { + "en": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", + "bg": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", + "de": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", + "ru": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", + "zh": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history." + }, + "git-cliff returned empty version.": { + "en": "git-cliff returned empty version.", + "bg": "git-cliff returned empty version.", + "de": "git-cliff returned empty version.", + "ru": "git-cliff returned empty version.", + "zh": "git-cliff returned empty version." + }, + "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).": { + "en": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", + "bg": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", + "de": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", + "ru": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", + "zh": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1)." + }, + "in_progress": { + "en": "in progress", + "bg": "в процес", + "de": "in Bearbeitung", + "ru": "в процессе", + "zh": "进行中" + }, + "inactive": { + "en": "inactive", + "bg": "неактивен", + "de": "inaktiv", + "ru": "неактивен", + "zh": "未激活" + }, + "mapping.json keys and values must be strings, got {k}={v}": { + "en": "mapping.json keys and values must be strings, got {k}={v}", + "bg": "mapping.json keys and values must be strings, got {k}={v}", + "de": "mapping.json keys and values must be strings, got {k}={v}", + "ru": "mapping.json keys and values must be strings, got {k}={v}", + "zh": "mapping.json keys and values must be strings, got {k}={v}" + }, + "mapping.json must be a dict of file-path -> page-title, got {type}": { + "en": "mapping.json must be a dict of file-path -> page-title, got {type}", + "bg": "mapping.json must be a dict of file-path -> page-title, got {type}", + "de": "mapping.json must be a dict of file-path -> page-title, got {type}", + "ru": "mapping.json must be a dict of file-path -> page-title, got {type}", + "zh": "mapping.json must be a dict of file-path -> page-title, got {type}" + }, + "pending": { + "en": "pending", + "bg": "в очакване", + "de": "ausstehend", + "ru": "ожидает", + "zh": "待处理" + }, + "unknown": { + "en": "unknown", + "bg": "неизвестен", + "de": "unbekannt", + "ru": "неизвестно", + "zh": "未知" + }, + "{file} already exists. Use --force to overwrite.": { + "en": "{file} already exists. Use --force to overwrite.", + "bg": "{file} already exists. Use --force to overwrite.", + "de": "{file} already exists. Use --force to overwrite.", + "ru": "{file} already exists. Use --force to overwrite.", + "zh": "{file} already exists. Use --force to overwrite." + }, + "--skip-build: skipping package build and PyPI publish.": { + "en": "--skip-build: skipping package build and PyPI publish.", + "bg": "--skip-build: skipping package build and PyPI publish.", + "de": "--skip-build: skipping package build and PyPI publish.", + "ru": "--skip-build: skipping package build and PyPI publish.", + "zh": "--skip-build: skipping package build and PyPI publish." + }, + "Integration tests cancelled — another runner failed.": { + "en": "Integration tests cancelled — another runner failed.", + "bg": "Integration tests cancelled — another runner failed.", + "de": "Integration tests cancelled — another runner failed.", + "ru": "Integration tests cancelled — another runner failed.", + "zh": "Integration tests cancelled — another runner failed." + }, + "Integration tests failed with exit code {code}": { + "en": "Integration tests failed with exit code {code}", + "bg": "Integration tests failed with exit code {code}", + "de": "Integration tests failed with exit code {code}", + "ru": "Integration tests failed with exit code {code}", + "zh": "Integration tests failed with exit code {code}" + }, + "Integration tests passed.": { + "en": "Integration tests passed.", + "bg": "Integration tests passed.", + "de": "Integration tests passed.", + "ru": "Integration tests passed.", + "zh": "Integration tests passed." + }, + "Merged {count} reports: {tests} tests, {failures} failures → {output}": { + "en": "Merged {count} reports: {tests} tests, {failures} failures → {output}", + "bg": "Merged {count} reports: {tests} tests, {failures} failures → {output}", + "de": "Merged {count} reports: {tests} tests, {failures} failures → {output}", + "ru": "Merged {count} reports: {tests} tests, {failures} failures → {output}", + "zh": "Merged {count} reports: {tests} tests, {failures} failures → {output}" + }, + "No JUnit reports found matching {pattern} — skipping merge.": { + "en": "No JUnit reports found matching {pattern} — skipping merge.", + "bg": "No JUnit reports found matching {pattern} — skipping merge.", + "de": "No JUnit reports found matching {pattern} — skipping merge.", + "ru": "No JUnit reports found matching {pattern} — skipping merge.", + "zh": "No JUnit reports found matching {pattern} — skipping merge." + }, + "Roles directory not found: {path}": { + "en": "Roles directory not found: {path}", + "bg": "Roles directory not found: {path}", + "de": "Roles directory not found: {path}", + "ru": "Roles directory not found: {path}", + "zh": "Roles directory not found: {path}" + } } diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 9dd9320..0614d49 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -225,6 +225,29 @@ class TestMoleculeCommands: mock_run.assert_called_once_with("devx.molecule.molecule_all", []) +class TestNewCiCommands: + @patch("devx.cli._run_module") + def test_ci_distribute_files(self, mock_run: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke(cli, ["ci", "distribute-files", "--", "--pattern", "*.py"]) + assert result.exit_code == 0 + mock_run.assert_called_once_with("devx.ci.distribute_files", ["--pattern", "*.py"]) + + @patch("devx.cli._run_module") + def test_ci_merge_junit(self, mock_run: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke(cli, ["ci", "merge-junit", "--", "--output", "merged.xml"]) + assert result.exit_code == 0 + mock_run.assert_called_once_with("devx.ci.merge_junit", ["--output", "merged.xml"]) + + @patch("devx.cli._run_module") + def test_ci_integration_guard(self, mock_run: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke(cli, ["ci", "integration-guard", "--", "-v"]) + assert result.exit_code == 0 + mock_run.assert_called_once_with("devx.ci.integration_guard", ["-v"]) + + class TestRunModule: @patch("importlib.import_module") def test_run_module_success(self, mock_import: MagicMock) -> None: diff --git a/tests/unit/test_distribute_files.py b/tests/unit/test_distribute_files.py new file mode 100644 index 0000000..435c12a --- /dev/null +++ b/tests/unit/test_distribute_files.py @@ -0,0 +1,154 @@ +"""Unit tests for devx.ci.distribute_files.""" + +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from devx.ci.distribute_files import ( + DEFAULT_MAX_RUNNERS, + discover_files, + distribute, + files_for_runner, + main, +) + + +class TestDiscoverFiles: + def test_discovers_sorted(self, tmp_path: Path) -> None: + (tmp_path / "test_b.py").write_text("") + (tmp_path / "test_a.py").write_text("") + result = discover_files(str(tmp_path / "test_*.py")) + assert len(result) == 2 + assert result[0].endswith("test_a.py") + assert result[1].endswith("test_b.py") + + def test_no_matches(self, tmp_path: Path) -> None: + assert discover_files(str(tmp_path / "nonexistent-*.py")) == [] + + +class TestDistribute: + def test_even_split(self) -> None: + files = [f"test_{i}.py" for i in range(6)] + groups = distribute(files, 3) + assert len(groups) == 3 + assert all(len(g) == 2 for g in groups) + + def test_uneven_split(self) -> None: + files = [f"test_{i}.py" for i in range(5)] + groups = distribute(files, 3) + assert len(groups[0]) == 2 + assert len(groups[1]) == 2 + assert len(groups[2]) == 1 + + def test_more_runners_than_files(self) -> None: + files = ["test_a.py"] + groups = distribute(files, 5) + assert len(groups) == 5 + assert len(groups[0]) == 1 + assert all(len(g) == 0 for g in groups[1:]) + + def test_empty(self) -> None: + assert distribute([], 3) == [[], [], []] + + +class TestFilesForRunner: + def test_returns_correct_subset(self) -> None: + files = [f"test_{i}.py" for i in range(6)] + assert len(files_for_runner(files, 0, 3)) == 2 + assert len(files_for_runner(files, 1, 3)) == 2 + assert len(files_for_runner(files, 2, 3)) == 2 + + def test_out_of_range_raises(self) -> None: + with pytest.raises(Exception, match="out of range"): + files_for_runner(["a.py"], 5, 3) + + +class TestCli: + def test_no_runner_index_prints_groups(self, tmp_path: Path) -> None: + for i in range(3): + (tmp_path / f"test_{i}.py").write_text("") + runner = CliRunner() + result = runner.invoke(main, ["--pattern", str(tmp_path / "test_*.py"), "--max-runners", "3"]) + assert result.exit_code == 0 + assert "Runner 0:" in result.output + assert "Runner 1:" in result.output + assert "Runner 2:" in result.output + + def test_runner_index_prints_assigned(self, tmp_path: Path) -> None: + for i in range(3): + (tmp_path / f"test_{i}.py").write_text("") + runner = CliRunner() + result = runner.invoke( + main, + ["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "1", "--max-runners", "3"], + ) + assert result.exit_code == 0 + assert "test_0.py" in result.output + + def test_github_env_writes_files(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + gh_file = tmp_path / "env.txt" + monkeypatch.setenv("GITHUB_ENV", str(gh_file)) + for i in range(2): + (tmp_path / f"test_{i}.py").write_text("") + runner = CliRunner() + result = runner.invoke( + main, + ["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "1", "--max-runners", "2", "--github-env"], + ) + assert result.exit_code == 0 + content = gh_file.read_text() + assert "ASSIGNED_FILES=" in content + assert "SKIP=false" in content + + def test_skip_if_excess(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + gh_file = tmp_path / "env.txt" + monkeypatch.setenv("GITHUB_ENV", str(gh_file)) + (tmp_path / "test.py").write_text("") + runner = CliRunner() + result = runner.invoke( + main, + [ + "--pattern", + str(tmp_path / "test_*.py"), + "--runner-index", + "5", + "--max-runners", + "2", + "--github-env", + "--skip-if-excess", + ], + ) + assert result.exit_code == 0 + content = gh_file.read_text() + assert "ASSIGNED_FILES=\n" in content + assert "SKIP=true" in content + + def test_runner_index_zero_raises(self, tmp_path: Path) -> None: + (tmp_path / "test.py").write_text("") + runner = CliRunner() + result = runner.invoke( + main, + ["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "0", "--max-runners", "3"], + ) + assert result.exit_code != 0 + + def test_no_env_var_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("GITHUB_ENV", raising=False) + (tmp_path / "test.py").write_text("") + runner = CliRunner() + result = runner.invoke( + main, + ["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "1", "--max-runners", "3", "--github-env"], + ) + assert result.exit_code != 0 + + +def test_default_max_runners() -> None: + assert DEFAULT_MAX_RUNNERS == 3 + + +def test_main_module_block() -> None: + import devx.ci.distribute_files as mod + + assert hasattr(mod, "main") diff --git a/tests/unit/test_distribute_molecule.py b/tests/unit/test_distribute_molecule.py index b63614a..ddac579 100644 --- a/tests/unit/test_distribute_molecule.py +++ b/tests/unit/test_distribute_molecule.py @@ -8,13 +8,19 @@ import pytest from click.testing import CliRunner from devx.molecule.distribute_molecule import ( + DEFAULT_ROLES_ROOT, MOLECULE_ROOT, PLATFORMS, + MultiRoleTestPair, TestPair, + build_multi_role_pairs, build_pairs, cli, + discover_multi_role_scenarios, discover_scenarios, distribute, + distribute_multi_role, + multi_role_pairs_for_runner, pairs_for_runner, ) @@ -259,3 +265,197 @@ def test_main_module_block() -> None: namespace = dict(dm.__dict__) exec(compile(source, dm.__file__, "exec"), namespace) assert callable(namespace["cli"]) + + +class TestDiscoverMultiRole: + def test_discovers_role_scenario_pairs(self, tmp_path: Path) -> None: + roles = tmp_path / "roles" + for scenario in ["default", "binary"]: + (roles / "gitea-runner" / "molecule" / scenario).mkdir(parents=True) + (roles / "gitea-runner" / "molecule" / "common").mkdir(parents=True) + (roles / "gitea-runner" / "molecule" / "_shared").mkdir(parents=True) + (roles / "docker-base" / "molecule" / "default").mkdir(parents=True) + (roles / "no-molecule").mkdir(parents=True) + result = discover_multi_role_scenarios(roles) + assert ("docker-base", "default") in result + assert ("gitea-runner", "default") in result + assert ("gitea-runner", "binary") in result + assert ("gitea-runner", "common") not in result + assert ("gitea-runner", "_shared") not in result + assert len(result) == 3 + + def test_raises_when_dir_missing(self, tmp_path: Path) -> None: + with pytest.raises(click.ClickException) as exc: + discover_multi_role_scenarios(tmp_path / "nonexistent") + assert "not found" in str(exc.value) + + def test_default_roles_root_raises_when_missing(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Calling with no args uses DEFAULT_ROLES_ROOT which doesn't exist in tests.""" + with pytest.raises(click.ClickException): + discover_multi_role_scenarios() + + def test_default_roles_root_constant(self) -> None: + assert Path("ansible/roles") == DEFAULT_ROLES_ROOT + + +class TestMultiRoleTestPair: + def test_encode_roundtrip(self) -> None: + pair = MultiRoleTestPair( + "docker-base", "default", {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": ""} + ) + encoded = pair.encode() + assert encoded == "docker-base|default|ubuntu-2204|ubuntu:22.04|" + decoded = MultiRoleTestPair.decode(encoded) + assert decoded.role == "docker-base" + assert decoded.scenario == "default" + assert decoded.platform["name"] == "ubuntu-2204" + + +class TestBuildMultiRolePairs: + def test_cross_product(self) -> None: + role_scenarios = [("role-a", "default"), ("role-b", "binary")] + platforms = [{"name": "p1", "image": "i1", "command": ""}] + pairs = build_multi_role_pairs(role_scenarios, platforms) + assert len(pairs) == 2 + assert pairs[0].role == "role-a" + assert pairs[1].role == "role-b" + + def test_default_platforms(self) -> None: + pairs = build_multi_role_pairs([("r", "s")]) + assert len(pairs) == len(PLATFORMS) + + +class TestDistributeMultiRole: + def test_even_split(self) -> None: + pairs = [MultiRoleTestPair(f"r{i}", "s", {"name": "p", "image": "i", "command": ""}) for i in range(6)] + groups = distribute_multi_role(pairs, 3) + assert all(len(g) == 2 for g in groups) + + def test_out_of_range_raises(self) -> None: + pairs = [MultiRoleTestPair("r", "s", {"name": "p", "image": "i", "command": ""})] + with pytest.raises(click.ClickException): + multi_role_pairs_for_runner(pairs, 5, 3) + + +class TestCliMultiRole: + def test_roles_root_list(self, tmp_path: Path) -> None: + roles = tmp_path / "roles" + (roles / "role-a" / "molecule" / "default").mkdir(parents=True) + (roles / "role-b" / "molecule" / "binary").mkdir(parents=True) + runner = CliRunner() + result = runner.invoke(cli, ["--roles-root", str(roles), "--list"]) + assert result.exit_code == 0 + assert "role-a|default" in result.output + assert "role-b|binary" in result.output + + def test_roles_root_runner_index(self, tmp_path: Path) -> None: + roles = tmp_path / "roles" + (roles / "role-a" / "molecule" / "default").mkdir(parents=True) + runner = CliRunner() + result = runner.invoke(cli, ["--roles-root", str(roles), "--runner-index", "1", "--max-runners", "3"]) + assert result.exit_code == 0 + assert "role-a|default|" in result.output + + def test_roles_root_github_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + gh_file = tmp_path / "env.txt" + monkeypatch.setenv("GITHUB_ENV", str(gh_file)) + roles = tmp_path / "roles" + (roles / "role-a" / "molecule" / "default").mkdir(parents=True) + runner = CliRunner() + result = runner.invoke( + cli, + ["--roles-root", str(roles), "--runner-index", "1", "--max-runners", "3", "--github-env"], + ) + assert result.exit_code == 0 + content = gh_file.read_text() + assert "TEST_PAIRS=" in content + assert "SKIP=false" in content + + def test_roles_root_skip_if_excess(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + gh_file = tmp_path / "env.txt" + monkeypatch.setenv("GITHUB_ENV", str(gh_file)) + roles = tmp_path / "roles" + (roles / "role-a" / "molecule" / "default").mkdir(parents=True) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--roles-root", + str(roles), + "--runner-index", + "5", + "--max-runners", + "2", + "--github-env", + "--skip-if-excess", + ], + ) + assert result.exit_code == 0 + content = gh_file.read_text() + assert "SKIP=true" in content + + def test_molecule_root_option(self, tmp_path: Path) -> None: + root = tmp_path / "custom-molecule" + (root / "alpha").mkdir(parents=True) + runner = CliRunner() + result = runner.invoke(cli, ["--molecule-root", str(root), "--list"]) + assert result.exit_code == 0 + assert "alpha" in result.output + + def test_roles_root_list_platforms(self, tmp_path: Path) -> None: + """--roles-root --list-platforms prints platforms.""" + roles = tmp_path / "roles" + (roles / "role-a" / "molecule" / "default").mkdir(parents=True) + runner = CliRunner() + result = runner.invoke(cli, ["--roles-root", str(roles), "--list-platforms"]) + assert result.exit_code == 0 + assert "ubuntu-2204" in result.output + + def test_roles_root_no_runner_index_prints_groups(self, tmp_path: Path) -> None: + """--roles-root without --runner-index prints all groups.""" + roles = tmp_path / "roles" + (roles / "role-a" / "molecule" / "default").mkdir(parents=True) + (roles / "role-b" / "molecule" / "binary").mkdir(parents=True) + runner = CliRunner() + result = runner.invoke(cli, ["--roles-root", str(roles), "--max-runners", "2"]) + assert result.exit_code == 0 + assert "Runner 0:" in result.output + assert "Runner 1:" in result.output + + def test_roles_root_skips_non_dir_role(self, tmp_path: Path) -> None: + """Non-directory entries in roles root are skipped.""" + roles = tmp_path / "roles" + roles.mkdir(parents=True) + (roles / "README.md").write_text("not a role") + (roles / "role-a" / "molecule" / "default").mkdir(parents=True) + result = discover_multi_role_scenarios(roles) + assert ("role-a", "default") in result + assert len(result) == 1 + + def test_roles_root_skips_non_dir_scenario(self, tmp_path: Path) -> None: + """Non-directory entries in molecule dir are skipped.""" + roles = tmp_path / "roles" + (roles / "role-a" / "molecule").mkdir(parents=True) + (roles / "role-a" / "molecule" / "default").mkdir(parents=True) + (roles / "role-a" / "molecule" / "file.txt").write_text("not a scenario") + result = discover_multi_role_scenarios(roles) + assert ("role-a", "default") in result + assert len(result) == 1 + + def test_roles_root_skips_role_without_molecule(self, tmp_path: Path) -> None: + """Roles without a molecule/ directory are skipped.""" + roles = tmp_path / "roles" + (roles / "role-a" / "molecule" / "default").mkdir(parents=True) + (roles / "no-molecule").mkdir(parents=True) + result = discover_multi_role_scenarios(roles) + assert ("role-a", "default") in result + assert len(result) == 1 + + def test_roles_root_runner_index_zero_raises(self, tmp_path: Path) -> None: + """--roles-root --runner-index 0 should raise.""" + roles = tmp_path / "roles" + (roles / "role-a" / "molecule" / "default").mkdir(parents=True) + runner = CliRunner() + result = runner.invoke(cli, ["--roles-root", str(roles), "--runner-index", "0", "--max-runners", "3"]) + assert result.exit_code != 0 + assert "out of range" in result.output diff --git a/tests/unit/test_integration_guard.py b/tests/unit/test_integration_guard.py new file mode 100644 index 0000000..070085b --- /dev/null +++ b/tests/unit/test_integration_guard.py @@ -0,0 +1,308 @@ +"""Unit tests for devx.ci.integration_guard.""" + +from __future__ import annotations + +import os +import subprocess # nosec B404 +import time +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from devx.ci.integration_guard import cli + + +class TestCli: + def test_all_pass(self) -> None: + with ( + patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen, + patch("time.sleep"), + ): + proc = MagicMock() + proc.poll.return_value = 0 + proc.returncode = 0 + mock_popen.return_value = proc + + runner = CliRunner() + result = runner.invoke(cli, ["--", "tests/integration/test_foo.py"]) + assert result.exit_code == 0 + assert "Integration tests passed" in result.output + + def test_failure_exits_nonzero(self) -> None: + with ( + patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen, + patch("time.sleep"), + ): + proc = MagicMock() + proc.poll.return_value = 1 + proc.returncode = 1 + mock_popen.return_value = proc + + runner = CliRunner() + result = runner.invoke(cli, ["--", "tests/integration/test_foo.py"]) + assert result.exit_code == 1 + assert "failed" in result.output + + def test_junit_output_passed_to_pytest(self) -> None: + with ( + patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen, + patch("time.sleep"), + ): + proc = MagicMock() + proc.poll.return_value = 0 + proc.returncode = 0 + mock_popen.return_value = proc + + runner = CliRunner() + result = runner.invoke( + cli, + ["--junit-output", "junit-results/runner-1.xml", "--", "test_foo.py"], + ) + assert result.exit_code == 0 + call_args = mock_popen.call_args[0][0] + assert "--junitxml" in call_args + assert "junit-results/runner-1.xml" in call_args + + def test_pytest_args_passed_through(self) -> None: + with ( + patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen, + patch("time.sleep"), + ): + proc = MagicMock() + proc.poll.return_value = 0 + proc.returncode = 0 + mock_popen.return_value = proc + + runner = CliRunner() + result = runner.invoke( + cli, + ["--", "-x", "-v", "--tb=short", "test_a.py", "test_b.py"], + ) + assert result.exit_code == 0 + call_args = mock_popen.call_args[0][0] + assert "-x" in call_args + assert "-v" in call_args + assert "test_a.py" in call_args + assert "test_b.py" in call_args + + def test_keyboard_interrupt_kills_process(self) -> None: + with ( + patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen, + patch("time.sleep", side_effect=KeyboardInterrupt), + patch("os.killpg") as mock_killpg, + patch("os.getpgid") as mock_getpgid, + ): + mock_getpgid.return_value = 123 + proc = MagicMock() + proc.poll.return_value = None + proc.wait.return_value = 0 + mock_popen.return_value = proc + + runner = CliRunner() + result = runner.invoke(cli, ["--", "test_foo.py"]) + assert result.exit_code == 1 + mock_killpg.assert_called() + + def test_exits_when_other_runner_fails(self) -> None: + real_sleep = time.sleep + call_count = [0] + + def get_jobs_side_effect(*args, **kwargs): + call_count[0] += 1 + if call_count[0] < 2: + return [{"name": "integration-tests (1)", "conclusion": "running"}] + return [ + {"name": "integration-tests (0)", "conclusion": "running"}, + {"name": "integration-tests (1)", "conclusion": "failure"}, + ] + + with ( + patch.dict( + os.environ, + { + "GITEA_URL": "https://gitea.example", + "REPO_TOKEN": "token", + "RUN_ID": "123", + "JOB_NAME": "integration-tests", + "MATRIX_INDEX": "0", + "GITEA_REPOSITORY": "oblachno-oss/infra", + "PATH": os.environ.get("PATH", ""), + }, + clear=True, + ), + patch("devx.ci.integration_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("os.killpg") as mock_killpg, + patch("os.getpgid") as mock_getpgid, + patch("time.sleep", side_effect=lambda x: real_sleep(0.1)), + ): + mock_getpgid.return_value = 123 + proc = MagicMock() + proc.poll.return_value = None + proc.wait.return_value = 0 + mock_popen.return_value = proc + + runner = CliRunner() + result = runner.invoke(cli, ["--", "test_foo.py"]) + assert result.exit_code == 1 + mock_killpg.assert_called() + assert "cancelled" in result.output.lower() + + def test_process_lookup_error_suppressed(self) -> None: + real_sleep = time.sleep + call_count = [0] + + def get_jobs_side_effect(*args, **kwargs): + call_count[0] += 1 + if call_count[0] < 2: + return [{"name": "integration-tests (1)", "conclusion": "running"}] + return [ + {"name": "integration-tests (0)", "conclusion": "running"}, + {"name": "integration-tests (1)", "conclusion": "failure"}, + ] + + with ( + patch.dict( + os.environ, + { + "GITEA_URL": "https://gitea.example", + "REPO_TOKEN": "token", + "RUN_ID": "123", + "JOB_NAME": "integration-tests", + "MATRIX_INDEX": "0", + "GITEA_REPOSITORY": "oblachno-oss/infra", + "PATH": os.environ.get("PATH", ""), + }, + clear=True, + ), + patch("devx.ci.integration_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("os.killpg", side_effect=ProcessLookupError("no such process")), + patch("os.getpgid") as mock_getpgid, + patch("time.sleep", side_effect=lambda x: real_sleep(0.1)), + ): + mock_getpgid.return_value = 123 + proc = MagicMock() + proc.poll.return_value = None + proc.wait.return_value = 0 + mock_popen.return_value = proc + + runner = CliRunner() + result = runner.invoke(cli, ["--", "test_foo.py"]) + assert result.exit_code == 1 + + def test_timeout_expired_kills_with_sigkill(self) -> None: + real_sleep = time.sleep + call_count = [0] + + def get_jobs_side_effect(*args, **kwargs): + call_count[0] += 1 + if call_count[0] < 2: + return [{"name": "integration-tests (1)", "conclusion": "running"}] + return [ + {"name": "integration-tests (0)", "conclusion": "running"}, + {"name": "integration-tests (1)", "conclusion": "failure"}, + ] + + with ( + patch.dict( + os.environ, + { + "GITEA_URL": "https://gitea.example", + "REPO_TOKEN": "token", + "RUN_ID": "123", + "JOB_NAME": "integration-tests", + "MATRIX_INDEX": "0", + "GITEA_REPOSITORY": "oblachno-oss/infra", + "PATH": os.environ.get("PATH", ""), + }, + clear=True, + ), + patch("devx.ci.integration_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("os.killpg") as mock_killpg, + patch("os.getpgid") as mock_getpgid, + patch("time.sleep", side_effect=lambda x: real_sleep(0.1)), + ): + mock_getpgid.return_value = 123 + proc = MagicMock() + proc.poll.return_value = None + proc.wait.side_effect = [subprocess.TimeoutExpired("cmd", 10)] + mock_popen.return_value = proc + + runner = CliRunner() + result = runner.invoke(cli, ["--", "test_foo.py"]) + assert result.exit_code == 1 + # SIGKILL should have been called (second killpg call) + assert mock_killpg.call_count >= 2 + + def test_no_env_vars_runs_without_polling(self) -> None: + with ( + patch.dict(os.environ, {"PATH": os.environ.get("PATH", "")}, clear=True), + patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen, + patch("time.sleep"), + ): + proc = MagicMock() + proc.poll.return_value = 0 + proc.returncode = 0 + mock_popen.return_value = proc + + runner = CliRunner() + result = runner.invoke(cli, ["--", "test_foo.py"]) + assert result.exit_code == 0 + assert "without cross-runner cancellation" in result.output + + def test_partial_env_vars_runs_without_polling(self) -> None: + """Only GITEA_URL set (missing REPO_TOKEN and RUN_ID) — should skip polling.""" + with ( + patch.dict( + os.environ, + {"GITEA_URL": "https://gitea.example", "PATH": os.environ.get("PATH", "")}, + clear=True, + ), + patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen, + patch("time.sleep"), + ): + proc = MagicMock() + proc.poll.return_value = 0 + proc.returncode = 0 + mock_popen.return_value = proc + + runner = CliRunner() + result = runner.invoke(cli, ["--", "test_foo.py"]) + assert result.exit_code == 0 + assert "without cross-runner cancellation" in result.output + + def test_invalid_repository_falls_back_to_default(self) -> None: + """GITEA_REPOSITORY without '/' falls back to oblachno-oss/devx.""" + with ( + patch.dict( + os.environ, + {"GITEA_REPOSITORY": "invalid", "PATH": os.environ.get("PATH", "")}, + clear=True, + ), + patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen, + patch("time.sleep"), + ): + proc = MagicMock() + proc.poll.return_value = 0 + proc.returncode = 0 + mock_popen.return_value = proc + + runner = CliRunner() + result = runner.invoke(cli, ["--", "test_foo.py"]) + assert result.exit_code == 0 + + +def test_main_module_block() -> None: + import devx.ci.integration_guard as ig + + with open(ig.__file__) as f: + source = f.read() + source = source.replace('if __name__ == "__main__":\n cli()\n', "") + namespace = dict(ig.__dict__) + exec(compile(source, ig.__file__, "exec"), namespace) + assert callable(namespace["cli"]) diff --git a/tests/unit/test_merge_junit.py b/tests/unit/test_merge_junit.py new file mode 100644 index 0000000..e146285 --- /dev/null +++ b/tests/unit/test_merge_junit.py @@ -0,0 +1,91 @@ +"""Unit tests for devx.ci.merge_junit.""" + +from pathlib import Path +from xml.etree import ElementTree as ET + +import pytest +from click.testing import CliRunner + +from devx.ci.merge_junit import main, merge_files + + +def _write_suite(path: Path, name: str, tests: int, failures: int) -> None: + suite = ET.Element("testsuite", name=name, tests=str(tests), failures=str(failures)) + for i in range(tests): + tc = ET.SubElement(suite, "testcase", classname="cls", name=f"test{i}", time="0.1") + if i < failures: + ET.SubElement(tc, "failure", message="fail") + tree = ET.ElementTree(suite) + tree.write(path, encoding="UTF-8", xml_declaration=True) + + +class TestMergeFiles: + def test_merges_multiple_suites(self, tmp_path: Path) -> None: + _write_suite(tmp_path / "runner-1.xml", "r1", tests=3, failures=1) + _write_suite(tmp_path / "runner-2.xml", "r2", tests=2, failures=0) + merged, total_tests, total_failures = merge_files(str(tmp_path / "runner-*.xml")) + assert total_tests == 5 + assert total_failures == 1 + assert merged.tag == "testsuites" + assert len(merged) == 2 + + def test_no_files_returns_empty(self, tmp_path: Path) -> None: + merged, total_tests, total_failures = merge_files(str(tmp_path / "nonexistent-*.xml")) + assert total_tests == 0 + assert total_failures == 0 + assert merged.tag == "testsuites" + assert len(merged) == 0 + + def test_handles_testsuites_wrapper_root(self, tmp_path: Path) -> None: + wrapper = ET.Element("testsuites") + suite = ET.SubElement(wrapper, "testsuite", name="r1", tests="4", failures="2") + ET.SubElement(suite, "testcase", classname="c", name="t", time="0.1") + tree = ET.ElementTree(wrapper) + tree.write(tmp_path / "runner-1.xml", encoding="UTF-8", xml_declaration=True) + merged, total_tests, total_failures = merge_files(str(tmp_path / "runner-*.xml")) + assert total_tests == 4 + assert total_failures == 2 + + +class TestCli: + def test_writes_merged_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _write_suite(tmp_path / "runner-1.xml", "r1", tests=2, failures=0) + _write_suite(tmp_path / "runner-2.xml", "r2", tests=3, failures=0) + out = tmp_path / "merged.xml" + runner = CliRunner() + result = runner.invoke( + main, + ["--pattern", str(tmp_path / "runner-*.xml"), "--output", str(out)], + ) + assert result.exit_code == 0 + assert out.exists() + tree = ET.parse(out) + root = tree.getroot() + assert root.get("tests") == "5" + assert root.get("failures") == "0" + + def test_exits_nonzero_on_failures(self, tmp_path: Path) -> None: + _write_suite(tmp_path / "runner-1.xml", "r1", tests=2, failures=1) + out = tmp_path / "merged.xml" + runner = CliRunner() + result = runner.invoke( + main, + ["--pattern", str(tmp_path / "runner-*.xml"), "--output", str(out)], + ) + assert result.exit_code != 0 + assert "failures" in result.output + + def test_no_files_exits_zero(self, tmp_path: Path) -> None: + runner = CliRunner() + result = runner.invoke( + main, + ["--pattern", str(tmp_path / "nonexistent-*.xml"), "--output", str(tmp_path / "out.xml")], + ) + assert result.exit_code == 0 + assert "No JUnit" in result.output or "skipping" in result.output + + +def test_main_module_block() -> None: + import devx.ci.merge_junit as mod + + assert hasattr(mod, "main") diff --git a/tests/unit/test_molecule_ci_guard.py b/tests/unit/test_molecule_ci_guard.py index c879a77..16a4ba8 100644 --- a/tests/unit/test_molecule_ci_guard.py +++ b/tests/unit/test_molecule_ci_guard.py @@ -5,8 +5,11 @@ from __future__ import annotations import os import subprocess # nosec B404 import time +import xml.etree.ElementTree as ET +from pathlib import Path from unittest.mock import MagicMock, patch +import click import pytest import requests @@ -16,7 +19,10 @@ from devx.molecule.molecule_ci_guard import ( build_molecule_cmd, cli, get_running_jobs, + parse_pair, poll_for_other_failures, + resolve_role_dir, + write_junit_report, ) @@ -435,3 +441,240 @@ def test_main_module_block() -> None: namespace = dict(mg.__dict__) exec(compile(source, mg.__file__, "exec"), namespace) assert callable(namespace["cli"]) + + +class TestParsePair: + def test_single_role_4_part(self) -> None: + role, scenario, name, image, cmd = parse_pair("default|ubuntu-2204|ubuntu:22.04|") + assert role == "" + assert scenario == "default" + assert name == "ubuntu-2204" + assert image == "ubuntu:22.04" + assert cmd == "" + + def test_multi_role_5_part(self) -> None: + role, scenario, name, image, cmd = parse_pair("gitea-runner|default|ubuntu-2204|ubuntu:22.04|") + assert role == "gitea-runner" + assert scenario == "default" + assert name == "ubuntu-2204" + assert image == "ubuntu:22.04" + assert cmd == "" + + def test_multi_role_with_command(self) -> None: + role, scenario, name, image, cmd = parse_pair( + "docker-base|lifecycle|archlinux|archlinux:latest|/usr/lib/systemd/systemd" + ) + assert role == "docker-base" + assert scenario == "lifecycle" + assert cmd == "/usr/lib/systemd/systemd" + + def test_invalid_pair_raises(self) -> None: + with pytest.raises(click.ClickException, match="Invalid pair format"): + parse_pair("only|two|parts") + + def test_too_many_parts_raises(self) -> None: + with pytest.raises(click.ClickException, match="Invalid pair format"): + parse_pair("a|b|c|d|e|f") + + +class TestResolveRoleDir: + def test_multi_role_with_roles_root(self, tmp_path: Path) -> None: + roles_root = tmp_path / "ansible" / "roles" + roles_root.mkdir(parents=True) + result = resolve_role_dir("gitea-runner", roles_root, tmp_path) + assert result == roles_root / "gitea-runner" + + def test_multi_role_default_roles_root(self, tmp_path: Path) -> None: + result = resolve_role_dir("docker-base", None, tmp_path) + assert result == tmp_path / "ansible" / "roles" / "docker-base" + + def test_single_role_uses_default(self, tmp_path: Path) -> None: + result = resolve_role_dir("", None, tmp_path) + assert result == tmp_path / "ansible" / "roles" / "gitea-runner" + + +class TestWriteJunitReport: + def test_writes_report_with_passing_tests(self, tmp_path: Path) -> None: + output = str(tmp_path / "junit-results" / "runner-1.xml") + testcases = [ + {"role": "gitea-runner", "scenario": "default", "time": 5.2, "passed": True, "error": None}, + {"role": "docker-base", "scenario": "lifecycle", "time": 3.1, "passed": True, "error": None}, + ] + write_junit_report(output, testcases, 1) + tree = ET.parse(output) + root = tree.getroot() + assert root.get("tests") == "2" + assert root.get("failures") == "0" + assert len(root) == 2 + + def test_writes_report_with_failures(self, tmp_path: Path) -> None: + output = str(tmp_path / "runner-2.xml") + testcases = [ + {"role": "", "scenario": "default", "time": 1.0, "passed": False, "error": "Exit code: 1"}, + ] + write_junit_report(output, testcases, 2) + tree = ET.parse(output) + root = tree.getroot() + assert root.get("tests") == "1" + assert root.get("failures") == "1" + failure = root[0][0] + assert failure.tag == "failure" + assert failure.text == "Exit code: 1" + + def test_creates_parent_directory(self, tmp_path: Path) -> None: + output = str(tmp_path / "deep" / "nested" / "dir" / "runner.xml") + write_junit_report(output, [], 0) + assert Path(output).exists() + + +class TestCliMultiRole: + def test_multi_role_pair_passes(self, tmp_path: Path) -> None: + from click.testing import CliRunner + + roles_root = tmp_path / "ansible" / "roles" + (roles_root / "gitea-runner").mkdir(parents=True) + + with ( + patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, + patch("time.sleep"), + ): + proc = MagicMock() + proc.poll.return_value = 0 + proc.returncode = 0 + mock_popen.return_value = proc + + runner = CliRunner() + result = runner.invoke( + cli, + ["--roles-root", str(roles_root), "gitea-runner|default|ubuntu-2204|ubuntu:22.04|"], + ) + assert result.exit_code == 0 + assert "All molecule tests passed" in result.output + + def test_junit_output_written(self, tmp_path: Path) -> None: + from click.testing import CliRunner + + roles_root = tmp_path / "ansible" / "roles" + (roles_root / "gitea-runner").mkdir(parents=True) + junit_path = str(tmp_path / "junit-results" / "runner-1.xml") + + with ( + patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, + patch("time.sleep"), + ): + proc = MagicMock() + proc.poll.return_value = 0 + proc.returncode = 0 + mock_popen.return_value = proc + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--roles-root", + str(roles_root), + "--junit-output", + junit_path, + "gitea-runner|default|ubuntu-2204|ubuntu:22.04|", + ], + ) + assert result.exit_code == 0 + assert Path(junit_path).exists() + + def test_junit_output_on_failure(self, tmp_path: Path) -> None: + from click.testing import CliRunner + + roles_root = tmp_path / "ansible" / "roles" + (roles_root / "gitea-runner").mkdir(parents=True) + junit_path = str(tmp_path / "junit-results" / "runner-1.xml") + + with ( + patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, + patch("time.sleep"), + ): + proc = MagicMock() + proc.poll.return_value = 1 + proc.returncode = 1 + mock_popen.return_value = proc + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--roles-root", + str(roles_root), + "--junit-output", + junit_path, + "gitea-runner|default|ubuntu-2204|ubuntu:22.04|", + ], + ) + assert result.exit_code == 1 + assert Path(junit_path).exists() + tree = ET.parse(junit_path) + assert tree.getroot().get("failures") == "1" + + def test_junit_output_on_cancellation(self, tmp_path: Path) -> None: + """JUnit report is written when a runner is cancelled by another runner's failure.""" + from click.testing import CliRunner + + real_sleep = time.sleep + roles_root = tmp_path / "ansible" / "roles" + (roles_root / "gitea-runner").mkdir(parents=True) + junit_path = str(tmp_path / "junit-results" / "runner-1.xml") + call_count = [0] + + def get_jobs_side_effect(*args, **kwargs): + call_count[0] += 1 + if call_count[0] < 2: + return [{"name": "molecule-tests (1)", "conclusion": "running"}] + return [ + {"name": "molecule-tests (0)", "conclusion": "running"}, + {"name": "molecule-tests (1)", "conclusion": "failure"}, + ] + + with ( + patch.dict( + os.environ, + { + "GITEA_URL": "https://gitea.example", + "REPO_TOKEN": "token", + "RUN_ID": "123", + "JOB_NAME": "molecule-tests", + "MATRIX_INDEX": "0", + "GITEA_REPOSITORY": "oblachno-oss/infra", + "PATH": os.environ.get("PATH", ""), + }, + clear=True, + ), + patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01), + patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, + patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect), + patch("os.killpg"), + patch("os.getpgid") as mock_getpgid, + patch("time.sleep", side_effect=lambda x: real_sleep(0.1)), + ): + mock_getpgid.return_value = 123 + proc = MagicMock() + proc.poll.return_value = None + proc.wait.return_value = 0 + mock_popen.return_value = proc + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "--roles-root", + str(roles_root), + "--junit-output", + junit_path, + "gitea-runner|default|ubuntu-2204|ubuntu:22.04|", + ], + ) + assert result.exit_code == 1 + assert Path(junit_path).exists() + tree = ET.parse(junit_path) + root = tree.getroot() + assert root.get("failures") == "1" + # The failure message should mention cancellation + failure = root[0][0] + assert "Cancelled" in (failure.text or "") diff --git a/tests/unit/test_notify_failure.py b/tests/unit/test_notify_failure.py index 3428bf8..c54d2e4 100644 --- a/tests/unit/test_notify_failure.py +++ b/tests/unit/test_notify_failure.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch from click.testing import CliRunner -from devx.ci.notify_failure import main +from devx.ci.notify_failure import _configure_tea_login, main from devx.gitea_cli import TeaCLIError @@ -114,3 +114,105 @@ class TestNotifyFailure: ) assert result.exit_code != 0 assert "REPO_TOKEN" in result.output + + @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) + @patch("devx.ci.notify_failure.shutil.which", return_value=None) + @patch("devx.ci.notify_failure.TeaCLI") + def test_auto_login_no_tea_skips(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None: + """--auto-login with tea not installed skips login and still creates issue.""" + mock_tea = MagicMock() + mock_tea.list_labels.return_value = [] + mock_tea.create_issue.return_value = {"index": 60, "title": "test"} + mock_tea_cls.return_value = mock_tea + + runner = CliRunner() + result = runner.invoke( + main, + ["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"], + ) + assert result.exit_code == 0 + assert "issue #60" in result.output + + @patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) + @patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") + @patch("devx.ci.notify_failure.TeaCLI") + def test_auto_login_no_token_skips_login(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None: + """--auto-login with no REPO_TOKEN skips login but raises before creating issue.""" + mock_tea = MagicMock() + mock_tea_cls.return_value = mock_tea + + runner = CliRunner() + result = runner.invoke( + main, + ["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"], + ) + assert result.exit_code != 0 + assert "REPO_TOKEN" in result.output + + +class TestConfigureTeaLogin: + @patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) + @patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") + def test_no_token_skips(self, mock_which: MagicMock) -> None: + """_configure_tea_login with no token prints skip message and returns.""" + _configure_tea_login() + + @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) + @patch("devx.ci.notify_failure.shutil.which", return_value=None) + def test_no_tea_skips(self, mock_which: MagicMock) -> None: + """_configure_tea_login with no tea binary prints skip message and returns.""" + _configure_tea_login() + + @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) + @patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") + @patch("devx.ci.notify_failure.subprocess.run") + @patch("devx.ci.notify_failure.TeaCLI") + def test_auto_login_configures_tea( + self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock + ) -> None: + """--auto-login calls tea login add and default.""" + mock_run = MagicMock() + mock_run.returncode = 0 + mock_run.stdout = "" + mock_subprocess.return_value = mock_run + + mock_tea = MagicMock() + mock_tea.list_labels.return_value = [] + mock_tea.create_issue.return_value = {"index": 61, "title": "test"} + mock_tea_cls.return_value = mock_tea + + runner = CliRunner() + result = runner.invoke( + main, + ["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"], + ) + assert result.exit_code == 0 + assert "issue #61" in result.output + # tea login add was called + assert mock_subprocess.call_count >= 2 + + @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) + @patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") + @patch("devx.ci.notify_failure.subprocess.run") + @patch("devx.ci.notify_failure.TeaCLI") + def test_auto_login_skips_if_already_configured( + self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock + ) -> None: + """--auto-login skips tea login add if login already exists.""" + mock_list = MagicMock() + mock_list.returncode = 0 + mock_list.stdout = "devx https://git.example.com" + mock_subprocess.return_value = mock_list + + mock_tea = MagicMock() + mock_tea.list_labels.return_value = [] + mock_tea.create_issue.return_value = {"index": 62, "title": "test"} + mock_tea_cls.return_value = mock_tea + + runner = CliRunner() + result = runner.invoke( + main, + ["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"], + ) + assert result.exit_code == 0 + assert "already configured" in result.output diff --git a/tests/unit/test_opentofu.py b/tests/unit/test_opentofu.py new file mode 100644 index 0000000..a419850 --- /dev/null +++ b/tests/unit/test_opentofu.py @@ -0,0 +1,192 @@ +"""Unit tests for devx.opentofu.""" + +from __future__ import annotations + +import json +from pathlib import Path +from subprocess import CompletedProcess +from unittest.mock import MagicMock, patch + +import pytest + +from devx.opentofu import get_tofu_output, get_tofu_vm_field, get_tofu_vm_ip + + +class TestGetTofuOutput: + @patch("devx.opentofu.subprocess.run") + def test_returns_parsed_json(self, mock_run: MagicMock) -> None: + payload = {"staging": {"ipv4": "1.2.3.4"}} + mock_run.return_value = CompletedProcess( + args=["tofu", "output", "-json", "customer_vms"], + returncode=0, + stdout=json.dumps(payload), + stderr="", + ) + result = get_tofu_output("customer_vms", cwd="/tmp/tofu/staging") + assert result == payload + mock_run.assert_called_once() + call_kwargs = mock_run.call_args + assert call_kwargs.args[0] == ["tofu", "output", "-json", "customer_vms"] + assert call_kwargs.kwargs["cwd"] == "/tmp/tofu/staging" + assert call_kwargs.kwargs["env"] is None + + @patch("devx.opentofu.subprocess.run") + def test_with_env(self, mock_run: MagicMock) -> None: + mock_run.return_value = CompletedProcess( + args=["tofu", "output", "-json", "obs"], + returncode=0, + stdout='{"staging": {"ipv4": "5.6.7.8"}}', + stderr="", + ) + env = {"HCLOUD_TOKEN": "secret"} + result = get_tofu_output("obs", cwd=Path("/tmp"), env=env) + assert result == {"staging": {"ipv4": "5.6.7.8"}} + assert mock_run.call_args.kwargs["env"] == env + + @patch("devx.opentofu.subprocess.run") + def test_no_cwd(self, mock_run: MagicMock) -> None: + mock_run.return_value = CompletedProcess( + args=["tofu", "output", "-json", "x"], + returncode=0, + stdout='{"a": 1}', + stderr="", + ) + result = get_tofu_output("x") + assert result == {"a": 1} + assert mock_run.call_args.kwargs["cwd"] is None + + @patch("devx.opentofu.subprocess.run") + def test_pathlib_cwd(self, mock_run: MagicMock) -> None: + mock_run.return_value = CompletedProcess( + args=["tofu", "output", "-json", "x"], + returncode=0, + stdout="{}", + stderr="", + ) + get_tofu_output("x", cwd=Path("/some/path")) + assert mock_run.call_args.kwargs["cwd"] == "/some/path" + + @patch("devx.opentofu.subprocess.run") + def test_failure_raises_runtime_error(self, mock_run: MagicMock) -> None: + mock_run.return_value = CompletedProcess( + args=["tofu", "output", "-json", "x"], + returncode=1, + stdout="", + stderr="Error: module not found", + ) + with pytest.raises(RuntimeError, match="tofu output failed"): + get_tofu_output("x", cwd="/tmp") + + @patch("devx.opentofu.subprocess.run") + def test_invalid_json_raises(self, mock_run: MagicMock) -> None: + mock_run.return_value = CompletedProcess( + args=["tofu", "output", "-json", "x"], + returncode=0, + stdout="not json", + stderr="", + ) + with pytest.raises(json.JSONDecodeError): + get_tofu_output("x") + + +class TestGetTofuVmIp: + @patch("devx.opentofu.subprocess.run") + def test_returns_ipv4(self, mock_run: MagicMock) -> None: + mock_run.return_value = CompletedProcess( + args=["tofu", "output", "-json", "customer_vms"], + returncode=0, + stdout=json.dumps({"oblachno": {"ipv4": "10.0.0.1"}}), + stderr="", + ) + ip = get_tofu_vm_ip("customer_vms", "oblachno", cwd="/tmp") + assert ip == "10.0.0.1" + + @patch("devx.opentofu.subprocess.run") + def test_missing_vm_returns_empty(self, mock_run: MagicMock) -> None: + mock_run.return_value = CompletedProcess( + args=["tofu", "output", "-json", "customer_vms"], + returncode=0, + stdout=json.dumps({"other": {"ipv4": "10.0.0.2"}}), + stderr="", + ) + ip = get_tofu_vm_ip("customer_vms", "missing", cwd="/tmp") + assert ip == "" + + @patch("devx.opentofu.subprocess.run") + def test_missing_ip_field_returns_empty(self, mock_run: MagicMock) -> None: + mock_run.return_value = CompletedProcess( + args=["tofu", "output", "-json", "customer_vms"], + returncode=0, + stdout=json.dumps({"vm1": {"name": "test"}}), + stderr="", + ) + ip = get_tofu_vm_ip("customer_vms", "vm1", cwd="/tmp") + assert ip == "" + + @patch("devx.opentofu.subprocess.run") + def test_custom_ip_field(self, mock_run: MagicMock) -> None: + mock_run.return_value = CompletedProcess( + args=["tofu", "output", "-json", "vms"], + returncode=0, + stdout=json.dumps({"vm1": {"address": "192.168.1.1"}}), + stderr="", + ) + ip = get_tofu_vm_ip("vms", "vm1", cwd="/tmp", ip_field="address") + assert ip == "192.168.1.1" + + @patch("devx.opentofu.subprocess.run") + def test_non_dict_output_returns_empty(self, mock_run: MagicMock) -> None: + mock_run.return_value = CompletedProcess( + args=["tofu", "output", "-json", "vms"], + returncode=0, + stdout='["not", "a", "dict"]', + stderr="", + ) + ip = get_tofu_vm_ip("vms", "vm1", cwd="/tmp") + assert ip == "" + + +class TestGetTofuVmField: + @patch("devx.opentofu.subprocess.run") + def test_returns_field_value(self, mock_run: MagicMock) -> None: + mock_run.return_value = CompletedProcess( + args=["tofu", "output", "-json", "obs"], + returncode=0, + stdout=json.dumps({"staging": {"volume_linux_device": "/dev/sda1"}}), + stderr="", + ) + val = get_tofu_vm_field("obs", "staging", "volume_linux_device", cwd="/tmp") + assert val == "/dev/sda1" + + @patch("devx.opentofu.subprocess.run") + def test_missing_field_returns_empty(self, mock_run: MagicMock) -> None: + mock_run.return_value = CompletedProcess( + args=["tofu", "output", "-json", "obs"], + returncode=0, + stdout=json.dumps({"staging": {"ipv4": "1.2.3.4"}}), + stderr="", + ) + val = get_tofu_vm_field("obs", "staging", "volume_linux_device", cwd="/tmp") + assert val == "" + + @patch("devx.opentofu.subprocess.run") + def test_missing_vm_returns_empty(self, mock_run: MagicMock) -> None: + mock_run.return_value = CompletedProcess( + args=["tofu", "output", "-json", "obs"], + returncode=0, + stdout=json.dumps({"prod": {"x": "y"}}), + stderr="", + ) + val = get_tofu_vm_field("obs", "staging", "x", cwd="/tmp") + assert val == "" + + @patch("devx.opentofu.subprocess.run") + def test_non_dict_output_returns_empty(self, mock_run: MagicMock) -> None: + mock_run.return_value = CompletedProcess( + args=["tofu", "output", "-json", "obs"], + returncode=0, + stdout='"a string"', + stderr="", + ) + val = get_tofu_vm_field("obs", "staging", "x", cwd="/tmp") + assert val == "" diff --git a/tests/unit/test_publish.py b/tests/unit/test_publish.py index 1c79b3e..a0bcdef 100644 --- a/tests/unit/test_publish.py +++ b/tests/unit/test_publish.py @@ -300,3 +300,20 @@ class TestMain: result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 1 assert "Release creation failed" in result.output + + @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}) + @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") + @patch("devx.ci.publish.TeaCLI") + @patch("devx.ci.publish.build_package") + def test_skip_build_skips_build_and_publish( + self, mock_build: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock + ) -> None: + """--skip-build skips build_package and PyPI publish, only creates Gitea release.""" + mock_tea = MagicMock() + mock_tea_cls.return_value = mock_tea + runner = CliRunner() + result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"]) + assert result.exit_code == 0 + assert "skip" in result.output.lower() + mock_build.assert_not_called() + mock_tea.create_release.assert_called_once() diff --git a/tests/unit/test_push_badges.py b/tests/unit/test_push_badges.py index 8c9be0b..c38572c 100644 --- a/tests/unit/test_push_badges.py +++ b/tests/unit/test_push_badges.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -223,3 +224,66 @@ class TestMain: result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"]) assert result.exit_code == 0 mock_update.assert_not_called() + + def test_retries_success_on_second_attempt(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """With --retries 3, first attempt fails but second succeeds.""" + monkeypatch.chdir(tmp_path) + badges_dir = tmp_path / ".badges" + badges_dir.mkdir() + (badges_dir / "badge1.svg").touch() + + import subprocess + + call_count = [0] + + def side_effect(*args: Any, **kwargs: Any) -> Any: + call_count[0] += 1 + # First call (git fetch) fails, rest succeed + if call_count[0] == 1: + raise subprocess.CalledProcessError(1, "git fetch") + return MagicMock(returncode=0, stdout="", stderr="") + + runner = CliRunner() + with ( + patch("subprocess.run", side_effect=side_effect), + patch("devx.ci.push_badges.update_readme_with_badge_sha"), + patch("time.sleep"), + ): + result = runner.invoke( + push_badges.main, + ["--output-dir", str(badges_dir), "--no-readme-update", "--retries", "3"], + ) + assert result.exit_code == 0 + + def test_retries_exhausted(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """With --retries 2, all attempts fail and exit code is non-zero.""" + monkeypatch.chdir(tmp_path) + + import subprocess + + runner = CliRunner() + with ( + patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")), + patch("time.sleep"), + ): + result = runner.invoke( + push_badges.main, + ["--no-readme-update", "--retries", "2"], + ) + assert result.exit_code != 0 + assert "failed after 2" in result.output + + def test_default_retries_is_one(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Without --retries, only one attempt is made (no retry on failure).""" + monkeypatch.chdir(tmp_path) + + import subprocess + + runner = CliRunner() + with ( + patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")), + patch("time.sleep") as mock_sleep, + ): + result = runner.invoke(push_badges.main, ["--no-readme-update"]) + assert result.exit_code != 0 + mock_sleep.assert_not_called() diff --git a/tests/unit/test_release.py b/tests/unit/test_release.py index 2088cc3..e27bf3b 100644 --- a/tests/unit/test_release.py +++ b/tests/unit/test_release.py @@ -53,14 +53,14 @@ class TestRunCmd: class TestGetLatestTag: - @patch("devx.ci.release.run_cmd") - def test_returns_tag(self, mock_run_cmd: MagicMock) -> None: - mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.1.0\n") + @patch("devx.ci._shared.subprocess.run") + def test_returns_tag(self, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock(returncode=0, stdout="v0.1.0\n") assert get_latest_tag() == "v0.1.0" - @patch("devx.ci.release.run_cmd") - def test_no_tags_returns_empty(self, mock_run_cmd: MagicMock) -> None: - mock_run_cmd.return_value = MagicMock(returncode=1, stdout="") + @patch("devx.ci._shared.subprocess.run") + def test_no_tags_returns_empty(self, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock(returncode=1, stdout="") assert get_latest_tag() == "" @@ -889,11 +889,12 @@ class TestMain: assert "master" in result.output @patch.dict("os.environ", {}) + @patch("devx.ci.release.get_latest_tag", return_value="v0.5.0") @patch("devx.ci.release.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.has_user_facing_changes", return_value=False) @patch("devx.ci.release.run_cmd") def test_dry_run_on_non_master_warns( - self, mock_run_cmd: MagicMock, mock_uf: MagicMock, mock_vtc: MagicMock + self, mock_run_cmd: MagicMock, mock_uf: MagicMock, mock_vtc: MagicMock, mock_glt: MagicMock ) -> None: """Dry-run mode should not fail on non-master branches.""" mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="")