Compare commits

...
8 Commits
Author SHA1 Message Date
devx-ci-bot 547fef4f27 release: v0.6.0 [skip ci] 2026-06-23 15:38:44 +02:00
emil 23183df7c7 DEVX-12: feat: add opentofu helpers, CLI entry points, shared utility, and CI improvements
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / release (push) Successful in 1m22s
Post-merge / vikunja (push) Successful in 32s
Post-merge / badges (push) Successful in 50s
Post-merge / sync-wiki (push) Successful in 57s
2026-06-23 13:37:10 +00:00
devx-ci-bot f382408115 release: v0.5.0 [skip ci] 2026-06-23 03:36:10 +02:00
emil 19bec24f45 DEVX-10: feat: add tag verification, idempotency, and --verify mode to release script
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / release (push) Successful in 50s
Post-merge / vikunja (push) Successful in 17s
Post-merge / sync-wiki (push) Successful in 40s
Post-merge / badges (push) Successful in 1m0s
2026-06-23 01:28:27 +00:00
devx-ci-bot 37772f21a9 release: v0.4.4 [skip ci] 2026-06-22 23:45:40 +02:00
emil b07132e3c6 DEVX-9: fix: configurable task prefix and CWD-relative DOCS_DIR 2026-06-22 21:44:35 +00:00
devx-ci-bot 7b3b604c2c release: v0.4.3 [skip ci] 2026-06-22 23:13:27 +02:00
emil 53990dc10c DEVX-8: fix: expand DEFAULT_INFRASTRUCTURE to cover all common project files 2026-06-22 21:12:16 +00:00
37 changed files with 5017 additions and 1139 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .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 - name: Documentation coverage check
env: env:
PYTHONPATH: src PYTHONPATH: src
+1 -1
View File
@@ -1 +1 @@
DEVX-7 DEVX-12
+13 -3
View File
@@ -55,16 +55,20 @@ src/devx/
├── translations.json # Translation strings (en, bg) ├── translations.json # Translation strings (en, bg)
├── ci/ # CI/CD automation modules (run by workflows) ├── ci/ # CI/CD automation modules (run by workflows)
│ ├── release.py # Automated versioning, tagging, changelog │ ├── 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 │ ├── 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 │ ├── classify_changes.py # User-facing vs workflow-only change detection
│ ├── detect_release_commit.py # Detect release commits on master │ ├── detect_release_commit.py # Detect release commits on master
│ ├── validate_commit_msg.py # Conventional commit validation │ ├── validate_commit_msg.py # Conventional commit validation
│ ├── pr_review.py # Automated PR review │ ├── pr_review.py # Automated PR review
│ ├── post_merge.py # Vikunja task updates after merge │ ├── post_merge.py # Vikunja task updates after merge
│ ├── sync_wiki.py # Sync documentation to Gitea wiki │ ├── sync_wiki.py # Sync documentation to Gitea wiki
│ ├── push_badges.py # Generate and push quality badges │ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures)
│ ├── notify_failure.py # Create Gitea issues on CI 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 │ ├── check_translations.py # Translation completeness check
│ └── doc_coverage.py # Documentation coverage check │ └── doc_coverage.py # Documentation coverage check
├── tools/ # Developer tooling modules (run locally or by CI) ├── tools/ # Developer tooling modules (run locally or by CI)
@@ -73,7 +77,13 @@ src/devx/
│ ├── check_test_speed.py # Measure unit test execution time │ ├── check_test_speed.py # Measure unit test execution time
│ ├── configure_repo.py # Branch protection and label setup │ ├── configure_repo.py # Branch protection and label setup
│ └── generate_badges.py # Badge SVG generation │ └── 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) └── 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 ### Key Design Principles
+28 -1
View File
@@ -2,6 +2,30 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.6.0] - 2026-06-23
### Features
- Add opentofu helpers, CLI entry points, shared utility, and CI improvements
## [0.5.0] - 2026-06-23
### Features
- Add tag verification, idempotency, and --verify mode to release script
## [0.4.4] - 2026-06-22
### Bug Fixes
- Configurable task prefix and CWD-relative DOCS_DIR
## [0.4.3] - 2026-06-22
### Bug Fixes
- Expand DEFAULT_INFRASTRUCTURE to cover all common project files
## [0.4.2] - 2026-06-22 ## [0.4.2] - 2026-06-22
### Bug Fixes ### Bug Fixes
@@ -19,27 +43,30 @@ All notable changes to this project will be documented in this file.
### Features ### Features
- Add DEFAULT_INFRASTRUCTURE and configurable task prefix - Add DEFAULT_INFRASTRUCTURE and configurable task prefix
## [0.3.0] - 2026-06-22 ## [0.3.0] - 2026-06-22
### Features ### Features
- Add --no-ansible-collections option to setup tool - Add --no-ansible-collections option to setup tool
## [0.2.0] - 2026-06-22 ## [0.2.0] - 2026-06-22
### Features ### Features
- Pluggable change classification framework - Pluggable change classification framework
## [0.1.2] - 2026-06-22 ## [0.1.2] - 2026-06-22
### Bug Fixes ### Bug Fixes
- Make sync-wiki and vikunja depend on release - Make sync-wiki and vikunja depend on release
## [0.1.1] - 2026-06-22 ## [0.1.1] - 2026-06-22
### Bug Fixes ### Bug Fixes
- Disable push whitelist, allow direct pushes to master - Disable push whitelist, allow direct pushes to master
## [0.1.0] - 2026-06-22
## [0.1.0] - 2026-06-22 ## [0.1.0] - 2026-06-22
+23
View File
@@ -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. 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` ### `devx ci doc-coverage`
Check documentation coverage for CLI commands and major modules. 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` ### `devx ci notify-failure`
Create a Gitea issue when a CI workflow fails. Create a Gitea issue when a CI workflow fails.
@@ -74,6 +86,17 @@ Configure repository: branch protection + labels via Gitea API.
Generate self-contained SVG badge files from project metrics. Generate self-contained SVG badge files from project metrics.
### `devx tools generate-cliff-config`
Generate a `cliff.toml` configuration file with the correct task ID prefix.
Eliminates the need to manually duplicate and maintain cliff.toml across
repos that use devx.
```bash
python -m devx.tools.generate_cliff_config --prefix GRM
python -m devx.tools.generate_cliff_config --prefix GRM --force # overwrite existing
```
### `devx tools install-checkmake` ### `devx tools install-checkmake`
Install checkmake (Makefile linter) if not already present. Install checkmake (Makefile linter) if not already present.
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects.""" """devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.4.2" __version__ = "0.6.0"
+18
View File
@@ -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()
+10 -13
View File
@@ -138,6 +138,7 @@ from typing import Any
import click import click
from devx.ci._shared import get_latest_tag
from devx.i18n import _ from devx.i18n import _
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -271,19 +272,28 @@ DEFAULT_INFRASTRUCTURE: list[str] = [
# Build tooling # Build tooling
"Makefile", "Makefile",
"cliff.toml", "cliff.toml",
"uv.lock",
# Linting / formatting config # Linting / formatting config
".pre-commit-config.yaml", ".pre-commit-config.yaml",
".ruff.toml", ".ruff.toml",
".ansible-lint", ".ansible-lint",
".checkmake.ini",
".editorconfig",
# Environment templates (not the actual .env which is gitignored) # Environment templates (not the actual .env which is gitignored)
".env.example", ".env.example",
# Git config # Git config
".gitignore", ".gitignore",
".gitattributes",
# Project-level documentation (not part of the installed package) # Project-level documentation (not part of the installed package)
"AGENTS.md", "AGENTS.md",
"README.md", "README.md",
"CHANGELOG.md", "CHANGELOG.md",
"TROUBLESHOOTING.md", "TROUBLESHOOTING.md",
"CONTRIBUTING.md",
"CODE_OF_CONDUCT.md",
"REVIEW_CHECKLIST.md",
# Agent/CI tooling config (not part of the installed package)
".devin/**",
# Generated venv activation scripts (created by `make setup`) # Generated venv activation scripts (created by `make setup`)
"activate.sh", "activate.sh",
"activate.fish", "activate.fish",
@@ -485,19 +495,6 @@ def get_changed_files(base: str, head: str) -> list[str]:
return output.split("\n") 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) # Backward-compatible API (used by release.py and CI workflows)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+121
View File
@@ -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()
+138
View File
@@ -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()
+97
View File
@@ -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 ``<testsuites>`` element.
Returns ``(merged_element, total_tests, total_failures)``.
If no files match, returns an empty ``<testsuites>`` 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 <testsuites> (wrapper) and <testsuite> (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()
+61 -2
View File
@@ -10,13 +10,20 @@ Usage:
--repo <owner/repo> \ --repo <owner/repo> \
--run-id <run_id> \ --run-id <run_id> \
--workflow <workflow_name> \ --workflow <workflow_name> \
--commit <commit_sha> --commit <commit_sha> \
--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 from __future__ import annotations
import logging import logging
import os import os
import shutil
import subprocess # nosec B404
import click import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
@@ -30,6 +37,49 @@ load_dotenv()
logger = logging.getLogger("devx") 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: def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
"""Create issue via tea CLI. Returns issue index. """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("--run-id", required=True, help="CI run ID.")
@click.option("--workflow", required=True, help="Workflow name.") @click.option("--workflow", required=True, help="Workflow name.")
@click.option("--commit", required=True, help="Commit SHA.") @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", "") token = os.environ.get("REPO_TOKEN", "")
if not token: if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
if auto_login:
_configure_tea_login()
title = f"[CI] {workflow} workflow failed (run #{run_id})" title = f"[CI] {workflow} workflow failed (run #{run_id})"
body = ( body = (
f"The **{workflow}** workflow failed.\n\n" f"The **{workflow}** workflow failed.\n\n"
+24 -14
View File
@@ -164,7 +164,14 @@ def _default_gitea_registry_url() -> str:
"or a URL derived from GITEA_API_URL. When set, publishes to Gitea PyPI " "or a URL derived from GITEA_API_URL. When set, publishes to Gitea PyPI "
"instead of standard PyPI (unless PYPI_TOKEN is also set).", "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", "") gitea_token = os.environ.get("REPO_TOKEN", "")
if not gitea_token: if not gitea_token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) 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: if not registry_url:
registry_url = _default_gitea_registry_url() registry_url = _default_gitea_registry_url()
build_package() if not skip_build:
build_package()
if pypi_token: if pypi_token:
# Standard PyPI flow takes precedence when PYPI_TOKEN is set # Standard PyPI flow takes precedence when PYPI_TOKEN is set
publish_to_pypi(pypi_token) publish_to_pypi(pypi_token)
elif registry_url: elif registry_url:
# Gitea PyPI registry flow # Gitea PyPI registry flow
publish_to_gitea_registry(registry_url, gitea_token) publish_to_gitea_registry(registry_url, gitea_token)
else: else:
click.echo( click.echo(
_( _(
"PYPI_TOKEN not set and no registry URL configured — " "PYPI_TOKEN not set and no registry URL configured — "
"skipping PyPI publish. No worries, we'll just create the Gitea release." "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) tea = TeaCLI(repo=repo)
release_body = generate_release_notes(tag) release_body = generate_release_notes(tag)
+29 -6
View File
@@ -17,9 +17,11 @@ Usage::
from __future__ import annotations from __future__ import annotations
import contextlib
import re import re
import subprocess # nosec B404 import subprocess # nosec B404
import sys import sys
import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -160,13 +162,34 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
default=False, default=False,
help="Skip updating README with cache-busting URLs (for local testing).", 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.""" """Generate badges and push them to the badges branch."""
fetch_latest_master(branch) last_error: Exception | None = None
generate_badges(output_dir) for attempt in range(1, retries + 1):
badges_sha = push_to_badges_branch(output_dir) try:
if not no_readme_update: fetch_latest_master(branch)
update_readme_with_badge_sha(badges_sha) 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 if __name__ == "__main__": # pragma: no cover
+333 -16
View File
@@ -23,8 +23,14 @@ This script is idempotent: if there are no new conventional commits since the
last tag, it exits with a message and does nothing. If the tag already exists last tag, it exits with a message and does nothing. If the tag already exists
(e.g., from a partial previous run), it skips tag creation and only pushes. (e.g., from a partial previous run), it skips tag creation and only pushes.
**Tag consistency**: Before releasing, the script fetches remote tags and
verifies all existing tags point to commits whose message matches the tag
version. This prevents duplicate release commits (a common issue when CI
checkouts don't fetch tags) and ensures tag/version/commit alignment.
Usage: Usage:
REPO_TOKEN=<token> python3 -m devx.ci.release [--dry-run] [--skip-tests] REPO_TOKEN=<token> python3 -m devx.ci.release [--dry-run] [--skip-tests]
python3 -m devx.ci.release --verify # Check tag/version/release alignment
""" """
from __future__ import annotations from __future__ import annotations
@@ -32,10 +38,12 @@ from __future__ import annotations
import os import os
import re import re
import subprocess # nosec B404 import subprocess # nosec B404
import sys
import click import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] 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.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=.
from devx.i18n import _ from devx.i18n import _
@@ -65,20 +73,87 @@ def run_cmd(args: list[str], check: bool = True, capture: bool = True) -> subpro
return result 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: def tag_exists(tag: str) -> bool:
"""Check if a git tag already exists.""" """Check if a git tag already exists."""
result = run_cmd(["git", "tag", "-l", tag], check=False) result = run_cmd(["git", "tag", "-l", tag], check=False)
return bool(result.stdout.strip()) return bool(result.stdout.strip())
def get_tag_commit(tag: str) -> str:
"""Get the commit hash a tag points to."""
result = run_cmd(["git", "rev-list", "-n1", tag], check=False)
return result.stdout.strip()
def get_head_commit() -> str:
"""Get the current HEAD commit hash."""
result = run_cmd(["git", "rev-parse", "HEAD"], check=False)
return result.stdout.strip()
def fetch_tags() -> None:
"""Fetch tags from remote to ensure local tag state is current.
This is critical in CI environments where a fresh checkout may not
include tags from previous runs. Without this, the script may
create duplicate release commits because ``tag_exists`` returns False
for a tag that exists on the remote but wasn't fetched.
"""
result = run_cmd(["git", "fetch", "--tags", "origin"], check=False)
if result.returncode != 0:
# Don't fail hard — maybe there's no remote (local-only repo)
click.echo(_("Warning: could not fetch tags from origin."))
def get_all_tags() -> list[str]:
"""Get all git tags sorted by version (newest first)."""
result = run_cmd(["git", "tag", "-l", "--sort=-v:refname"], check=False)
if result.returncode != 0:
return []
return [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
def get_commit_version(commit: str) -> str | None:
"""Extract version from a release commit message.
Returns the version string (e.g., '0.4.4') or None if the commit
is not a release commit.
"""
result = run_cmd(["git", "log", "-1", "--pretty=%s", commit], check=False)
match = re.match(r"^release: v(\d+\.\d+\.\d+)", result.stdout.strip())
return match.group(1) if match else None
def verify_tag_consistency() -> list[str]:
"""Verify all tags point to commits with matching version in message.
Returns a list of error messages for inconsistent tags.
An empty list means all tags are consistent.
The first release (v0.1.0 or earliest tag) is exempt — initial releases
often don't have a "release:" commit message (e.g., the initial commit
serves as the first release).
"""
errors: list[str] = []
tags = get_all_tags()
# Sort oldest first to identify the first tag
sorted_tags = sorted(tags, key=lambda t: [int(x) for x in t.lstrip("v").split(".")])
first_tag = sorted_tags[0] if sorted_tags else None
for tag in tags:
tag_version = tag.lstrip("v")
commit_version = get_commit_version(tag)
if commit_version is None:
# First tag is allowed to point to a non-release commit (initial release)
if tag == first_tag:
continue
errors.append(
f" {tag} → points to non-release commit (expected 'release: v{tag_version}', got non-release commit)"
)
elif commit_version != tag_version:
errors.append(f" {tag} → commit says 'release: v{commit_version}' (expected 'release: v{tag_version}')")
return errors
def get_bumped_version() -> str: def get_bumped_version() -> str:
"""Use git-cliff to calculate the next version from conventional commits.""" """Use git-cliff to calculate the next version from conventional commits."""
result = run_cmd(["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG]) result = run_cmd(["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG])
@@ -123,9 +198,12 @@ def has_unreleased_changes(bumped_version: str | None = None) -> bool:
latest = get_latest_tag() latest = get_latest_tag()
if not latest: if not latest:
return True return True
# Check for any commits since the last tag # Check for any commits since the last tag, excluding release commits
# (release commits themselves are not "unreleased changes" — they ARE
# the release). This prevents duplicate release commits when the
# script runs multiple times.
result = run_cmd( result = run_cmd(
["git", "log", f"{latest}..HEAD", "--oneline"], ["git", "log", f"{latest}..HEAD", "--oneline", "--no-merges", "--invert-grep", "--grep=^release: v"],
check=False, check=False,
) )
if result.returncode != 0: if result.returncode != 0:
@@ -239,10 +317,27 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
"""Create an annotated tag with the changelog as message and push it. """Create an annotated tag with the changelog as message and push it.
Returns True if the tag was created/pushed, False if it already existed. Returns True if the tag was created/pushed, False if it already existed.
Raises an error if the tag exists but points to a different commit than HEAD.
""" """
tag = f"v{new_version}" tag = f"v{new_version}"
if tag_exists(tag): if tag_exists(tag):
click.echo(_("Tag {tag} already exists, skipping creation.", tag=tag)) # Verify the tag points to HEAD — if it points elsewhere, that's
# a consistency error, not a skip condition.
tag_commit = get_tag_commit(tag)
head_commit = get_head_commit()
if tag_commit != head_commit:
raise click.ClickException(
_(
"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.",
tag=tag,
tag_commit=tag_commit[:7],
head_commit=head_commit[:7],
)
)
click.echo(_("Tag {tag} already exists and points to HEAD. Skipping creation.", tag=tag))
if not dry_run: if not dry_run:
# Ensure the existing tag is pushed # Ensure the existing tag is pushed
run_cmd(["git", "push", "origin", tag], check=False) run_cmd(["git", "push", "origin", tag], check=False)
@@ -256,6 +351,178 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
return True return True
# ---------------------------------------------------------------------------
# Verification mode
# ---------------------------------------------------------------------------
def get_init_version() -> str | None:
"""Read __version__ from the version file."""
try:
with open(INIT_FILE) as f:
content = f.read()
match = re.search(r'^__version__\s*=\s*"([^"]*)"', content, flags=re.MULTILINE)
return match.group(1) if match else None
except FileNotFoundError:
return None
def get_changelog_versions() -> list[str]:
"""Extract version numbers from CHANGELOG.md headers, in order."""
try:
with open(CHANGELOG_FILE) as f:
content = f.read()
return re.findall(r"^## \[(\d+\.\d+\.\d+)\]", content, flags=re.MULTILINE)
except FileNotFoundError:
return []
def verify_alignment() -> int:
"""Verify tag/version/changelog alignment. Returns exit code (0=ok, 1=issues)."""
click.echo(_("=== Release Alignment Verification ===\n"))
has_issues = False
# 1. Check __version__ matches latest tag
init_version = get_init_version()
latest_tag = get_latest_tag()
latest_tag_version = latest_tag.lstrip("v") if latest_tag else None
click.echo(_("Version file: {file}", file=INIT_FILE))
if init_version:
click.echo(f' __version__ = "{init_version}"')
else:
click.echo(" __version__ = NOT FOUND")
has_issues = True
click.echo(_("\nLatest tag: {tag}", tag=latest_tag or "(none)"))
if latest_tag_version and init_version:
if latest_tag_version == init_version:
click.echo(f" ✓ Tag version matches __version__ ({init_version})")
else:
click.echo(f" ✗ MISMATCH: tag={latest_tag_version}, __version__={init_version}")
has_issues = True
# 2. Check all tags point to commits with matching version
click.echo(_("\nTag → Commit alignment:"))
tag_errors = verify_tag_consistency()
all_tags = get_all_tags()
if not all_tags:
click.echo(" (no tags)")
elif not tag_errors:
click.echo(f" ✓ All {len(all_tags)} tags point to matching release commits")
else:
has_issues = True
for err in tag_errors:
click.echo(f"{err}")
# 3. Check CHANGELOG versions are in descending order
click.echo(_("\nCHANGELOG version ordering:"))
changelog_versions = get_changelog_versions()
if not changelog_versions:
click.echo(" (no versions in CHANGELOG)")
else:
# Check for duplicates
seen: set[str] = set()
duplicates: list[str] = []
for v in changelog_versions:
if v in seen:
duplicates.append(v)
seen.add(v)
# Check ordering (should be descending)
is_ordered = all(changelog_versions[i] >= changelog_versions[i + 1] for i in range(len(changelog_versions) - 1))
if duplicates:
has_issues = True
click.echo(f" ✗ Duplicate entries: {', '.join(duplicates)}")
elif not is_ordered:
has_issues = True
click.echo(f" ✗ Versions not in descending order: {changelog_versions}")
else:
click.echo(f"{len(changelog_versions)} versions, all in descending order")
# Check latest CHANGELOG version matches latest tag.
# The CHANGELOG may have one unreleased section ahead of the latest tag
# (e.g., CHANGELOG has 0.6.4 but latest tag is v0.6.3 — 0.6.4 is unreleased).
if changelog_versions and latest_tag_version:
if changelog_versions[0] == latest_tag_version:
click.echo(f" ✓ Latest CHANGELOG version matches latest tag ({latest_tag_version})")
elif latest_tag_version in changelog_versions:
tag_idx = changelog_versions.index(latest_tag_version)
# Latest tag should be at index 0 or 1 (0 = released, 1 = unreleased ahead)
if tag_idx == 1:
click.echo(
f" ✓ Latest CHANGELOG version ({changelog_versions[0]}) is unreleased, "
f"latest tag is {latest_tag_version}"
)
else:
click.echo(
f" ✗ MISMATCH: CHANGELOG latest={changelog_versions[0]}, "
f"tag={latest_tag_version} (tag is at position {tag_idx})"
)
has_issues = True
else:
click.echo(f" ✗ MISMATCH: CHANGELOG latest={changelog_versions[0]}, tag={latest_tag_version}")
has_issues = True
# 4. Check for untagged release commits.
# Distinguish between:
# - Truly untagged: no tag exists for that version (needs a tag)
# - Duplicates: a tag for that version exists but on a different commit
# (historical artifact from buggy release script — informational, not an error)
click.echo(_("\nUntagged release commits:"))
result = run_cmd(
["git", "log", "--all", "--format=%h %s", "--grep=^release: v"],
check=False,
)
if result.returncode == 0 and result.stdout.strip():
all_release_commits = result.stdout.strip().split("\n")
all_tags_set = {t.lstrip("v") for t in get_all_tags()}
truly_untagged: list[str] = []
duplicates: list[str] = []
for line in all_release_commits:
short_hash = line.split()[0]
tags_at = run_cmd(["git", "tag", "--points-at", short_hash], check=False)
if not tags_at.stdout.strip():
# Check if a tag for this version exists elsewhere
match = re.search(r"release: v(\d+\.\d+\.\d+)", line)
if match and match.group(1) in all_tags_set:
duplicates.append(line)
else:
truly_untagged.append(line)
if truly_untagged:
has_issues = True
click.echo(f"{len(truly_untagged)} untagged release commits (no tag for version):")
for c in truly_untagged[:10]:
click.echo(f" {c}")
if len(truly_untagged) > 10:
click.echo(f" ... and {len(truly_untagged) - 10} more")
else:
click.echo(" ✓ All release commits have tags")
if duplicates:
click.echo(f" {len(duplicates)} duplicate release commits (tag exists on different commit):")
for c in duplicates[:5]:
click.echo(f" {c}")
if len(duplicates) > 5:
click.echo(f" ... and {len(duplicates) - 5} more")
else:
click.echo(" (no release commits found)")
# Summary
click.echo(_("\n=== Summary ==="))
if has_issues:
click.echo("✗ Issues found — see above for details.")
return 1
click.echo("✓ All checks passed — tags, versions, and changelog are aligned.")
return 0
# ---------------------------------------------------------------------------
# Main command
# ---------------------------------------------------------------------------
@click.command() @click.command()
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.") @click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.")
@click.option( @click.option(
@@ -264,7 +531,20 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
default=False, default=False,
help="Skip lint and test verification (NOT recommended — only for emergency releases).", help="Skip lint and test verification (NOT recommended — only for emergency releases).",
) )
def main(dry_run: bool, skip_tests: bool) -> None: @click.option(
"--verify",
is_flag=True,
default=False,
help="Verify tag/version/changelog alignment and exit (no changes made).",
)
def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
"""Automated release: calculate next version, update files, tag, and push.
Use --verify to check tag/version/changelog alignment without making changes.
"""
if verify:
sys.exit(verify_alignment())
# Ensure we're on master (skip this check in dry-run mode for PR validation) # Ensure we're on master (skip this check in dry-run mode for PR validation)
branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip() branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
if branch != "master" and not dry_run: if branch != "master" and not dry_run:
@@ -277,19 +557,56 @@ def main(dry_run: bool, skip_tests: bool) -> None:
) )
) )
# Fetch tags from remote to ensure local tag state is current.
# This is critical in CI where a fresh checkout may not include tags
# from previous runs. Without this, tag_exists() returns False for
# tags that exist on the remote, leading to duplicate release commits.
if not dry_run:
fetch_tags()
# Pre-flight: verify existing tags are consistent. If any tag points
# to a commit with a mismatched version, abort before creating more
# inconsistencies.
tag_errors = verify_tag_consistency()
if tag_errors:
click.echo(_("ERROR: Tag consistency check failed. Existing tags are misaligned:"))
for err in tag_errors:
click.echo(err)
click.echo(
_(
"\nFix the misaligned tags before creating new releases. "
"Run 'python3 -m devx.ci.release --verify' for a full report."
)
)
raise click.ClickException(_("Tag consistency check failed."))
# Release lock: if HEAD is already a release commit, check if the tag # Release lock: if HEAD is already a release commit, check if the tag
# exists. If the tag is missing (e.g., tag push failed in a previous run), # exists AND points to HEAD. If the tag is missing (e.g., tag push
# create and push it instead of skipping — this recovers from the # failed in a previous run), create and push it. If the tag exists
# common failure mode where the commit was pushed but the tag was not. # but points elsewhere, that's an error.
head_msg = run_cmd(["git", "log", "-1", "--pretty=%s"]).stdout.strip() head_msg = run_cmd(["git", "log", "-1", "--pretty=%s"]).stdout.strip()
release_match = re.match(r"^release: v(\d+\.\d+\.\d+)", head_msg) release_match = re.match(r"^release: v(\d+\.\d+\.\d+)", head_msg)
if release_match: if release_match:
release_version = release_match.group(1) release_version = release_match.group(1)
release_tag = f"v{release_version}" release_tag = f"v{release_version}"
if tag_exists(release_tag): if tag_exists(release_tag):
tag_commit = get_tag_commit(release_tag)
head_commit = get_head_commit()
if tag_commit != head_commit:
raise click.ClickException(
_(
"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.",
version=release_version,
tag=release_tag,
tag_commit=tag_commit[:7],
head_commit=head_commit[:7],
)
)
click.echo( click.echo(
_( _(
"HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.", "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
msg=head_msg, msg=head_msg,
tag=release_tag, tag=release_tag,
) )
+7 -1
View File
@@ -34,7 +34,13 @@ from devx.i18n import _
load_dotenv() load_dotenv()
DOCS_DIR = Path(__file__).resolve().parent.parent.parent.parent / "docs" # DOCS_DIR is the repo's docs/ directory. When devx is installed as a
# package (e.g., in .venv/lib/python3.12/site-packages/devx/), the
# __file__-relative path would point inside the venv, not the repo.
# Use DEVX_DOCS_DIR env var if set, otherwise fall back to ./docs
# (relative to the current working directory, which is the repo root
# in CI and local development).
DOCS_DIR = Path(os.environ.get("DEVX_DOCS_DIR", "docs"))
MAPPING_FILE = DOCS_DIR / "mapping.json" MAPPING_FILE = DOCS_DIR / "mapping.json"
+15 -7
View File
@@ -2,9 +2,14 @@
"""Validate commit messages for devx. """Validate commit messages for devx.
Rules: Rules:
- On feature branches: conventional commits ONLY, must NOT include DEVX-N prefix. - On feature branches: conventional commits ONLY, must NOT include <PREFIX>-N prefix.
- On master branch: must follow '<task-id>: <conventional commit>' pattern, - On master branch: must follow '<task-id>: <conventional commit>' pattern,
e.g. 'DEVX-24: fix: resolve timeout'. e.g. 'DEVX-24: fix: resolve timeout'.
The task ID prefix is configurable via the ``DEVX_TASK_PREFIX`` environment
variable (default: ``DEVX``). Projects consuming devx (e.g., GRM) set
their own prefix (e.g., ``GRM``) so the validator enforces the correct
task ID format for each project.
""" """
import re import re
@@ -12,10 +17,10 @@ import subprocess # nosec B404
import click import click
from devx.config import CONVENTIONAL_RE from devx.config import CONVENTIONAL_RE, TASK_PREFIX
from devx.i18n import _ from devx.i18n import _
MASTER_TASK_ID_RE = re.compile(r"^DEVX-\d+:") MASTER_TASK_ID_RE = re.compile(rf"^{TASK_PREFIX}-\d+:")
def first_line(text: str) -> str: def first_line(text: str) -> str:
@@ -51,8 +56,9 @@ def main(commit_msg_file: str, branch: str | None) -> None:
raise click.ClickException( raise click.ClickException(
_( _(
"Oops! Master branch commits must start with a task ID.\n" "Oops! Master branch commits must start with a task ID.\n"
" Expected: DEVX-N: <conventional commit message>\n" " Expected: {prefix}-N: <conventional commit message>\n"
" Got: {subject}", " Got: {subject}",
prefix=TASK_PREFIX,
subject=subject, subject=subject,
) )
) )
@@ -61,8 +67,9 @@ def main(commit_msg_file: str, branch: str | None) -> None:
raise click.ClickException( raise click.ClickException(
_( _(
"Oops! Master branch commit must follow conventional format after task ID.\n" "Oops! Master branch commit must follow conventional format after task ID.\n"
" Expected: DEVX-N: <type>: <description>\n" " Expected: {prefix}-N: <type>: <description>\n"
" Got: {subject}", " Got: {subject}",
prefix=TASK_PREFIX,
subject=subject, subject=subject,
) )
) )
@@ -71,8 +78,9 @@ def main(commit_msg_file: str, branch: str | None) -> None:
if MASTER_TASK_ID_RE.match(subject): if MASTER_TASK_ID_RE.match(subject):
raise click.ClickException( raise click.ClickException(
_( _(
"Oops! Do not include task ID (DEVX-N) in feature branch commits.\n" "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n"
" The task ID will be added automatically on merge via CI." " The task ID will be added automatically on merge via CI.",
prefix=TASK_PREFIX,
) )
) )
+28
View File
@@ -151,6 +151,27 @@ def ci_validate_commit_msg(args: tuple[str, ...]) -> None:
_run_module("devx.ci.validate_commit_msg", list(args)) _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() @cli.group()
def tools() -> None: def tools() -> None:
"""Development tool commands.""" """Development tool commands."""
@@ -177,6 +198,13 @@ def tools_generate_badges(args: tuple[str, ...]) -> None:
_run_module("devx.tools.generate_badges", list(args)) _run_module("devx.tools.generate_badges", list(args))
@tools.command("generate-cliff-config")
@click.argument("args", nargs=-1)
def tools_generate_cliff_config(args: tuple[str, ...]) -> None:
"""Generate a cliff.toml configuration file for the project."""
_run_module("devx.tools.generate_cliff_config", list(args))
@tools.command("install-checkmake") @tools.command("install-checkmake")
@click.argument("args", nargs=-1) @click.argument("args", nargs=-1)
def tools_install_checkmake(args: tuple[str, ...]) -> None: def tools_install_checkmake(args: tuple[str, ...]) -> None:
+136 -1
View File
@@ -29,6 +29,7 @@ from devx.molecule.platforms import PLATFORMS
DEFAULT_MAX_RUNNERS = 3 DEFAULT_MAX_RUNNERS = 3
MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule") MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule")
DEFAULT_ROLES_ROOT = Path("ansible/roles")
@dataclass(frozen=True) @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]: def discover_scenarios(root: Path | None = None) -> list[str]:
"""Return sorted list of molecule scenario directory names.""" """Return sorted list of molecule scenario directory names."""
if root is None: if root is None:
@@ -62,6 +88,33 @@ def discover_scenarios(root: Path | None = None) -> list[str]:
return sorted(scenarios) 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]: def build_pairs(scenarios: list[str], platforms: list[dict[str, str]] | None = None) -> list[TestPair]:
"""Build the full cross-product of scenarios and platforms.""" """Build the full cross-product of scenarios and platforms."""
if platforms is None: 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] 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]]: def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]:
"""Split *pairs* into *max_runners* balanced groups (round-robin).""" """Split *pairs* into *max_runners* balanced groups (round-robin)."""
groups: list[list[TestPair]] = [[] for _ in range(max_runners)] groups: list[list[TestPair]] = [[] for _ in range(max_runners)]
@@ -142,6 +225,19 @@ def _write_github_env(key: str, value: str) -> None:
default=False, default=False,
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.", 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( def cli(
runner_index: int | None, runner_index: int | None,
max_runners: int, max_runners: int,
@@ -149,8 +245,47 @@ def cli(
list_platforms: bool, list_platforms: bool,
github_env: bool, github_env: bool,
skip_if_excess: bool, skip_if_excess: bool,
molecule_root: Path | None,
roles_root: Path | None,
) -> 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: if list_all:
for s in scenarios: for s in scenarios:
click.echo(s) click.echo(s)
+126 -13
View File
@@ -1,7 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Run molecule tests sequentially while polling Gitea for other runner failures. """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 Pairs are executed one at a time (molecule scenarios share temp directories and
Docker networks, so parallel execution within a single runner is unsafe). 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 reports failure, the current molecule subprocess is killed and this runner
exits early with code 1. exits early with code 1.
Usage: JUnit XML is generated when ``--junit-output`` is provided, recording each
python3 -m devx.molecule.molecule_ci_guard <pair1> <pair2> ... 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: Environment variables:
GITEA_URL Base URL of the Gitea instance. GITEA_URL Base URL of the Gitea instance.
@@ -30,6 +43,7 @@ import subprocess # nosec B404
import sys import sys
import threading import threading
import time import time
import xml.etree.ElementTree as ET # nosec B405
from pathlib import Path from pathlib import Path
import click import click
@@ -95,9 +109,23 @@ def build_molecule_cmd(scenario: str) -> list[str]:
return cmd 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]: def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]:
"""Build environment for a single molecule pair.""" """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 = base_env.copy()
env["MOLECULE_PLATFORM_NAME"] = platform_name env["MOLECULE_PLATFORM_NAME"] = platform_name
env["MOLECULE_PLATFORM_IMAGE"] = platform_image 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 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.command()
@click.argument("pairs", nargs=-1, required=True) @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.""" """Run molecule pairs sequentially, stop if another CI runner fails."""
gitea_url = os.environ.get("GITEA_URL", "") gitea_url = os.environ.get("GITEA_URL", "")
token = os.environ.get("REPO_TOKEN", "") 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") job_name = os.environ.get("JOB_NAME", "molecule-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0")) current_index = int(os.environ.get("MATRIX_INDEX", "0"))
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx") 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: if not owner or not repo:
owner, repo = "oblachno-oss", "devx" 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.")) click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
repo_root = Path(__file__).resolve().parent.parent.parent.parent repo_root = Path(__file__).resolve().parent.parent.parent.parent
role_dir = repo_root / "ansible" / "roles" / "gitea-runner"
base_env = os.environ.copy() base_env = os.environ.copy()
base_env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock") 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() poller.start()
testcases: list[dict] = []
try: try:
for pair in pairs: for pair in pairs:
if failed_event.is_set(): if failed_event.is_set():
sys.exit(1) sys.exit(1)
parts = pair.split("|") role, scenario, platform_name, _img, _cmd = parse_pair(pair)
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]
click.echo(_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name)) click.echo(_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name))
cmd = build_molecule_cmd(scenario) cmd = build_molecule_cmd(scenario)
env = build_env_for_pair(pair, base_env) 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 process = subprocess.Popen( # nosec B603
cmd, cmd,
cwd=str(role_dir), cwd=str(cwd),
env=env, env=env,
preexec_fn=os.setsid, preexec_fn=os.setsid,
) )
@@ -187,6 +271,18 @@ def cli(pairs: tuple[str, ...]) -> None:
with contextlib.suppress(ProcessLookupError): with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGKILL) os.killpg(os.getpgid(process.pid), signal.SIGKILL)
process.wait() 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) sys.exit(1)
time.sleep(1) time.sleep(1)
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -196,13 +292,30 @@ def cli(pairs: tuple[str, ...]) -> None:
sys.exit(1) sys.exit(1)
rc = process.returncode 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: if rc != 0:
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc)) 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) sys.exit(rc)
click.echo(_("PASSED: {pair}", pair=pair)) click.echo(_("PASSED: {pair}", pair=pair))
click.echo(_("All molecule tests passed.")) click.echo(_("All molecule tests passed."))
if junit_output:
write_junit_report(junit_output, testcases, current_index)
finally: finally:
stop_event.set() stop_event.set()
+113
View File
@@ -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 <output_name>`` 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, ""))
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Generate a cliff.toml configuration file for a project.
Produces a git-cliff configuration with the correct task ID prefix
preprocessor, matching the format used by devx itself. Downstream
repos can use this to avoid duplicating the entire cliff.toml by hand.
Usage::
python -m devx.tools.generate_cliff_config --prefix GRM
python -m devx.tools.generate_cliff_config --prefix GRM --output cliff.toml
python -m devx.tools.generate_cliff_config --prefix GRM --force
"""
from __future__ import annotations
from pathlib import Path
import click
from devx.config import TASK_PREFIX
from devx.i18n import _
# Template uses __PREFIX__ and __PREFIX_REGEX__ as placeholders to avoid
# conflicts with Jinja2's {{ }} and {% %} syntax in the cliff.toml body.
CLIFF_TEMPLATE = """\
# git-cliff configuration for __PREFIX__
# https://git-cliff.org/docs/configuration
# Generated by: python -m devx.tools.generate_cliff_config --prefix __PREFIX__
[changelog]
header = \"\"\"
# Changelog\\n
All notable changes to this project will be documented in this file.\\n
\"\"\"
body = \"\"\"
{% if version %}\\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\\
## [unreleased]
{% endif %}\\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\\
{% if commit.breaking %}[**breaking**] {% endif %}\\
{{ commit.message | upper_first }}\\
{% endfor %}
{% endfor %}
\"\"\"
trim = true
render_always = true
[git]
conventional_commits = true
filter_unconventional = true
require_conventional = false
split_commits = false
protect_breaking_commits = false
filter_commits = false
fail_on_unmatched_commit = false
use_branch_tags = false
topo_order = false
topo_order_commits = true
sort_commits = "oldest"
recurse_submodules = false
commit_preprocessors = [
# Strip __PREFIX__-N: task ID prefix from squash-merge commits so git-cliff sees conventional commits
{ pattern = "^__PREFIX_REGEX__-\\\\d+:\\\\s+", replace = "" },
]
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->Features" },
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
{ message = "^perf", group = "<!-- 4 -->Performance" },
{ message = "^refactor", group = "<!-- 2 -->Refactor" },
# Skip infrastructure-only commits — they don't affect users
{ message = "^doc", skip = true },
{ message = "^test", skip = true },
{ message = "^style", skip = true },
{ message = "^chore", skip = true },
{ message = "^ci", skip = true },
# Skip release commits — they are release artifacts, not features
{ message = "^release:", skip = true },
{ body = ".*security", group = "<!-- 8 -->Security" },
{ message = "^revert", group = "<!-- 9 -->Revert" },
# Skip anything that doesn't match above — safe default
{ message = ".*", skip = true },
]
[bump]
features_always_bump_minor = true
breaking_always_bump_major = false
initial_tag = "0.1.0"
# Refactor commits bump patch — structural changes to src/ or pyproject.toml
# affect users even though no new feature was added.
refactor_always_bump_patch = true
"""
def _generate(prefix: str) -> str:
"""Generate cliff.toml content for the given prefix."""
prefix_regex = prefix.replace("\\", "\\\\")
return CLIFF_TEMPLATE.replace("__PREFIX__", prefix).replace("__PREFIX_REGEX__", prefix_regex)
@click.command()
@click.option(
"--prefix",
default=TASK_PREFIX,
help="Task ID prefix for commit preprocessor (default: DEVX_TASK_PREFIX env var or 'DEVX').",
)
@click.option(
"--output",
"-o",
default="cliff.toml",
type=click.Path(),
help="Output file path (default: cliff.toml).",
)
@click.option(
"--force",
is_flag=True,
help="Overwrite existing file without prompting.",
)
def main(prefix: str, output: str, force: bool) -> None:
"""Generate a cliff.toml configuration file."""
output_path = Path(output)
if output_path.exists() and not force:
raise click.ClickException(
_(
"{file} already exists. Use --force to overwrite.",
file=str(output_path),
)
)
content = _generate(prefix)
output_path.write_text(content)
click.echo(
_(
"Generated {file} with prefix '{prefix}'.",
file=str(output_path),
prefix=prefix,
)
)
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
+1197 -1043
View File
@@ -1,1045 +1,1199 @@
{ {
"\nAll documentation coverage checks passed!": { "\n=== Summary ===": {
"en": "\nAll documentation coverage checks passed!", "en": "\n=== Summary ===",
"bg": "\nAll documentation coverage checks passed!", "bg": "\n=== Summary ===",
"de": "\nAll documentation coverage checks passed!", "de": "\n=== Summary ===",
"ru": "\nAll documentation coverage checks passed!", "ru": "\n=== Summary ===",
"zh": "\nAll documentation coverage checks passed!" "zh": "\n=== Summary ==="
}, },
"\nChecking CI script documentation in ci-cd-workflow.md...": { "\nAll documentation coverage checks passed!": {
"en": "\nChecking CI script documentation in ci-cd-workflow.md...", "en": "\nAll documentation coverage checks passed!",
"bg": "\nChecking CI script documentation in ci-cd-workflow.md...", "bg": "\nAll documentation coverage checks passed!",
"de": "\nChecking CI script documentation in ci-cd-workflow.md...", "de": "\nAll documentation coverage checks passed!",
"ru": "\nChecking CI script documentation in ci-cd-workflow.md...", "ru": "\nAll documentation coverage checks passed!",
"zh": "\nChecking CI script documentation in ci-cd-workflow.md..." "zh": "\nAll documentation coverage checks passed!"
}, },
"\nChecking module documentation in architecture.md...": { "\nCHANGELOG version ordering:": {
"en": "\nChecking module documentation in architecture.md...", "en": "\nCHANGELOG version ordering:",
"bg": "\nChecking module documentation in architecture.md...", "bg": "\nCHANGELOG version ordering:",
"de": "\nChecking module documentation in architecture.md...", "de": "\nCHANGELOG version ordering:",
"ru": "\nChecking module documentation in architecture.md...", "ru": "\nCHANGELOG version ordering:",
"zh": "\nChecking module documentation in architecture.md..." "zh": "\nCHANGELOG version ordering:"
}, },
"\nDoc coverage: {covered}/{total} ({pct}%)": { "\nChecking CI script documentation in ci-cd-workflow.md...": {
"en": "\nDoc coverage: {covered}/{total} ({pct}%)", "en": "\nChecking CI script documentation in ci-cd-workflow.md...",
"bg": "\nDoc coverage: {covered}/{total} ({pct}%)", "bg": "\nChecking CI script documentation in ci-cd-workflow.md...",
"de": "\nDoc coverage: {covered}/{total} ({pct}%)", "de": "\nChecking CI script documentation in ci-cd-workflow.md...",
"ru": "\nDoc coverage: {covered}/{total} ({pct}%)", "ru": "\nChecking CI script documentation in ci-cd-workflow.md...",
"zh": "\nDoc coverage: {covered}/{total} ({pct}%)" "zh": "\nChecking CI script documentation in ci-cd-workflow.md..."
}, },
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}": { "\nChecking module documentation in architecture.md...": {
"en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}", "en": "\nChecking module documentation in architecture.md...",
"bg": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}", "bg": "\nChecking module documentation in architecture.md...",
"de": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}", "de": "\nChecking module documentation in architecture.md...",
"ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}", "ru": "\nChecking module documentation in architecture.md...",
"zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}" "zh": "\nChecking module documentation in architecture.md..."
}, },
"\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": { "\nDoc coverage: {covered}/{total} ({pct}%)": {
"en": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.", "en": "\nDoc coverage: {covered}/{total} ({pct}%)",
"bg": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.", "bg": "\nDoc coverage: {covered}/{total} ({pct}%)",
"de": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.", "de": "\nDoc coverage: {covered}/{total} ({pct}%)",
"ru": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.", "ru": "\nDoc coverage: {covered}/{total} ({pct}%)",
"zh": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce." "zh": "\nDoc coverage: {covered}/{total} ({pct}%)"
}, },
"\nIntegrity check FAILED ({count} issues):": { "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}": {
"en": "\nIntegrity check FAILED ({count} issues):", "en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"bg": "\nIntegrity check FAILED ({count} issues):", "bg": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"de": "\nIntegrity check FAILED ({count} issues):", "de": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"ru": "\nIntegrity check FAILED ({count} issues):", "ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"zh": "\nIntegrity check FAILED ({count} issues):" "zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
}, },
"\nIntegrity check passed — all {count} pages verified.": { "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": {
"en": "\nIntegrity check passed — all {count} pages verified.", "en": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
"bg": "\nIntegrity check passed — all {count} pages verified.", "bg": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
"de": "\nIntegrity check passed — all {count} pages verified.", "de": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
"ru": "\nIntegrity check passed — all {count} pages verified.", "ru": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
"zh": "\nIntegrity check passed — all {count} pages verified." "zh": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce."
}, },
"\nMissing documentation:": { "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.": {
"en": "\nMissing documentation:", "en": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
"bg": "\nMissing documentation:", "bg": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
"de": "\nMissing documentation:", "de": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
"ru": "\nMissing documentation:", "ru": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
"zh": "\nMissing documentation:" "zh": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report."
}, },
"\nResult: {status}": { "\nIntegrity check FAILED ({count} issues):": {
"en": "\nResult: {status}", "en": "\nIntegrity check FAILED ({count} issues):",
"bg": "\nResult: {status}", "bg": "\nIntegrity check FAILED ({count} issues):",
"de": "\nResult: {status}", "de": "\nIntegrity check FAILED ({count} issues):",
"ru": "\nResult: {status}", "ru": "\nIntegrity check FAILED ({count} issues):",
"zh": "\nResult: {status}" "zh": "\nIntegrity check FAILED ({count} issues):"
}, },
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).": { "\nIntegrity check passed — all {count} pages verified.": {
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).", "en": "\nIntegrity check passed — all {count} pages verified.",
"bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).", "bg": "\nIntegrity check passed — all {count} pages verified.",
"de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).", "de": "\nIntegrity check passed — all {count} pages verified.",
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).", "ru": "\nIntegrity check passed — all {count} pages verified.",
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)." "zh": "\nIntegrity check passed — all {count} pages verified."
}, },
"\nRunning full wiki integrity check...": { "\nLatest tag: {tag}": {
"en": "\nRunning full wiki integrity check...", "en": "\nLatest tag: {tag}",
"bg": "\nRunning full wiki integrity check...", "bg": "\nLatest tag: {tag}",
"de": "\nRunning full wiki integrity check...", "de": "\nLatest tag: {tag}",
"ru": "\nRunning full wiki integrity check...", "ru": "\nLatest tag: {tag}",
"zh": "\nRunning full wiki integrity check..." "zh": "\nLatest tag: {tag}"
}, },
"\nUser-facing changes ({count}):": { "\nMissing documentation:": {
"en": "\nUser-facing changes ({count}):", "en": "\nMissing documentation:",
"bg": "\nUser-facing changes ({count}):", "bg": "\nMissing documentation:",
"de": "\nUser-facing changes ({count}):", "de": "\nMissing documentation:",
"ru": "\nUser-facing changes ({count}):", "ru": "\nMissing documentation:",
"zh": "\nUser-facing changes ({count}):" "zh": "\nMissing documentation:"
}, },
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!": { "\nResult: {status}": {
"en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!", "en": "\nResult: {status}",
"bg": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!", "bg": "\nResult: {status}",
"de": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!", "de": "\nResult: {status}",
"ru": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!", "ru": "\nResult: {status}",
"zh": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!" "zh": "\nResult: {status}"
}, },
"\nVerification passed — all wiki pages have correct content.": { "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).": {
"en": "\nVerification passed — all wiki pages have correct content.", "en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"bg": "\nVerification passed — all wiki pages have correct content.", "bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"de": "\nVerification passed — all wiki pages have correct content.", "de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"ru": "\nVerification passed — all wiki pages have correct content.", "ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"zh": "\nVerification passed — all wiki pages have correct content." "zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
}, },
"\nVerifying wiki pages have content...": { "\nRunning full wiki integrity check...": {
"en": "\nVerifying wiki pages have content...", "en": "\nRunning full wiki integrity check...",
"bg": "\nVerifying wiki pages have content...", "bg": "\nRunning full wiki integrity check...",
"de": "\nVerifying wiki pages have content...", "de": "\nRunning full wiki integrity check...",
"ru": "\nVerifying wiki pages have content...", "ru": "\nRunning full wiki integrity check...",
"zh": "\nVerifying wiki pages have content..." "zh": "\nRunning full wiki integrity check..."
}, },
"\nWorkflow-only changes ({count}):": { "\nTag → Commit alignment:": {
"en": "\nWorkflow-only changes ({count}):", "en": "\nTag → Commit alignment:",
"bg": "\nWorkflow-only changes ({count}):", "bg": "\nTag → Commit alignment:",
"de": "\nWorkflow-only changes ({count}):", "de": "\nTag → Commit alignment:",
"ru": "\nWorkflow-only changes ({count}):", "ru": "\nTag → Commit alignment:",
"zh": "\nWorkflow-only changes ({count}):" "zh": "\nTag → Commit alignment:"
}, },
"\n[dry-run] Changelog:\n{changelog}": { "\nUntagged release commits:": {
"en": "\n[dry-run] Changelog:\n{changelog}", "en": "\nUntagged release commits:",
"bg": "\n[dry-run] Changelog:\n{changelog}", "bg": "\nUntagged release commits:",
"de": "\n[dry-run] Changelog:\n{changelog}", "de": "\nUntagged release commits:",
"ru": "\n[dry-run] Changelog:\n{changelog}", "ru": "\nUntagged release commits:",
"zh": "\n[dry-run] Changelog:\n{changelog}" "zh": "\nUntagged release commits:"
}, },
" - Auto-delete branch after merge: yes": { "\nUser-facing changes ({count}):": {
"en": " - Auto-delete branch after merge: yes", "en": "\nUser-facing changes ({count}):",
"bg": " - Автоматично изтриване на клон след сливане: да", "bg": "\nUser-facing changes ({count}):",
"de": " - Branch nach Merge automatisch löschen: ja", "de": "\nUser-facing changes ({count}):",
"ru": " - Автоудаление ветки после слияния: да", "ru": "\nUser-facing changes ({count}):",
"zh": " - 合并后自动删除分支: 是" "zh": "\nUser-facing changes ({count}):"
}, },
" - Block outdated branches: yes": { "\nVerification FAILED: {failures} page(s) have empty or mismatched content!": {
"en": " - Block outdated branches: yes", "en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"bg": " - Блокиране на остарели клонове: да", "bg": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"de": " - Veraltete Branches blockieren: ja", "de": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"ru": " - Блокировать устаревшие ветки: да", "ru": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"zh": " - 阻止过时分支: 是" "zh": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!"
}, },
" - Block rejected reviews: yes": { "\nVerification passed — all wiki pages have correct content.": {
"en": " - Block rejected reviews: yes", "en": "\nVerification passed — all wiki pages have correct content.",
"bg": " - Блокиране на отхвърлени рецензии: да", "bg": "\nVerification passed — all wiki pages have correct content.",
"de": " - Abgelehnte Reviews blockieren: ja", "de": "\nVerification passed — all wiki pages have correct content.",
"ru": " - Блокировать отклонённые ревью: да", "ru": "\nVerification passed — all wiki pages have correct content.",
"zh": " - 阻止被拒绝的审查: 是" "zh": "\nVerification passed — all wiki pages have correct content."
}, },
" - Direct pushes: BLOCKED (require PR, whitelisted users can push)": { "\nVerifying wiki pages have content...": {
"en": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)", "en": "\nVerifying wiki pages have content...",
"bg": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)", "bg": "\nVerifying wiki pages have content...",
"de": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)", "de": "\nVerifying wiki pages have content...",
"ru": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)", "ru": "\nVerifying wiki pages have content...",
"zh": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)" "zh": "\nVerifying wiki pages have content..."
}, },
" - Dismiss stale approvals: yes": { "\nWorkflow-only changes ({count}):": {
"en": " - Dismiss stale approvals: yes", "en": "\nWorkflow-only changes ({count}):",
"bg": " - Анулиране на остарели одобрения: да", "bg": "\nWorkflow-only changes ({count}):",
"de": " - Veraltete Genehmigungen ablehnen: ja", "de": "\nWorkflow-only changes ({count}):",
"ru": " - Отклонять устаревшие одобрения: да", "ru": "\nWorkflow-only changes ({count}):",
"zh": " - 忽略过时审批: 是" "zh": "\nWorkflow-only changes ({count}):"
}, },
" - Required approvals: {count}": { "\n[dry-run] Changelog:\n{changelog}": {
"en": " - Required approvals: {count}", "en": "\n[dry-run] Changelog:\n{changelog}",
"bg": " - Необходими одобрения: {count}", "bg": "\n[dry-run] Changelog:\n{changelog}",
"de": " - Erforderliche Genehmigungen: {count}", "de": "\n[dry-run] Changelog:\n{changelog}",
"ru": " - Требуемые одобрения: {count}", "ru": "\n[dry-run] Changelog:\n{changelog}",
"zh": " - 必需审批数: {count}" "zh": "\n[dry-run] Changelog:\n{changelog}"
}, },
" - Required status checks: {checks}": { "\n{label} files changed ({count}):": {
"en": " - Required status checks: {checks}", "en": "\n{label} files changed ({count}):",
"bg": " - Необходими проверки на състоянието: {checks}", "bg": "\n{label} files changed ({count}):",
"de": " - Erforderliche Status-Checks: {checks}", "de": "\n{label} files changed ({count}):",
"ru": " - Требуемые проверки статуса: {checks}", "ru": "\n{label} files changed ({count}):",
"zh": " - 必需状态检查: {checks}" "zh": "\n{label} files changed ({count}):"
}, },
" Created: {title}": { "\n{tag} files ({count}):": {
"en": " Created: {title}", "en": "\n{tag} files ({count}):",
"bg": " Created: {title}", "bg": "\n{tag} files ({count}):",
"de": " Created: {title}", "de": "\n{tag} files ({count}):",
"ru": " Created: {title}", "ru": "\n{tag} files ({count}):",
"zh": " Created: {title}" "zh": "\n{tag} files ({count}):"
}, },
" FAIL: {title} — content mismatch or empty!": { " - Auto-delete branch after merge: yes": {
"en": " FAIL: {title} — content mismatch or empty!", "en": " - Auto-delete branch after merge: yes",
"bg": " FAIL: {title} — content mismatch or empty!", "bg": " - Автоматично изтриване на клон след сливане: да",
"de": " FAIL: {title} — content mismatch or empty!", "de": " - Branch nach Merge automatisch löschen: ja",
"ru": " FAIL: {title} — content mismatch or empty!", "ru": " - Автоудаление ветки после слияния: да",
"zh": " FAIL: {title} — content mismatch or empty!" "zh": " - 合并后自动删除分支: 是"
}, },
" MISSING: devx {cmd}": { " - Block outdated branches: yes": {
"en": " MISSING: devx {cmd}", "en": " - Block outdated branches: yes",
"bg": " ЛИПСВА: devx {cmd}", "bg": " - Блокиране на остарели клонове: да",
"de": " FEHLT: devx {cmd}", "de": " - Veraltete Branches blockieren: ja",
"ru": " ОТСУТСТВУЕТ: devx {cmd}", "ru": " - Блокировать устаревшие ветки: да",
"zh": " 缺失: devx {cmd}" "zh": " - 阻止过时分支: 是"
}, },
" MISSING: {module}": { " - Block rejected reviews: yes": {
"en": " MISSING: {module}", "en": " - Block rejected reviews: yes",
"bg": " MISSING: {module}", "bg": " - Блокиране на отхвърлени рецензии: да",
"de": " MISSING: {module}", "de": " - Abgelehnte Reviews blockieren: ja",
"ru": " MISSING: {module}", "ru": " - Блокировать отклонённые ревью: да",
"zh": " MISSING: {module}" "zh": " - 阻止被拒绝的审查: 是"
}, },
" MISSING: {script}": { " - Direct pushes: BLOCKED (require PR, whitelisted users can push)": {
"en": " MISSING: {script}", "en": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
"bg": " MISSING: {script}", "bg": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
"de": " MISSING: {script}", "de": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
"ru": " MISSING: {script}", "ru": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
"zh": " MISSING: {script}" "zh": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)"
}, },
" OK: devx {cmd}": { " - Dismiss stale approvals: yes": {
"en": " OK: devx {cmd}", "en": " - Dismiss stale approvals: yes",
"bg": " ОК: devx {cmd}", "bg": " - Анулиране на остарели одобрения: да",
"de": " OK: devx {cmd}", "de": " - Veraltete Genehmigungen ablehnen: ja",
"ru": " ОК: devx {cmd}", "ru": " - Отклонять устаревшие одобрения: да",
"zh": " 正常: devx {cmd}" "zh": " - 忽略过时审批: 是"
}, },
" OK: {module}": { " - Required approvals: {count}": {
"en": " OK: {module}", "en": " - Required approvals: {count}",
"bg": " OK: {module}", "bg": " - Необходими одобрения: {count}",
"de": " OK: {module}", "de": " - Erforderliche Genehmigungen: {count}",
"ru": " OK: {module}", "ru": " - Требуемые одобрения: {count}",
"zh": " OK: {module}" "zh": " - 必需审批数: {count}"
}, },
" OK: {script}": { " - Required status checks: {checks}": {
"en": " OK: {script}", "en": " - Required status checks: {checks}",
"bg": " OK: {script}", "bg": " - Необходими проверки на състоянието: {checks}",
"de": " OK: {script}", "de": " - Erforderliche Status-Checks: {checks}",
"ru": " OK: {script}", "ru": " - Требуемые проверки статуса: {checks}",
"zh": " OK: {script}" "zh": " - 必需状态检查: {checks}"
}, },
" OK: {title} ({chars} chars)": { " Created: {title}": {
"en": " OK: {title} ({chars} chars)", "en": " Created: {title}",
"bg": " OK: {title} ({chars} chars)", "bg": " Created: {title}",
"de": " OK: {title} ({chars} chars)", "de": " Created: {title}",
"ru": " OK: {title} ({chars} chars)", "ru": " Created: {title}",
"zh": " OK: {title} ({chars} chars)" "zh": " Created: {title}"
}, },
" Updated: {title}": { " FAIL: {title} — content mismatch or empty!": {
"en": " Updated: {title}", "en": " FAIL: {title} — content mismatch or empty!",
"bg": " Updated: {title}", "bg": " FAIL: {title} — content mismatch or empty!",
"de": " Updated: {title}", "de": " FAIL: {title} — content mismatch or empty!",
"ru": " Updated: {title}", "ru": " FAIL: {title} — content mismatch or empty!",
"zh": " Updated: {title}" "zh": " FAIL: {title} — content mismatch or empty!"
}, },
"API poll warning: {exc}": { " MISSING: devx {cmd}": {
"en": "API poll warning: {exc}", "en": " MISSING: devx {cmd}",
"bg": "API poll warning: {exc}", "bg": " ЛИПСВА: devx {cmd}",
"de": "API poll warning: {exc}", "de": " FEHLT: devx {cmd}",
"ru": "API poll warning: {exc}", "ru": " ОТСУТСТВУЕТ: devx {cmd}",
"zh": "API poll warning: {exc}" "zh": " 缺失: devx {cmd}"
}, },
"All molecule tests passed.": { " MISSING: {module}": {
"en": "All molecule tests passed.", "en": " MISSING: {module}",
"bg": "All molecule tests passed.", "bg": " MISSING: {module}",
"de": "All molecule tests passed.", "de": " MISSING: {module}",
"ru": "All molecule tests passed.", "ru": " MISSING: {module}",
"zh": "All molecule tests passed." "zh": " MISSING: {module}"
}, },
"Another molecule runner failed. Stopping this runner early.": { " MISSING: {script}": {
"en": "Another molecule runner failed. Stopping this runner early.", "en": " MISSING: {script}",
"bg": "Another molecule runner failed. Stopping this runner early.", "bg": " MISSING: {script}",
"de": "Another molecule runner failed. Stopping this runner early.", "de": " MISSING: {script}",
"ru": "Another molecule runner failed. Stopping this runner early.", "ru": " MISSING: {script}",
"zh": "Another molecule runner failed. Stopping this runner early." "zh": " MISSING: {script}"
}, },
"Bumping version: {current} -> v{new_version}": { " OK: devx {cmd}": {
"en": "Bumping version: {current} -> v{new_version}", "en": " OK: devx {cmd}",
"bg": "Bumping version: {current} -> v{new_version}", "bg": " ОК: devx {cmd}",
"de": "Bumping version: {current} -> v{new_version}", "de": " OK: devx {cmd}",
"ru": "Bumping version: {current} -> v{new_version}", "ru": " ОК: devx {cmd}",
"zh": "Bumping version: {current} -> v{new_version}" "zh": " 正常: devx {cmd}"
}, },
"Checking CLI command documentation...": { " OK: {module}": {
"en": "Checking CLI command documentation...", "en": " OK: {module}",
"bg": "Checking CLI command documentation...", "bg": " OK: {module}",
"de": "Checking CLI command documentation...", "de": " OK: {module}",
"ru": "Checking CLI command documentation...", "ru": " OK: {module}",
"zh": "Checking CLI command documentation..." "zh": " OK: {module}"
}, },
"Command failed ({cmd}): {stderr}": { " OK: {script}": {
"en": "Command failed ({cmd}): {stderr}", "en": " OK: {script}",
"bg": "Command failed ({cmd}): {stderr}", "bg": " OK: {script}",
"de": "Command failed ({cmd}): {stderr}", "de": " OK: {script}",
"ru": "Command failed ({cmd}): {stderr}", "ru": " OK: {script}",
"zh": "Command failed ({cmd}): {stderr}" "zh": " OK: {script}"
}, },
"Comparing {base}..{head} ({count} files changed)": { " OK: {title} ({chars} chars)": {
"en": "Comparing {base}..{head} ({count} files changed)", "en": " OK: {title} ({chars} chars)",
"bg": "Comparing {base}..{head} ({count} files changed)", "bg": " OK: {title} ({chars} chars)",
"de": "Comparing {base}..{head} ({count} files changed)", "de": " OK: {title} ({chars} chars)",
"ru": "Comparing {base}..{head} ({count} files changed)", "ru": " OK: {title} ({chars} chars)",
"zh": "Comparing {base}..{head} ({count} files changed)" "zh": " OK: {title} ({chars} chars)"
}, },
"Configuring branch protection for {branch}...": { " Updated: {title}": {
"en": "Configuring branch protection for {branch}...", "en": " Updated: {title}",
"bg": "Конфигуриране на защита на клона {branch}...", "bg": " Updated: {title}",
"de": "Konfiguriere Branch-Schutz für {branch}...", "de": " Updated: {title}",
"ru": "Настройка защиты ветки {branch}...", "ru": " Updated: {title}",
"zh": "正在配置 {branch} 的分支保护..." "zh": " Updated: {title}"
}, },
"Configuring repository settings...": { "=== Release Alignment Verification ===\n": {
"en": "Configuring repository settings...", "en": "=== Release Alignment Verification ===\n",
"bg": "Конфигуриране на настройките на хранилището...", "bg": "=== Release Alignment Verification ===\n",
"de": "Repository-Einstellungen konfigurieren...", "de": "=== Release Alignment Verification ===\n",
"ru": "Настройка параметров репозитория...", "ru": "=== Release Alignment Verification ===\n",
"zh": "正在配置仓库设置..." "zh": "=== Release Alignment Verification ===\n"
}, },
"Could not extract conventional commit message from PR commits.": { "API poll warning: {exc}": {
"en": "Could not extract conventional commit message from PR commits.", "en": "API poll warning: {exc}",
"bg": "Could not extract conventional commit message from PR commits.", "bg": "API poll warning: {exc}",
"de": "Could not extract conventional commit message from PR commits.", "de": "API poll warning: {exc}",
"ru": "Could not extract conventional commit message from PR commits.", "ru": "API poll warning: {exc}",
"zh": "Could not extract conventional commit message from PR commits." "zh": "API poll warning: {exc}"
}, },
"Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": { "All molecule tests passed.": {
"en": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.", "en": "All molecule tests passed.",
"bg": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.", "bg": "All molecule tests passed.",
"de": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.", "de": "All molecule tests passed.",
"ru": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.", "ru": "All molecule tests passed.",
"zh": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task." "zh": "All molecule tests passed."
}, },
"Could not find __version__ in {file}": { "Another molecule runner failed. Stopping this runner early.": {
"en": "Could not find __version__ in {file}", "en": "Another molecule runner failed. Stopping this runner early.",
"bg": "Could not find __version__ in {file}", "bg": "Another molecule runner failed. Stopping this runner early.",
"de": "Could not find __version__ in {file}", "de": "Another molecule runner failed. Stopping this runner early.",
"ru": "Could not find __version__ in {file}", "ru": "Another molecule runner failed. Stopping this runner early.",
"zh": "Could not find __version__ in {file}" "zh": "Another molecule runner failed. Stopping this runner early."
}, },
"Could not parse test execution time from output.": { "Bumping version: {current} -> v{new_version}": {
"en": "Could not parse test execution time from output.", "en": "Bumping version: {current} -> v{new_version}",
"bg": "Could not parse test execution time from output.", "bg": "Bumping version: {current} -> v{new_version}",
"de": "Could not parse test execution time from output.", "de": "Bumping version: {current} -> v{new_version}",
"ru": "Could not parse test execution time from output.", "ru": "Bumping version: {current} -> v{new_version}",
"zh": "Could not parse test execution time from output." "zh": "Bumping version: {current} -> v{new_version}"
}, },
"Created issue #{issue_id}: {title}": { "Checking CLI command documentation...": {
"en": "Created issue #{issue_id}: {title}", "en": "Checking CLI command documentation...",
"bg": "Created issue #{issue_id}: {title}", "bg": "Checking CLI command documentation...",
"de": "Created issue #{issue_id}: {title}", "de": "Checking CLI command documentation...",
"ru": "Created issue #{issue_id}: {title}", "ru": "Checking CLI command documentation...",
"zh": "Created issue #{issue_id}: {title}" "zh": "Checking CLI command documentation..."
}, },
"Created release commit.": { "Command failed ({cmd}): {stderr}": {
"en": "Created release commit.", "en": "Command failed ({cmd}): {stderr}",
"bg": "Created release commit.", "bg": "Command failed ({cmd}): {stderr}",
"de": "Created release commit.", "de": "Command failed ({cmd}): {stderr}",
"ru": "Created release commit.", "ru": "Command failed ({cmd}): {stderr}",
"zh": "Created release commit." "zh": "Command failed ({cmd}): {stderr}"
}, },
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": { "Comparing {base}..{head} ({count} files changed)": {
"en": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", "en": "Comparing {base}..{head} ({count} files changed)",
"bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", "bg": "Comparing {base}..{head} ({count} files changed)",
"de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", "de": "Comparing {base}..{head} ({count} files changed)",
"ru": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", "ru": "Comparing {base}..{head} ({count} files changed)",
"zh": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently." "zh": "Comparing {base}..{head} ({count} files changed)"
}, },
"ERROR: REPO_TOKEN is not set.": { "Configuring branch protection for {branch}...": {
"en": "ERROR: REPO_TOKEN is not set.", "en": "Configuring branch protection for {branch}...",
"bg": "ГРЕШКА: REPO_TOKEN не е зададен.", "bg": "Конфигуриране на защита на клона {branch}...",
"de": "FEHLER: REPO_TOKEN ist nicht gesetzt.", "de": "Konfiguriere Branch-Schutz für {branch}...",
"ru": "ОШИБКА: REPO_TOKEN не задан.", "ru": "Настройка защиты ветки {branch}...",
"zh": "错误:未设置 REPO_TOKEN。" "zh": "正在配置 {branch} 的分支保护..."
}, },
"ERROR: VIKUNJA_TOKEN is not set.": { "Configuring repository settings...": {
"en": "ERROR: VIKUNJA_TOKEN is not set.", "en": "Configuring repository settings...",
"bg": "ГРЕШКА: VIKUNJA_TOKEN не е зададен.", "bg": "Конфигуриране на настройките на хранилището...",
"de": "FEHLER: VIKUNJA_TOKEN ist nicht gesetzt.", "de": "Repository-Einstellungen konfigurieren...",
"ru": "ОШИБКА: VIKUNJA_TOKEN не задан.", "ru": "Настройка параметров репозитория...",
"zh": "错误:未设置 VIKUNJA_TOKEN。" "zh": "正在配置仓库设置..."
}, },
"ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.": { "Could not extract conventional commit message from PR commits.": {
"en": "ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.", "en": "Could not extract conventional commit message from PR commits.",
"bg": "ГРЕШКА: Името на хранилището не е указано. Използвайте --repo или задайте DEVX_REPO_NAME.", "bg": "Could not extract conventional commit message from PR commits.",
"de": "FEHLER: Repository-Name nicht angegeben. Verwenden Sie --repo oder setzen Sie DEVX_REPO_NAME.", "de": "Could not extract conventional commit message from PR commits.",
"ru": "ОШИБКА: Имя репозитория не указано. Используйте --repo или задайте DEVX_REPO_NAME.", "ru": "Could not extract conventional commit message from PR commits.",
"zh": "错误:未指定仓库名称。请使用 --repo 或设置 DEVX_REPO_NAME。" "zh": "Could not extract conventional commit message from PR commits."
}, },
"ERROR: mapping.json not found at {path}": { "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": {
"en": "ERROR: mapping.json not found at {path}", "en": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"bg": "ERROR: mapping.json not found at {path}", "bg": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"de": "ERROR: mapping.json not found at {path}", "de": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"ru": "ERROR: mapping.json not found at {path}", "ru": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"zh": "ERROR: mapping.json not found at {path}" "zh": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task."
}, },
"FAILED: {pair} exited with code {code}": { "Could not find __version__ in {file}": {
"en": "FAILED: {pair} exited with code {code}", "en": "Could not find __version__ in {file}",
"bg": "FAILED: {pair} exited with code {code}", "bg": "Could not find __version__ in {file}",
"de": "FAILED: {pair} exited with code {code}", "de": "Could not find __version__ in {file}",
"ru": "FAILED: {pair} exited with code {code}", "ru": "Could not find __version__ in {file}",
"zh": "FAILED: {pair} exited with code {code}" "zh": "Could not find __version__ in {file}"
}, },
"Failed to create issue via tea: {error}": { "Could not parse test execution time from output.": {
"en": "Failed to create issue via tea: {error}", "en": "Could not parse test execution time from output.",
"bg": "Failed to create issue via tea: {error}", "bg": "Could not parse test execution time from output.",
"de": "Failed to create issue via tea: {error}", "de": "Could not parse test execution time from output.",
"ru": "Failed to create issue via tea: {error}", "ru": "Could not parse test execution time from output.",
"zh": "Failed to create issue via tea: {error}" "zh": "Could not parse test execution time from output."
}, },
"Found {count} existing wiki pages.": { "Created issue #{issue_id}: {title}": {
"en": "Found {count} existing wiki pages.", "en": "Created issue #{issue_id}: {title}",
"bg": "Found {count} existing wiki pages.", "bg": "Created issue #{issue_id}: {title}",
"de": "Found {count} existing wiki pages.", "de": "Created issue #{issue_id}: {title}",
"ru": "Found {count} existing wiki pages.", "ru": "Created issue #{issue_id}: {title}",
"zh": "Found {count} existing wiki pages." "zh": "Created issue #{issue_id}: {title}"
}, },
"GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": { "Created release commit.": {
"en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "en": "Created release commit.",
"bg": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "bg": "Created release commit.",
"de": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "de": "Created release commit.",
"ru": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "ru": "Created release commit.",
"zh": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation." "zh": "Created release commit."
}, },
"HTTP error: {status} — {message}": { "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": {
"en": "HTTP error: {status} — {message}", "en": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"bg": "HTTP грешка: {status} — {message}", "bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"de": "HTTP-Fehler: {status} — {message}", "de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"ru": "Ошибка HTTP: {status} — {message}", "ru": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"zh": "HTTP 错误: {status} — {message}" "zh": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently."
}, },
"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.": { "ERROR: REPO_TOKEN is not set.": {
"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.", "en": "ERROR: REPO_TOKEN is not set.",
"bg": "HTTP {status} Забранено — вашият токен няма администраторски права.\nУверете се, че токенът принадлежи на собственик на хранилище или администратор на организация.\nАлтернативно, конфигурирайте защитата на клона ръчно в Настройки → Клонове.", "bg": "ГРЕШКА: REPO_TOKEN не е зададен.",
"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.", "de": "FEHLER: REPO_TOKEN ist nicht gesetzt.",
"ru": "HTTP {status} Запрещено — у вашего токена нет прав администратора.\nУбедитесь, что токен принадлежит владельцу репозитория или администратору организации.\nЛибо настройте защиту ветки вручную в разделе Настройки → Ветки.", "ru": "ОШИБКА: REPO_TOKEN не задан.",
"zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。" "zh": "错误:未设置 REPO_TOKEN。"
}, },
"Head branch is behind master. Pulling and rebasing...": { "ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.": {
"en": "Head branch is behind master. Pulling and rebasing...", "en": "ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.",
"bg": "Head branch is behind master. Pulling and rebasing...", "bg": "ГРЕШКА: Името на хранилището не е указано. Използвайте --repo или задайте DEVX_REPO_NAME.",
"de": "Head branch is behind master. Pulling and rebasing...", "de": "FEHLER: Repository-Name nicht angegeben. Verwenden Sie --repo oder setzen Sie DEVX_REPO_NAME.",
"ru": "Head branch is behind master. Pulling and rebasing...", "ru": "ОШИБКА: Имя репозитория не указано. Используйте --repo или задайте DEVX_REPO_NAME.",
"zh": "Head branch is behind master. Pulling and rebasing..." "zh": "错误:未指定仓库名称。请使用 --repo 或设置 DEVX_REPO_NAME。"
}, },
"Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": { "ERROR: Tag consistency check failed. Existing tags are misaligned:": {
"en": "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}", "en": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
"bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}", "bg": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
"de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}", "de": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
"ru": "Инфраструктурный коммит (без ID задачи DEVX-N), пропуск обновления Vikunja: {msg}", "ru": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
"zh": "基础设施提交(无 DEVX-N 任务 ID),跳过 Vikunja 更新: {msg}" "zh": "ERROR: Tag consistency check failed. Existing tags are misaligned:"
}, },
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}": { "ERROR: VIKUNJA_TOKEN is not set.": {
"en": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}", "en": "ERROR: VIKUNJA_TOKEN is not set.",
"bg": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}", "bg": "ГРЕШКА: VIKUNJA_TOKEN не е зададен.",
"de": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}", "de": "FEHLER: VIKUNJA_TOKEN ist nicht gesetzt.",
"ru": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}", "ru": "ОШИБКА: VIKUNJA_TOKEN не задан.",
"zh": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}" "zh": "错误:未设置 VIKUNJA_TOKEN。"
}, },
"Lint passed.": { "ERROR: mapping.json not found at {path}": {
"en": "Lint passed.", "en": "ERROR: mapping.json not found at {path}",
"bg": "Lint passed.", "bg": "ERROR: mapping.json not found at {path}",
"de": "Lint passed.", "de": "ERROR: mapping.json not found at {path}",
"ru": "Lint passed.", "ru": "ERROR: mapping.json not found at {path}",
"zh": "Lint passed." "zh": "ERROR: mapping.json not found at {path}"
}, },
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.": { "FAILED: {pair} exited with code {code}": {
"en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.", "en": "FAILED: {pair} exited with code {code}",
"bg": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.", "bg": "FAILED: {pair} exited with code {code}",
"de": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.", "de": "FAILED: {pair} exited with code {code}",
"ru": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.", "ru": "FAILED: {pair} exited with code {code}",
"zh": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually." "zh": "FAILED: {pair} exited with code {code}"
}, },
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": { "Failed to create issue via tea: {error}": {
"en": "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.", "en": "Failed to create issue via tea: {error}",
"bg": "Сливането неуспешно с HTTP {status}: {message}\nПроверете дали PR е готов и имате права за сливане.", "bg": "Failed to create issue via tea: {error}",
"de": "Merge fehlgeschlagen mit HTTP {status}: {message}\nBitte prüfen Sie, ob der PR bereit ist und Sie Merge-Rechte haben.", "de": "Failed to create issue via tea: {error}",
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.", "ru": "Failed to create issue via tea: {error}",
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。" "zh": "Failed to create issue via tea: {error}"
}, },
"Module {mod} has no main() function": { "Found {count} existing wiki pages.": {
"en": "Module {mod} has no main() function", "en": "Found {count} existing wiki pages.",
"bg": "Модул {mod} няма функция main()", "bg": "Found {count} existing wiki pages.",
"de": "Modul {mod} hat keine main()-Funktion", "de": "Found {count} existing wiki pages.",
"ru": "Модуль {mod} не имеет функции main()", "ru": "Found {count} existing wiki pages.",
"zh": "模块 {mod} 没有 main() 函数" "zh": "Found {count} existing wiki pages."
}, },
"Molecule directory not found: {path}": { "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
"en": "Molecule directory not found: {path}", "en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"bg": "Директорията на molecule не е намерена: {path}", "bg": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"de": "Molecule-Verzeichnis nicht gefunden: {path}", "de": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"ru": "Директория molecule не найдена: {path}", "ru": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"zh": "未找到 molecule 目录: {path}" "zh": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."
}, },
"Nice! Gitea release {tag} created.": { "Generated {file} with prefix '{prefix}'.": {
"en": "Nice! Gitea release {tag} created.", "en": "Generated {file} with prefix '{prefix}'.",
"bg": "Отлично! Gitea release {tag} е създаден.", "bg": "Generated {file} with prefix '{prefix}'.",
"de": "Prima! Gitea-Release {tag} erstellt.", "de": "Generated {file} with prefix '{prefix}'.",
"ru": "Отлично! Gitea release {tag} создан.", "ru": "Generated {file} with prefix '{prefix}'.",
"zh": "不错!Gitea release {tag} 已创建。" "zh": "Generated {file} with prefix '{prefix}'."
}, },
"Nice! PR #{pr_number} squash-merged with title: {merge_title}": { "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.": {
"en": "Nice! PR #{pr_number} squash-merged with title: {merge_title}", "en": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
"bg": "Отлично! PR #{pr_number} е squash-merge-нат със заглавие: {merge_title}", "bg": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
"de": "Prima! PR #{pr_number} wurde mit Titel {merge_title} squash-gemergt.", "de": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
"ru": "Отлично! PR #{pr_number} squash-merge с заголовком: {merge_title}", "ru": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
"zh": "不错!PR #{pr_number} 已 squash 合并,标题: {merge_title}" "zh": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag."
}, },
"Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.": { "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": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.", "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": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.", "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": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.", "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": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.", "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": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered." "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."
}, },
"Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.": { "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.": {
"en": "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.", "en": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
"bg": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) е обновена и маркирана като готова.", "bg": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
"de": "Prima! Vikunja-Aufgabe {task_id} (ID {vikunja_id}) aktualisiert und als erledigt markiert.", "de": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.", "ru": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。" "zh": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping."
}, },
"No changes between {base} and {head}.": { "HTTP error: {status} — {message}": {
"en": "No changes between {base} and {head}.", "en": "HTTP error: {status} — {message}",
"bg": "No changes between {base} and {head}.", "bg": "HTTP грешка: {status} — {message}",
"de": "No changes between {base} and {head}.", "de": "HTTP-Fehler: {status} — {message}",
"ru": "No changes between {base} and {head}.", "ru": "Ошибка HTTP: {status} — {message}",
"zh": "No changes between {base} and {head}." "zh": "HTTP 错误: {status} — {message}"
}, },
"No staged changes — version and changelog already up to date.": { "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": "No staged changes — version and changelog already up to date.", "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": "No staged changes — version and changelog already up to date.", "bg": "HTTP {status} Забранено — вашият токен няма администраторски права.\nУверете се, че токенът принадлежи на собственик на хранилище или администратор на организация.\nАлтернативно, конфигурирайте защитата на клона ръчно в Настройки → Клонове.",
"de": "No staged changes — version and changelog already up to date.", "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": "No staged changes — version and changelog already up to date.", "ru": "HTTP {status} Запрещено — у вашего токена нет прав администратора.\nУбедитесь, что токен принадлежит владельцу репозитория или администратору организации.\nЛибо настройте защиту ветки вручную в разделе Настройки → Ветки.",
"zh": "No staged changes — version and changelog already up to date." "zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。"
}, },
"No tags found — treating all changes as user-facing.": { "Head branch is behind master. Pulling and rebasing...": {
"en": "No tags found — treating all changes as user-facing.", "en": "Head branch is behind master. Pulling and rebasing...",
"bg": "No tags found — treating all changes as user-facing.", "bg": "Head branch is behind master. Pulling and rebasing...",
"de": "No tags found — treating all changes as user-facing.", "de": "Head branch is behind master. Pulling and rebasing...",
"ru": "No tags found — treating all changes as user-facing.", "ru": "Head branch is behind master. Pulling and rebasing...",
"zh": "No tags found — treating all changes as user-facing." "zh": "Head branch is behind master. Pulling and rebasing..."
}, },
"No unreleased changes found. Nothing to release.": { "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": {
"en": "No unreleased changes found. Nothing to release.", "en": "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}",
"bg": "No unreleased changes found. Nothing to release.", "bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}",
"de": "No unreleased changes found. Nothing to release.", "de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}",
"ru": "No unreleased changes found. Nothing to release.", "ru": "Инфраструктурный коммит (без ID задачи DEVX-N), пропуск обновления Vikunja: {msg}",
"zh": "No unreleased changes found. Nothing to release." "zh": "基础设施提交(无 DEVX-N 任务 ID),跳过 Vikunja 更新: {msg}"
}, },
"No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.": { "Lint failed — refusing to release. Fix lint errors first.\n{stderr}": {
"en": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.", "en": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"bg": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.", "bg": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"de": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.", "de": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"ru": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.", "ru": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"zh": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release." "zh": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}"
}, },
"Note: Self-approval not allowed. Posting COMMENT instead.": { "Lint passed.": {
"en": "Note: Self-approval not allowed. Posting COMMENT instead.", "en": "Lint passed.",
"bg": "Note: Self-approval not allowed. Posting COMMENT instead.", "bg": "Lint passed.",
"de": "Note: Self-approval not allowed. Posting COMMENT instead.", "de": "Lint passed.",
"ru": "Note: Self-approval not allowed. Posting COMMENT instead.", "ru": "Lint passed.",
"zh": "Note: Self-approval not allowed. Posting COMMENT instead." "zh": "Lint passed."
}, },
"Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE": { "Mapped file {file} is empty. Update the content or remove from mapping.json.": {
"en": "Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", "en": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"bg": "Опа! Съобщението за commit трябва да следва конвенционален формат.\n Очаква се: <type>: <description>\n Получено: {subject}\n Разрешени типове: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", "bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"de": "Ups! Commit-Nachricht muss dem konventionellen Commit-Format folgen.\n Erwartet: <type>: <description>\n Erhalten: {subject}\n Erlaubte Typen: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", "de": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"ru": "Ой! Сообщение коммита должно соответствовать формату conventional commit.\n Ожидается: <type>: <description>\n Получено: {subject}\n Допустимые типы: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", "ru": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"zh": "哎呀!提交消息必须遵循 conventional commit 格式。\n 预期格式: <type>: <description>\n 实际: {subject}\n 允许的类型: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE" "zh": "Mapped file {file} is empty. Update the content or remove from mapping.json."
}, },
"Oops! Do not include task ID (DEVX-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": { "Mapped file {file} not found. Update mapping.json or create the file.": {
"en": "Oops! Do not include task ID (DEVX-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.", "en": "Mapped file {file} not found. Update mapping.json or create the file.",
"bg": "Опа! Не включвайте идентификатор на задача (DEVX-N) в commit-и от feature клонове.\n Идентификаторът ще бъде добавен автоматично при сливане чрез CI.", "bg": "Mapped file {file} not found. Update mapping.json or create the file.",
"de": "Ups! Keine Task-ID (DEVX-N) in Feature-Branch-Commits einfügen.\n Die Task-ID wird beim Merge automatisch über CI hinzugefügt.", "de": "Mapped file {file} not found. Update mapping.json or create the file.",
"ru": "Ой! Не включайте ID задачи (DEVX-N) в коммиты feature-веток.\n ID задачи будет добавлен автоматически при слиянии через CI.", "ru": "Mapped file {file} not found. Update mapping.json or create the file.",
"zh": "哎呀!不要在 feature 分支的提交中包含任务 ID (DEVX-N)。\n 任务 ID 将在通过 CI 合并时自动添加。" "zh": "Mapped file {file} not found. Update mapping.json or create the file."
}, },
"Oops! Gitea PyPI registry publish failed:\n{stderr}": { "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.": {
"en": "Oops! Gitea PyPI registry publish failed:\n{stderr}", "en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"bg": "Опа! Публикуването в Gitea PyPI registry неуспешно:\n{stderr}", "bg": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"de": "Ups! Veröffentlichung in der Gitea PyPI-Registry fehlgeschlagen:\n{stderr}", "de": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"ru": "Ой! Публикация в Gitea PyPI registry не удалась:\n{stderr}", "ru": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"zh": "哎呀!Gitea PyPI registry 发布失败:\n{stderr}" "zh": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually."
}, },
"Oops! Master branch commit must follow conventional format after task ID.\n Expected: DEVX-N: <type>: <description>\n Got: {subject}": { "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": {
"en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: DEVX-N: <type>: <description>\n Got: {subject}", "en": "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.",
"bg": "Опа! Commit-ът в клона master трябва да следва конвенционален формат след идентификатора.\n Очаква се: DEVX-N: <type>: <description>\n Получено: {subject}", "bg": "Сливането неуспешно с HTTP {status}: {message}\nПроверете дали PR е готов и имате права за сливане.",
"de": "Ups! Master-Branch-Commit muss nach der Task-ID dem konventionellen Format folgen.\n Erwartet: DEVX-N: <type>: <description>\n Erhalten: {subject}", "de": "Merge fehlgeschlagen mit HTTP {status}: {message}\nBitte prüfen Sie, ob der PR bereit ist und Sie Merge-Rechte haben.",
"ru": "Ой! Коммит в ветку master после ID задачи должен соответствовать conventional формату.\n Ожидается: DEVX-N: <type>: <description>\n Получено: {subject}", "ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
"zh": "哎呀!master 分支提交在任务 ID 后必须遵循 conventional commit 格式。\n 预期格式: DEVX-N: <type>: <description>\n 实际: {subject}" "zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
}, },
"Oops! Master branch commits must start with a task ID.\n Expected: DEVX-N: <conventional commit message>\n Got: {subject}": { "Module {mod} has no main() function": {
"en": "Oops! Master branch commits must start with a task ID.\n Expected: DEVX-N: <conventional commit message>\n Got: {subject}", "en": "Module {mod} has no main() function",
"bg": "Опа! Commit-ите в клона master трябва да започват с идентификатор на задача.\n Очаква се: DEVX-N: <conventional commit message>\n Получено: {subject}", "bg": "Модул {mod} няма функция main()",
"de": "Ups! Master-Branch-Commits müssen mit einer Task-ID beginnen.\n Erwartet: DEVX-N: <conventional commit message>\n Erhalten: {subject}", "de": "Modul {mod} hat keine main()-Funktion",
"ru": "Ой! Коммиты в ветку master должны начинаться с ID задачи.\n Ожидается: DEVX-N: <conventional commit message>\n Получено: {subject}", "ru": "Модуль {mod} не имеет функции main()",
"zh": "哎呀!master 分支的提交必须以任务 ID 开头。\n 预期格式: DEVX-N: <conventional commit message>\n 实际: {subject}" "zh": "模块 {mod} 没有 main() 函数"
}, },
"Oops! No task ID found in .taskid file or branch name '{branch}'.": { "Molecule directory not found: {path}": {
"en": "Oops! No task ID found in .taskid file or branch name '{branch}'.", "en": "Molecule directory not found: {path}",
"bg": "Oops! No task ID found in .taskid file or branch name '{branch}'.", "bg": "Директорията на molecule не е намерена: {path}",
"de": "Oops! No task ID found in .taskid file or branch name '{branch}'.", "de": "Molecule-Verzeichnis nicht gefunden: {path}",
"ru": "Oops! No task ID found in .taskid file or branch name '{branch}'.", "ru": "Директория molecule не найдена: {path}",
"zh": "Oops! No task ID found in .taskid file or branch name '{branch}'." "zh": "未找到 molecule 目录: {path}"
}, },
"Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}": { "Nice! Gitea release {tag} created.": {
"en": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}", "en": "Nice! Gitea release {tag} created.",
"bg": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}", "bg": "Отлично! Gitea release {tag} е създаден.",
"de": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}", "de": "Prima! Gitea-Release {tag} erstellt.",
"ru": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}", "ru": "Отлично! Gitea release {tag} создан.",
"zh": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}" "zh": "不错!Gitea release {tag} 已创建。"
}, },
"Oops! Package build failed:\n{stderr}": { "Nice! PR #{pr_number} squash-merged with title: {merge_title}": {
"en": "Oops! Package build failed:\n{stderr}", "en": "Nice! PR #{pr_number} squash-merged with title: {merge_title}",
"bg": "Опа! Сборката на пакета неуспешна:\n{stderr}", "bg": "Отлично! PR #{pr_number} е squash-merge-нат със заглавие: {merge_title}",
"de": "Ups! Paket-Build fehlgeschlagen:\n{stderr}", "de": "Prima! PR #{pr_number} wurde mit Titel {merge_title} squash-gemergt.",
"ru": "Ой! Сборка пакета не удалась:\n{stderr}", "ru": "Отлично! PR #{pr_number} squash-merge с заголовком: {merge_title}",
"zh": "哎呀!包构建失败:\n{stderr}" "zh": "不错!PR #{pr_number} 已 squash 合并,标题: {merge_title}"
}, },
"Oops! PyPI publish failed:\n{stderr}": { "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.": {
"en": "Oops! PyPI publish failed:\n{stderr}", "en": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
"bg": "Опа! Публикуването в PyPI неуспешно:\n{stderr}", "bg": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
"de": "Ups! PyPI-Veröffentlichung fehlgeschlagen:\n{stderr}", "de": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}", "ru": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
"zh": "哎呀!PyPI 发布失败:\n{stderr}" "zh": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered."
}, },
"PASSED: {pair}": { "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.": {
"en": "PASSED: {pair}", "en": "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.",
"bg": "PASSED: {pair}", "bg": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) е обновена и маркирана като готова.",
"de": "PASSED: {pair}", "de": "Prima! Vikunja-Aufgabe {task_id} (ID {vikunja_id}) aktualisiert und als erledigt markiert.",
"ru": "PASSED: {pair}", "ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
"zh": "PASSED: {pair}" "zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
}, },
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": { "No changes between {base} and {head}.": {
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", "en": "No changes between {base} and {head}.",
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", "bg": "No changes between {base} and {head}.",
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", "de": "No changes between {base} and {head}.",
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", "ru": "No changes between {base} and {head}.",
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}" "zh": "No changes between {base} and {head}."
}, },
"PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.": { "No staged changes — version and changelog already up to date.": {
"en": "PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.", "en": "No staged changes — version and changelog already up to date.",
"bg": "PYPI_TOKEN не е зададен и няма конфигуриран URL на registry — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.", "bg": "No staged changes — version and changelog already up to date.",
"de": "PYPI_TOKEN nicht gesetzt und keine Registry-URL konfiguriert — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.", "de": "No staged changes — version and changelog already up to date.",
"ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.", "ru": "No staged changes — version and changelog already up to date.",
"zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。" "zh": "No staged changes — version and changelog already up to date."
}, },
"Published to Gitea PyPI registry.": { "No tags found — treating all changes as user-facing.": {
"en": "Published to Gitea PyPI registry.", "en": "No tags found — treating all changes as user-facing.",
"bg": "Публикувано в Gitea PyPI registry.", "bg": "No tags found — treating all changes as user-facing.",
"de": "In der Gitea PyPI-Registry veröffentlicht.", "de": "No tags found — treating all changes as user-facing.",
"ru": "Опубликовано в Gitea PyPI registry.", "ru": "No tags found — treating all changes as user-facing.",
"zh": "已发布到 Gitea PyPI registry。" "zh": "No tags found — treating all changes as user-facing."
}, },
"Published to PyPI.": { "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.": {
"en": "Published to PyPI.", "en": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"bg": "Публикувано в PyPI.", "bg": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"de": "In PyPI veröffentlicht.", "de": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"ru": "Опубликовано в PyPI.", "ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"zh": "已发布到 PyPI。" "zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID."
}, },
"Pushed release commit to master.": { "No unreleased changes found. Nothing to release.": {
"en": "Pushed release commit to master.", "en": "No unreleased changes found. Nothing to release.",
"bg": "Pushed release commit to master.", "bg": "No unreleased changes found. Nothing to release.",
"de": "Pushed release commit to master.", "de": "No unreleased changes found. Nothing to release.",
"ru": "Pushed release commit to master.", "ru": "No unreleased changes found. Nothing to release.",
"zh": "Pushed release commit to master." "zh": "No unreleased changes found. Nothing to release."
}, },
"Rebased and pushed. Retrying merge...": { "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.": {
"en": "Rebased and pushed. Retrying merge...", "en": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"bg": "Rebased and pushed. Retrying merge...", "bg": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"de": "Rebased and pushed. Retrying merge...", "de": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"ru": "Rebased and pushed. Retrying merge...", "ru": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"zh": "Rebased and pushed. Retrying merge..." "zh": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release."
}, },
"Release creation failed: {error}": { "Note: Self-approval not allowed. Posting COMMENT instead.": {
"en": "Release creation failed: {error}", "en": "Note: Self-approval not allowed. Posting COMMENT instead.",
"bg": "Release creation failed: {error}", "bg": "Note: Self-approval not allowed. Posting COMMENT instead.",
"de": "Release creation failed: {error}", "de": "Note: Self-approval not allowed. Posting COMMENT instead.",
"ru": "Release creation failed: {error}", "ru": "Note: Self-approval not allowed. Posting COMMENT instead.",
"zh": "Release creation failed: {error}" "zh": "Note: Self-approval not allowed. Posting COMMENT instead."
}, },
"Release must be run on master, currently on '{branch}'.": { "Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE": {
"en": "Release must be run on master, currently on '{branch}'.", "en": "Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"bg": "Release must be run on master, currently on '{branch}'.", "bg": "Опа! Съобщението за commit трябва да следва конвенционален формат.\n Очаква се: <type>: <description>\n Получено: {subject}\n Разрешени типове: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"de": "Release must be run on master, currently on '{branch}'.", "de": "Ups! Commit-Nachricht muss dem konventionellen Commit-Format folgen.\n Erwartet: <type>: <description>\n Erhalten: {subject}\n Erlaubte Typen: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"ru": "Release must be run on master, currently on '{branch}'.", "ru": "Ой! Сообщение коммита должно соответствовать формату conventional commit.\n Ожидается: <type>: <description>\n Получено: {subject}\n Допустимые типы: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"zh": "Release must be run on master, currently on '{branch}'." "zh": "哎呀!提交消息必须遵循 conventional commit 格式。\n 预期格式: <type>: <description>\n 实际: {subject}\n 允许的类型: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE"
}, },
"Repository configuration complete.": { "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": "Repository configuration complete.", "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": "Конфигурирането на хранилището е завършено.", "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": "Repository-Konfiguration abgeschlossen.", "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": "Конфигурация репозитория завершена.", "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": "仓库配置完成。" "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."
}, },
"Runner index {index} out of range (0..{max})": { "Oops! Gitea PyPI registry publish failed:\n{stderr}": {
"en": "Runner index {index} out of range (0..{max})", "en": "Oops! Gitea PyPI registry publish failed:\n{stderr}",
"bg": "Индексът на runner {index} е извън диапазона (0..{max})", "bg": "Опа! Публикуването в Gitea PyPI registry неуспешно:\n{stderr}",
"de": "Runner-Index {index} außerhalb des Bereichs (0..{max})", "de": "Ups! Veröffentlichung in der Gitea PyPI-Registry fehlgeschlagen:\n{stderr}",
"ru": "Индекс runner {index} вне диапазона (0..{max})", "ru": "Ой! Публикация в Gitea PyPI registry не удалась:\n{stderr}",
"zh": "Runner 索引 {index} 超出范围 (0..{max})" "zh": "哎呀!Gitea PyPI registry 发布失败:\n{stderr}"
}, },
"Running lint checks...": { "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}": {
"en": "Running lint checks...", "en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
"bg": "Running lint checks...", "bg": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
"de": "Running lint checks...", "de": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
"ru": "Running lint checks...", "ru": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
"zh": "Running lint checks..." "zh": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}"
}, },
"Running tests...": { "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}": {
"en": "Running tests...", "en": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
"bg": "Running tests...", "bg": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
"de": "Running tests...", "de": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
"ru": "Running tests...", "ru": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
"zh": "Running tests..." "zh": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}"
}, },
"Running: {scenario} on {platform}": { "Oops! No task ID found in .taskid file or branch name '{branch}'.": {
"en": "Running: {scenario} on {platform}", "en": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
"bg": "Running: {scenario} on {platform}", "bg": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
"de": "Running: {scenario} on {platform}", "de": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
"ru": "Running: {scenario} on {platform}", "ru": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
"zh": "Running: {scenario} on {platform}" "zh": "Oops! No task ID found in .taskid file or branch name '{branch}'."
}, },
"Skipping commit push — no staged changes.": { "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
"en": "Skipping commit push — no staged changes.", "en": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"bg": "Skipping commit push — no staged changes.", "bg": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"de": "Skipping commit push — no staged changes.", "de": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"ru": "Skipping commit push — no staged changes.", "ru": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"zh": "Skipping commit push — no staged changes." "zh": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}"
}, },
"Syncing {count} documentation pages to wiki...": { "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}": {
"en": "Syncing {count} documentation pages to wiki...", "en": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
"bg": "Syncing {count} documentation pages to wiki...", "bg": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
"de": "Syncing {count} documentation pages to wiki...", "de": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
"ru": "Syncing {count} documentation pages to wiki...", "ru": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
"zh": "Syncing {count} documentation pages to wiki..." "zh": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}"
}, },
"Tag v{version} already existed. Publish workflow should already have been triggered.": { "Oops! Package build failed:\n{stderr}": {
"en": "Tag v{version} already existed. Publish workflow should already have been triggered.", "en": "Oops! Package build failed:\n{stderr}",
"bg": "Tag v{version} already existed. Publish workflow should already have been triggered.", "bg": "Опа! Сборката на пакета неуспешна:\n{stderr}",
"de": "Tag v{version} already existed. Publish workflow should already have been triggered.", "de": "Ups! Paket-Build fehlgeschlagen:\n{stderr}",
"ru": "Tag v{version} already existed. Publish workflow should already have been triggered.", "ru": "Ой! Сборка пакета не удалась:\n{stderr}",
"zh": "Tag v{version} already existed. Publish workflow should already have been triggered." "zh": "哎呀!包构建失败:\n{stderr}"
}, },
"Tag {tag} already exists, skipping creation.": { "Oops! PyPI publish failed:\n{stderr}": {
"en": "Tag {tag} already exists, skipping creation.", "en": "Oops! PyPI publish failed:\n{stderr}",
"bg": "Tag {tag} already exists, skipping creation.", "bg": "Опа! Публикуването в PyPI неуспешно:\n{stderr}",
"de": "Tag {tag} already exists, skipping creation.", "de": "Ups! PyPI-Veröffentlichung fehlgeschlagen:\n{stderr}",
"ru": "Tag {tag} already exists, skipping creation.", "ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
"zh": "Tag {tag} already exists, skipping creation." "zh": "哎呀!PyPI 发布失败:\n{stderr}"
}, },
"Task ID: {task_id}": { "PASSED: {pair}": {
"en": "Task ID: {task_id}", "en": "PASSED: {pair}",
"bg": "Task ID: {task_id}", "bg": "PASSED: {pair}",
"de": "Task ID: {task_id}", "de": "PASSED: {pair}",
"ru": "Task ID: {task_id}", "ru": "PASSED: {pair}",
"zh": "Task ID: {task_id}" "zh": "PASSED: {pair}"
}, },
"Tests failed — refusing to release. Fix test failures first.\n{stderr}": { "PR number must be an integer, got: {pr_number}": {
"en": "Tests failed — refusing to release. Fix test failures first.\n{stderr}", "en": "PR number must be an integer, got: {pr_number}",
"bg": "Tests failed — refusing to release. Fix test failures first.\n{stderr}", "bg": "PR number must be an integer, got: {pr_number}",
"de": "Tests failed — refusing to release. Fix test failures first.\n{stderr}", "de": "PR number must be an integer, got: {pr_number}",
"ru": "Tests failed — refusing to release. Fix test failures first.\n{stderr}", "ru": "PR number must be an integer, got: {pr_number}",
"zh": "Tests failed — refusing to release. Fix test failures first.\n{stderr}" "zh": "PR number must be an integer, got: {pr_number}"
}, },
"Tests passed.": { "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": {
"en": "Tests passed.", "en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"bg": "Tests passed.", "bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"de": "Tests passed.", "de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"ru": "Tests passed.", "ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"zh": "Tests passed." "zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}"
}, },
"Unit tests passed in {duration:.2f}s (under {max}s limit).": { "PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.": {
"en": "Unit tests passed in {duration:.2f}s (under {max}s limit).", "en": "PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.",
"bg": "Unit tests passed in {duration:.2f}s (under {max}s limit).", "bg": "PYPI_TOKEN не е зададен и няма конфигуриран URL на registry — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.",
"de": "Unit tests passed in {duration:.2f}s (under {max}s limit).", "de": "PYPI_TOKEN nicht gesetzt und keine Registry-URL konfiguriert — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.",
"ru": "Unit tests passed in {duration:.2f}s (under {max}s limit).", "ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
"zh": "Unit tests passed in {duration:.2f}s (under {max}s limit)." "zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
}, },
"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.": { "Published to Gitea PyPI registry.": {
"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.", "en": "Published to Gitea PyPI registry.",
"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.", "bg": "Публикувано в Gitea PyPI registry.",
"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.", "de": "In der Gitea PyPI-Registry veröffentlicht.",
"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.", "ru": "Опубликовано в Gitea PyPI registry.",
"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." "zh": "已发布到 Gitea PyPI registry。"
}, },
"Updated version in {init}": { "Published to PyPI.": {
"en": "Updated version in {init}", "en": "Published to PyPI.",
"bg": "Updated version in {init}", "bg": "Публикувано в PyPI.",
"de": "Updated version in {init}", "de": "In PyPI veröffentlicht.",
"ru": "Updated version in {init}", "ru": "Опубликовано в PyPI.",
"zh": "Updated version in {init}" "zh": "已发布到 PyPI。"
}, },
"Updated {changelog_file}": { "Pushed release commit to master.": {
"en": "Updated {changelog_file}", "en": "Pushed release commit to master.",
"bg": "Updated {changelog_file}", "bg": "Pushed release commit to master.",
"de": "Updated {changelog_file}", "de": "Pushed release commit to master.",
"ru": "Updated {changelog_file}", "ru": "Pushed release commit to master.",
"zh": "Updated {changelog_file}" "zh": "Pushed release commit to master."
}, },
"WARNING: --skip-tests passed — skipping test verification.": { "Rebased and pushed. Retrying merge...": {
"en": "WARNING: --skip-tests passed — skipping test verification.", "en": "Rebased and pushed. Retrying merge...",
"bg": "WARNING: --skip-tests passed — skipping test verification.", "bg": "Rebased and pushed. Retrying merge...",
"de": "WARNING: --skip-tests passed — skipping test verification.", "de": "Rebased and pushed. Retrying merge...",
"ru": "WARNING: --skip-tests passed — skipping test verification.", "ru": "Rebased and pushed. Retrying merge...",
"zh": "WARNING: --skip-tests passed — skipping test verification." "zh": "Rebased and pushed. Retrying merge..."
}, },
"Wiki integrity check failed — {count} issue(s)": { "Release creation failed: {error}": {
"en": "Wiki integrity check failed — {count} issue(s)", "en": "Release creation failed: {error}",
"bg": "Wiki integrity check failed — {count} issue(s)", "bg": "Release creation failed: {error}",
"de": "Wiki integrity check failed — {count} issue(s)", "de": "Release creation failed: {error}",
"ru": "Wiki integrity check failed — {count} issue(s)", "ru": "Release creation failed: {error}",
"zh": "Wiki integrity check failed — {count} issue(s)" "zh": "Release creation failed: {error}"
}, },
"Wiki verification failed — {failures} page(s) empty or mismatched": { "Release must be run on master, currently on '{branch}'.": {
"en": "Wiki verification failed — {failures} page(s) empty or mismatched", "en": "Release must be run on master, currently on '{branch}'.",
"bg": "Wiki verification failed — {failures} page(s) empty or mismatched", "bg": "Release must be run on master, currently on '{branch}'.",
"de": "Wiki verification failed — {failures} page(s) empty or mismatched", "de": "Release must be run on master, currently on '{branch}'.",
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched", "ru": "Release must be run on master, currently on '{branch}'.",
"zh": "Wiki verification failed — {failures} page(s) empty or mismatched" "zh": "Release must be run on master, currently on '{branch}'."
}, },
"[dry-run] Would commit: release: v{version}": { "Repo must be in 'owner/name' format, got: {repo}": {
"en": "[dry-run] Would commit: release: v{version}", "en": "Repo must be in 'owner/name' format, got: {repo}",
"bg": "[dry-run] Would commit: release: v{version}", "bg": "Repo must be in 'owner/name' format, got: {repo}",
"de": "[dry-run] Would commit: release: v{version}", "de": "Repo must be in 'owner/name' format, got: {repo}",
"ru": "[dry-run] Would commit: release: v{version}", "ru": "Repo must be in 'owner/name' format, got: {repo}",
"zh": "[dry-run] Would commit: release: v{version}" "zh": "Repo must be in 'owner/name' format, got: {repo}"
}, },
"[dry-run] Would create tag: v{version}": { "Repository configuration complete.": {
"en": "[dry-run] Would create tag: v{version}", "en": "Repository configuration complete.",
"bg": "[dry-run] Would create tag: v{version}", "bg": "Конфигурирането на хранилището е завършено.",
"de": "[dry-run] Would create tag: v{version}", "de": "Repository-Konfiguration abgeschlossen.",
"ru": "[dry-run] Would create tag: v{version}", "ru": "Конфигурация репозитория завершена.",
"zh": "[dry-run] Would create tag: v{version}" "zh": "仓库配置完成。"
}, },
"[dry-run] Would create tag: {tag}": { "Runner index {index} out of range (0..{max})": {
"en": "[dry-run] Would create tag: {tag}", "en": "Runner index {index} out of range (0..{max})",
"bg": "[dry-run] Would create tag: {tag}", "bg": "Индексът на runner {index} е извън диапазона (0..{max})",
"de": "[dry-run] Would create tag: {tag}", "de": "Runner-Index {index} außerhalb des Bereichs (0..{max})",
"ru": "[dry-run] Would create tag: {tag}", "ru": "Индекс runner {index} вне диапазона (0..{max})",
"zh": "[dry-run] Would create tag: {tag}" "zh": "Runner 索引 {index} 超出范围 (0..{max})"
}, },
"[dry-run] Would push commit to master": { "Running lint checks...": {
"en": "[dry-run] Would push commit to master", "en": "Running lint checks...",
"bg": "[dry-run] Would push commit to master", "bg": "Running lint checks...",
"de": "[dry-run] Would push commit to master", "de": "Running lint checks...",
"ru": "[dry-run] Would push commit to master", "ru": "Running lint checks...",
"zh": "[dry-run] Would push commit to master" "zh": "Running lint checks..."
}, },
"[dry-run] Would sync page: {title} ({chars} chars)": { "Running tests...": {
"en": "[dry-run] Would sync page: {title} ({chars} chars)", "en": "Running tests...",
"bg": "[dry-run] Would sync page: {title} ({chars} chars)", "bg": "Running tests...",
"de": "[dry-run] Would sync page: {title} ({chars} chars)", "de": "Running tests...",
"ru": "[dry-run] Would sync page: {title} ({chars} chars)", "ru": "Running tests...",
"zh": "[dry-run] Would sync page: {title} ({chars} chars)" "zh": "Running tests..."
}, },
"[dry-run] Would update {changelog_file}": { "Running: {scenario} on {platform}": {
"en": "[dry-run] Would update {changelog_file}", "en": "Running: {scenario} on {platform}",
"bg": "[dry-run] Would update {changelog_file}", "bg": "Running: {scenario} on {platform}",
"de": "[dry-run] Would update {changelog_file}", "de": "Running: {scenario} on {platform}",
"ru": "[dry-run] Would update {changelog_file}", "ru": "Running: {scenario} on {platform}",
"zh": "[dry-run] Would update {changelog_file}" "zh": "Running: {scenario} on {platform}"
}, },
"[dry-run] Would update {init}": { "Skipping commit push — no staged changes.": {
"en": "[dry-run] Would update {init}", "en": "Skipping commit push — no staged changes.",
"bg": "[dry-run] Would update {init}", "bg": "Skipping commit push — no staged changes.",
"de": "[dry-run] Would update {init}", "de": "Skipping commit push — no staged changes.",
"ru": "[dry-run] Would update {init}", "ru": "Skipping commit push — no staged changes.",
"zh": "[dry-run] Would update {init}" "zh": "Skipping commit push — no staged changes."
}, },
"active": { "Syncing {count} documentation pages to wiki...": {
"en": "active", "en": "Syncing {count} documentation pages to wiki...",
"bg": "активен", "bg": "Syncing {count} documentation pages to wiki...",
"de": "aktiv", "de": "Syncing {count} documentation pages to wiki...",
"ru": "активен", "ru": "Syncing {count} documentation pages to wiki...",
"zh": "活跃" "zh": "Syncing {count} documentation pages to wiki..."
}, },
"completed": { "Tag consistency check failed.": {
"en": "completed", "en": "Tag consistency check failed.",
"bg": "завършен", "bg": "Tag consistency check failed.",
"de": "abgeschlossen", "de": "Tag consistency check failed.",
"ru": "завершён", "ru": "Tag consistency check failed.",
"zh": "已完成" "zh": "Tag consistency check failed."
}, },
"failed": { "Tag v{version} already existed. Publish workflow should already have been triggered.": {
"en": "failed", "en": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"bg": "неуспешен", "bg": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"de": "fehlgeschlagen", "de": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"ru": "неудачный", "ru": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"zh": "失败" "zh": "Tag v{version} already existed. Publish workflow should already have been triggered."
}, },
"git command failed ({cmd}): {stderr}": { "Tag {tag} already exists and points to HEAD. Skipping creation.": {
"en": "git command failed ({cmd}): {stderr}", "en": "Tag {tag} already exists and points to HEAD. Skipping creation.",
"bg": "git command failed ({cmd}): {stderr}", "bg": "Tag {tag} already exists and points to HEAD. Skipping creation.",
"de": "git command failed ({cmd}): {stderr}", "de": "Tag {tag} already exists and points to HEAD. Skipping creation.",
"ru": "git command failed ({cmd}): {stderr}", "ru": "Tag {tag} already exists and points to HEAD. Skipping creation.",
"zh": "git command failed ({cmd}): {stderr}" "zh": "Tag {tag} already exists and points to HEAD. Skipping creation."
}, },
"git-cliff returned empty version.": { "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": "git-cliff returned empty version.", "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": "git-cliff returned empty version.", "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": "git-cliff returned empty version.", "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": "git-cliff returned empty version.", "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": "git-cliff returned empty version." "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."
}, },
"inactive": { "Task ID: {task_id}": {
"en": "inactive", "en": "Task ID: {task_id}",
"bg": "неактивен", "bg": "Task ID: {task_id}",
"de": "inaktiv", "de": "Task ID: {task_id}",
"ru": "неактивен", "ru": "Task ID: {task_id}",
"zh": "未激活" "zh": "Task ID: {task_id}"
}, },
"in_progress": { "Tests failed — refusing to release. Fix test failures first.\n{stderr}": {
"en": "in progress", "en": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
"bg": "в процес", "bg": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
"de": "in Bearbeitung", "de": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
"ru": "в процессе", "ru": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
"zh": "进行中" "zh": "Tests failed — refusing to release. Fix test failures first.\n{stderr}"
}, },
"pending": { "Tests passed.": {
"en": "pending", "en": "Tests passed.",
"bg": "в очакване", "bg": "Tests passed.",
"de": "ausstehend", "de": "Tests passed.",
"ru": "ожидает", "ru": "Tests passed.",
"zh": "待处理" "zh": "Tests passed."
}, },
"unknown": { "Unit tests passed in {duration:.2f}s (under {max}s limit).": {
"en": "unknown", "en": "Unit tests passed in {duration:.2f}s (under {max}s limit).",
"bg": "неизвестен", "bg": "Unit tests passed in {duration:.2f}s (under {max}s limit).",
"de": "unbekannt", "de": "Unit tests passed in {duration:.2f}s (under {max}s limit).",
"ru": "неизвестно", "ru": "Unit tests passed in {duration:.2f}s (under {max}s limit).",
"zh": "未知" "zh": "Unit tests passed in {duration:.2f}s (under {max}s limit)."
}, },
"\n{label} files changed ({count}):": { "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": "\n{label} files changed ({count}):", "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": "\n{label} files changed ({count}):", "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": "\n{label} files changed ({count}):", "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": "\n{label} files changed ({count}):", "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": "\n{label} files changed ({count}):" "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."
}, },
"\n{tag} files ({count}):": { "Unknown check category '{check}'. Available: all, user-facing{tags}": {
"en": "\n{tag} files ({count}):", "en": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"bg": "\n{tag} files ({count}):", "bg": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"de": "\n{tag} files ({count}):", "de": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"ru": "\n{tag} files ({count}):", "ru": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"zh": "\n{tag} files ({count}):" "zh": "Unknown check category '{check}'. Available: all, user-facing{tags}"
}, },
"Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": { "Updated version in {init}": {
"en": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}", "en": "Updated version in {init}",
"bg": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}", "bg": "Updated version in {init}",
"de": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}", "de": "Updated version in {init}",
"ru": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}", "ru": "Updated version in {init}",
"zh": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}" "zh": "Updated version in {init}"
}, },
"Unknown check category '{check}'. Available: all, user-facing{tags}": { "Updated {changelog_file}": {
"en": "Unknown check category '{check}'. Available: all, user-facing{tags}", "en": "Updated {changelog_file}",
"bg": "Unknown check category '{check}'. Available: all, user-facing{tags}", "bg": "Updated {changelog_file}",
"de": "Unknown check category '{check}'. Available: all, user-facing{tags}", "de": "Updated {changelog_file}",
"ru": "Unknown check category '{check}'. Available: all, user-facing{tags}", "ru": "Updated {changelog_file}",
"zh": "Unknown check category '{check}'. Available: all, user-facing{tags}" "zh": "Updated {changelog_file}"
}, },
"HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.": { "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.": {
"en": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", "en": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"bg": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", "bg": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"de": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", "de": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"ru": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", "ru": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"zh": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag." "zh": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles."
}, },
"HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.": { "Version file: {file}": {
"en": "HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.", "en": "Version file: {file}",
"bg": "HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.", "bg": "Version file: {file}",
"de": "HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.", "de": "Version file: {file}",
"ru": "HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.", "ru": "Version file: {file}",
"zh": "HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping." "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.": { "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.", "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.", "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.", "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.", "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." "zh": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update."
}, },
"PR number must be an integer, got: {pr_number}": { "WARNING: --skip-tests passed — skipping test verification.": {
"en": "PR number must be an integer, got: {pr_number}", "en": "WARNING: --skip-tests passed — skipping test verification.",
"bg": "PR number must be an integer, got: {pr_number}", "bg": "WARNING: --skip-tests passed — skipping test verification.",
"de": "PR number must be an integer, got: {pr_number}", "de": "WARNING: --skip-tests passed — skipping test verification.",
"ru": "PR number must be an integer, got: {pr_number}", "ru": "WARNING: --skip-tests passed — skipping test verification.",
"zh": "PR number must be an integer, got: {pr_number}" "zh": "WARNING: --skip-tests passed — skipping test verification."
}, },
"git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.": { "Warning: could not fetch tags from origin.": {
"en": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", "en": "Warning: could not fetch tags from origin.",
"bg": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", "bg": "Warning: could not fetch tags from origin.",
"de": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", "de": "Warning: could not fetch tags from origin.",
"ru": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", "ru": "Warning: could not fetch tags from origin.",
"zh": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history." "zh": "Warning: could not fetch tags from origin."
}, },
"Repo must be in 'owner/name' format, got: {repo}": { "Wiki integrity check failed — {count} issue(s)": {
"en": "Repo must be in 'owner/name' format, got: {repo}", "en": "Wiki integrity check failed — {count} issue(s)",
"bg": "Repo must be in 'owner/name' format, got: {repo}", "bg": "Wiki integrity check failed — {count} issue(s)",
"de": "Repo must be in 'owner/name' format, got: {repo}", "de": "Wiki integrity check failed — {count} issue(s)",
"ru": "Repo must be in 'owner/name' format, got: {repo}", "ru": "Wiki integrity check failed — {count} issue(s)",
"zh": "Repo must be in 'owner/name' format, got: {repo}" "zh": "Wiki integrity check failed — {count} issue(s)"
}, },
"Mapped file {file} is empty. Update the content or remove from mapping.json.": { "Wiki verification failed — {failures} page(s) empty or mismatched": {
"en": "Mapped file {file} is empty. Update the content or remove from mapping.json.", "en": "Wiki verification failed — {failures} page(s) empty or mismatched",
"bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.", "bg": "Wiki verification failed — {failures} page(s) empty or mismatched",
"de": "Mapped file {file} is empty. Update the content or remove from mapping.json.", "de": "Wiki verification failed — {failures} page(s) empty or mismatched",
"ru": "Mapped file {file} is empty. Update the content or remove from mapping.json.", "ru": "Wiki verification failed — {failures} page(s) empty or mismatched",
"zh": "Mapped file {file} is empty. Update the content or remove from mapping.json." "zh": "Wiki verification failed — {failures} page(s) empty or mismatched"
}, },
"VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.": { "[dry-run] Would commit: release: v{version}": {
"en": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", "en": "[dry-run] Would commit: release: v{version}",
"bg": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", "bg": "[dry-run] Would commit: release: v{version}",
"de": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", "de": "[dry-run] Would commit: release: v{version}",
"ru": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", "ru": "[dry-run] Would commit: release: v{version}",
"zh": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles." "zh": "[dry-run] Would commit: release: v{version}"
}, },
"mapping.json keys and values must be strings, got {k}={v}": { "[dry-run] Would create tag: v{version}": {
"en": "mapping.json keys and values must be strings, got {k}={v}", "en": "[dry-run] Would create tag: v{version}",
"bg": "mapping.json keys and values must be strings, got {k}={v}", "bg": "[dry-run] Would create tag: v{version}",
"de": "mapping.json keys and values must be strings, got {k}={v}", "de": "[dry-run] Would create tag: v{version}",
"ru": "mapping.json keys and values must be strings, got {k}={v}", "ru": "[dry-run] Would create tag: v{version}",
"zh": "mapping.json keys and values must be strings, got {k}={v}" "zh": "[dry-run] Would create tag: v{version}"
}, },
"mapping.json must be a dict of file-path -> page-title, got {type}": { "[dry-run] Would create tag: {tag}": {
"en": "mapping.json must be a dict of file-path -> page-title, got {type}", "en": "[dry-run] Would create tag: {tag}",
"bg": "mapping.json must be a dict of file-path -> page-title, got {type}", "bg": "[dry-run] Would create tag: {tag}",
"de": "mapping.json must be a dict of file-path -> page-title, got {type}", "de": "[dry-run] Would create tag: {tag}",
"ru": "mapping.json must be a dict of file-path -> page-title, got {type}", "ru": "[dry-run] Would create tag: {tag}",
"zh": "mapping.json must be a dict of file-path -> page-title, got {type}" "zh": "[dry-run] Would create tag: {tag}"
}, },
"Mapped file {file} not found. Update mapping.json or create the file.": { "[dry-run] Would push commit to master": {
"en": "Mapped file {file} not found. Update mapping.json or create the file.", "en": "[dry-run] Would push commit to master",
"bg": "Mapped file {file} not found. Update mapping.json or create the file.", "bg": "[dry-run] Would push commit to master",
"de": "Mapped file {file} not found. Update mapping.json or create the file.", "de": "[dry-run] Would push commit to master",
"ru": "Mapped file {file} not found. Update mapping.json or create the file.", "ru": "[dry-run] Would push commit to master",
"zh": "Mapped file {file} not found. Update mapping.json or create the file." "zh": "[dry-run] Would push commit to master"
}, },
"No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.": { "[dry-run] Would sync page: {title} ({chars} chars)": {
"en": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", "en": "[dry-run] Would sync page: {title} ({chars} chars)",
"bg": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", "bg": "[dry-run] Would sync page: {title} ({chars} chars)",
"de": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", "de": "[dry-run] Would sync page: {title} ({chars} chars)",
"ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", "ru": "[dry-run] Would sync page: {title} ({chars} chars)",
"zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID." "zh": "[dry-run] Would sync page: {title} ({chars} chars)"
}, },
"git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).": { "[dry-run] Would update {changelog_file}": {
"en": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", "en": "[dry-run] Would update {changelog_file}",
"bg": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", "bg": "[dry-run] Would update {changelog_file}",
"de": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", "de": "[dry-run] Would update {changelog_file}",
"ru": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", "ru": "[dry-run] Would update {changelog_file}",
"zh": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1)." "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}"
}
} }
+33
View File
@@ -178,6 +178,39 @@ class TestClassifierConfig:
assert "tests/**" in DEFAULT_INFRASTRUCTURE assert "tests/**" in DEFAULT_INFRASTRUCTURE
assert "docs/**" in DEFAULT_INFRASTRUCTURE assert "docs/**" in DEFAULT_INFRASTRUCTURE
def test_default_infrastructure_covers_common_project_files(self) -> None:
"""DEFAULT_INFRASTRUCTURE must cover common project-level files
that are not part of the installed package.
This test prevents regression of the root cause of GRM-64
misclassification: 28 files (scripts/**, REVIEW_CHECKLIST.md)
were classified as user-facing because these patterns were
missing from the defaults.
"""
# Project documentation
assert "AGENTS.md" in DEFAULT_INFRASTRUCTURE
assert "README.md" in DEFAULT_INFRASTRUCTURE
assert "CHANGELOG.md" in DEFAULT_INFRASTRUCTURE
assert "TROUBLESHOOTING.md" in DEFAULT_INFRASTRUCTURE
assert "CONTRIBUTING.md" in DEFAULT_INFRASTRUCTURE
assert "CODE_OF_CONDUCT.md" in DEFAULT_INFRASTRUCTURE
assert "REVIEW_CHECKLIST.md" in DEFAULT_INFRASTRUCTURE
# Build tooling
assert "Makefile" in DEFAULT_INFRASTRUCTURE
assert "cliff.toml" in DEFAULT_INFRASTRUCTURE
assert "uv.lock" in DEFAULT_INFRASTRUCTURE
# Lint config
assert ".pre-commit-config.yaml" in DEFAULT_INFRASTRUCTURE
assert ".ruff.toml" in DEFAULT_INFRASTRUCTURE
assert ".ansible-lint" in DEFAULT_INFRASTRUCTURE
assert ".checkmake.ini" in DEFAULT_INFRASTRUCTURE
assert ".editorconfig" in DEFAULT_INFRASTRUCTURE
# Git config
assert ".gitignore" in DEFAULT_INFRASTRUCTURE
assert ".gitattributes" in DEFAULT_INFRASTRUCTURE
# Agent config
assert ".devin/**" in DEFAULT_INFRASTRUCTURE
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ChangeClassifier tests # ChangeClassifier tests
+30
View File
@@ -166,6 +166,13 @@ class TestToolsCommands:
assert result.exit_code == 0 assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.generate_badges", []) mock_run.assert_called_once_with("devx.tools.generate_badges", [])
@patch("devx.cli._run_module")
def test_tools_generate_cliff_config(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "generate-cliff-config"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.generate_cliff_config", [])
@patch("devx.cli._run_module") @patch("devx.cli._run_module")
def test_tools_install_checkmake(self, mock_run: MagicMock) -> None: def test_tools_install_checkmake(self, mock_run: MagicMock) -> None:
runner = CliRunner() runner = CliRunner()
@@ -218,6 +225,29 @@ class TestMoleculeCommands:
mock_run.assert_called_once_with("devx.molecule.molecule_all", []) 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: class TestRunModule:
@patch("importlib.import_module") @patch("importlib.import_module")
def test_run_module_success(self, mock_import: MagicMock) -> None: def test_run_module_success(self, mock_import: MagicMock) -> None:
+154
View File
@@ -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")
+200
View File
@@ -8,13 +8,19 @@ import pytest
from click.testing import CliRunner from click.testing import CliRunner
from devx.molecule.distribute_molecule import ( from devx.molecule.distribute_molecule import (
DEFAULT_ROLES_ROOT,
MOLECULE_ROOT, MOLECULE_ROOT,
PLATFORMS, PLATFORMS,
MultiRoleTestPair,
TestPair, TestPair,
build_multi_role_pairs,
build_pairs, build_pairs,
cli, cli,
discover_multi_role_scenarios,
discover_scenarios, discover_scenarios,
distribute, distribute,
distribute_multi_role,
multi_role_pairs_for_runner,
pairs_for_runner, pairs_for_runner,
) )
@@ -259,3 +265,197 @@ def test_main_module_block() -> None:
namespace = dict(dm.__dict__) namespace = dict(dm.__dict__)
exec(compile(source, dm.__file__, "exec"), namespace) exec(compile(source, dm.__file__, "exec"), namespace)
assert callable(namespace["cli"]) 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
+124
View File
@@ -0,0 +1,124 @@
"""Tests for devx.tools.generate_cliff_config."""
from __future__ import annotations
import tomllib
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from devx.tools.generate_cliff_config import main
class TestGenerateCliffConfig:
"""Tests for the generate_cliff_config tool."""
@pytest.fixture
def runner(self) -> CliRunner:
return CliRunner()
def test_generate_to_new_file(self, runner: CliRunner, tmp_path: Path) -> None:
"""Generate cliff.toml to a new file."""
output = tmp_path / "cliff.toml"
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
assert result.exit_code == 0
assert output.exists()
content = output.read_text()
assert "git-cliff configuration for GRM" in content
assert 'pattern = "^GRM-\\\\d+:\\\\s+"' in content
def test_generate_with_default_prefix(self, runner: CliRunner, tmp_path: Path) -> None:
"""Generate with default prefix (DEVX_TASK_PREFIX or 'DEVX')."""
output = tmp_path / "cliff.toml"
with patch("devx.tools.generate_cliff_config.TASK_PREFIX", "DEVX"):
result = runner.invoke(main, ["--output", str(output)])
assert result.exit_code == 0
content = output.read_text()
assert "git-cliff configuration for DEVX" in content
def test_existing_file_without_force(self, runner: CliRunner, tmp_path: Path) -> None:
"""Refuse to overwrite existing file without --force."""
output = tmp_path / "cliff.toml"
output.write_text("# existing")
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
assert result.exit_code != 0
assert "already exists" in result.output
assert output.read_text() == "# existing"
def test_existing_file_with_force(self, runner: CliRunner, tmp_path: Path) -> None:
"""Overwrite existing file with --force."""
output = tmp_path / "cliff.toml"
output.write_text("# existing")
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output), "--force"])
assert result.exit_code == 0
content = output.read_text()
assert "git-cliff configuration for GRM" in content
assert "# existing" not in content
def test_generated_config_is_valid_toml(self, runner: CliRunner, tmp_path: Path) -> None:
"""Generated config must be valid TOML."""
output = tmp_path / "cliff.toml"
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
assert result.exit_code == 0
with open(output, "rb") as f:
data = tomllib.load(f)
assert "changelog" in data
assert "git" in data
assert "bump" in data
assert data["bump"]["initial_tag"] == "0.1.0"
assert data["bump"]["features_always_bump_minor"] is True
def test_generated_config_has_correct_preprocessor(self, runner: CliRunner, tmp_path: Path) -> None:
"""Preprocessor pattern must match the given prefix."""
output = tmp_path / "cliff.toml"
result = runner.invoke(main, ["--prefix", "INFRA", "--output", str(output)])
assert result.exit_code == 0
with open(output, "rb") as f:
data = tomllib.load(f)
preprocessors = data["git"]["commit_preprocessors"]
assert len(preprocessors) == 1
pattern = preprocessors[0]["pattern"]
assert "INFRA" in pattern
def test_generated_config_has_commit_parsers(self, runner: CliRunner, tmp_path: Path) -> None:
"""Generated config must have all standard commit parsers."""
output = tmp_path / "cliff.toml"
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
assert result.exit_code == 0
with open(output, "rb") as f:
data = tomllib.load(f)
parsers = data["git"]["commit_parsers"]
# Should have feat, fix, perf, refactor, doc, test, style, chore, ci, release, security, revert, catch-all
messages = [p["message"] for p in parsers if "message" in p]
assert "^feat" in messages
assert "^fix" in messages
assert "^perf" in messages
assert "^refactor" in messages
assert "^release:" in messages
assert "^revert" in messages
assert ".*" in messages # catch-all
def test_default_output_path(self, runner: CliRunner, tmp_path: Path) -> None:
"""Default output path is cliff.toml in current directory."""
output = tmp_path / "cliff.toml"
# Change to tmp_path so default cliff.toml is created there
import os
old_cwd = os.getcwd()
os.chdir(tmp_path)
try:
result = runner.invoke(main, ["--prefix", "GRM"])
assert result.exit_code == 0
assert output.exists()
finally:
os.chdir(old_cwd)
def test_success_message(self, runner: CliRunner, tmp_path: Path) -> None:
"""Success message includes file and prefix."""
output = tmp_path / "cliff.toml"
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
assert result.exit_code == 0
assert "Generated" in result.output
assert "GRM" in result.output
+308
View File
@@ -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"])
+91
View File
@@ -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")
+243
View File
@@ -5,8 +5,11 @@ from __future__ import annotations
import os import os
import subprocess # nosec B404 import subprocess # nosec B404
import time import time
import xml.etree.ElementTree as ET
from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import click
import pytest import pytest
import requests import requests
@@ -16,7 +19,10 @@ from devx.molecule.molecule_ci_guard import (
build_molecule_cmd, build_molecule_cmd,
cli, cli,
get_running_jobs, get_running_jobs,
parse_pair,
poll_for_other_failures, poll_for_other_failures,
resolve_role_dir,
write_junit_report,
) )
@@ -435,3 +441,240 @@ def test_main_module_block() -> None:
namespace = dict(mg.__dict__) namespace = dict(mg.__dict__)
exec(compile(source, mg.__file__, "exec"), namespace) exec(compile(source, mg.__file__, "exec"), namespace)
assert callable(namespace["cli"]) 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 "")
+103 -1
View File
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch
from click.testing import CliRunner 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 from devx.gitea_cli import TeaCLIError
@@ -114,3 +114,105 @@ class TestNotifyFailure:
) )
assert result.exit_code != 0 assert result.exit_code != 0
assert "REPO_TOKEN" in result.output 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
+192
View File
@@ -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 == ""
+17
View File
@@ -300,3 +300,20 @@ class TestMain:
result = runner.invoke(main, ["v1.0.0", "owner/repo"]) result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1 assert result.exit_code == 1
assert "Release creation failed" in result.output 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()
+64
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -223,3 +224,66 @@ class TestMain:
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"]) result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
assert result.exit_code == 0 assert result.exit_code == 0
mock_update.assert_not_called() 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()
+705 -15
View File
@@ -9,9 +9,16 @@ from click.testing import CliRunner
from devx.ci.release import ( from devx.ci.release import (
commit_release_changes, commit_release_changes,
create_and_push_tag, create_and_push_tag,
fetch_tags,
get_all_tags,
get_bumped_version, get_bumped_version,
get_changelog, get_changelog,
get_changelog_versions,
get_commit_version,
get_head_commit,
get_init_version,
get_latest_tag, get_latest_tag,
get_tag_commit,
has_unreleased_changes, has_unreleased_changes,
main, main,
run_cmd, run_cmd,
@@ -19,6 +26,8 @@ from devx.ci.release import (
tag_exists, tag_exists,
update_changelog, update_changelog,
update_init_version, update_init_version,
verify_alignment,
verify_tag_consistency,
) )
@@ -44,14 +53,14 @@ class TestRunCmd:
class TestGetLatestTag: class TestGetLatestTag:
@patch("devx.ci.release.run_cmd") @patch("devx.ci._shared.subprocess.run")
def test_returns_tag(self, mock_run_cmd: MagicMock) -> None: def test_returns_tag(self, mock_run: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.1.0\n") mock_run.return_value = MagicMock(returncode=0, stdout="v0.1.0\n")
assert get_latest_tag() == "v0.1.0" assert get_latest_tag() == "v0.1.0"
@patch("devx.ci.release.run_cmd") @patch("devx.ci._shared.subprocess.run")
def test_no_tags_returns_empty(self, mock_run_cmd: MagicMock) -> None: def test_no_tags_returns_empty(self, mock_run: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="") mock_run.return_value = MagicMock(returncode=1, stdout="")
assert get_latest_tag() == "" assert get_latest_tag() == ""
@@ -156,6 +165,534 @@ class TestUpdateInitVersion:
update_init_version("0.2.0") update_init_version("0.2.0")
class TestGetTagCommit:
@patch("devx.ci.release.run_cmd")
def test_returns_commit(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="abc123\n", stderr="")
assert get_tag_commit("v0.1.0") == "abc123"
@patch("devx.ci.release.run_cmd")
def test_returns_empty_on_failure(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="err")
assert get_tag_commit("v0.1.0") == ""
class TestGetHeadCommit:
@patch("devx.ci.release.run_cmd")
def test_returns_head(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="def456\n", stderr="")
assert get_head_commit() == "def456"
class TestFetchTags:
@patch("devx.ci.release.run_cmd")
def test_success(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
fetch_tags()
@patch("devx.ci.release.run_cmd")
def test_failure_warns(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="err")
# Should not raise
fetch_tags()
class TestGetAllTags:
@patch("devx.ci.release.run_cmd")
def test_returns_tags(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.3.0\nv0.2.0\nv0.1.0\n", stderr="")
tags = get_all_tags()
assert tags == ["v0.3.0", "v0.2.0", "v0.1.0"]
@patch("devx.ci.release.run_cmd")
def test_empty(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="\n", stderr="")
assert get_all_tags() == []
@patch("devx.ci.release.run_cmd")
def test_failure_returns_empty(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="err")
assert get_all_tags() == []
class TestGetCommitVersion:
@patch("devx.ci.release.run_cmd")
def test_release_commit(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="release: v0.4.4 [skip ci]\n", stderr="")
assert get_commit_version("abc123") == "0.4.4"
@patch("devx.ci.release.run_cmd")
def test_non_release_commit(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="DEVX-9 feat: add thing\n", stderr="")
assert get_commit_version("abc123") is None
class TestVerifyTagConsistency:
@patch("devx.ci.release.get_commit_version")
@patch("devx.ci.release.get_all_tags")
def test_all_consistent(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None:
mock_tags.return_value = ["v0.2.0", "v0.1.0"]
mock_cv.side_effect = ["0.2.0", "0.1.0"]
errors = verify_tag_consistency()
assert errors == []
@patch("devx.ci.release.get_commit_version")
@patch("devx.ci.release.get_all_tags")
def test_tag_on_non_release_commit(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None:
# v0.1.0 is first (exempt), v0.2.0 is non-release (should error)
mock_tags.return_value = ["v0.2.0", "v0.1.0"]
mock_cv.side_effect = [None, "0.1.0"] # v0.2.0 non-release, v0.1.0 ok
errors = verify_tag_consistency()
assert len(errors) == 1
assert "non-release commit" in errors[0]
@patch("devx.ci.release.get_commit_version")
@patch("devx.ci.release.get_all_tags")
def test_first_tag_exempt_from_release_check(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None:
"""The first (oldest) tag is allowed to point to a non-release commit."""
mock_tags.return_value = ["v0.1.0"]
mock_cv.return_value = None # non-release commit
errors = verify_tag_consistency()
assert errors == [] # no error — first tag is exempt
@patch("devx.ci.release.get_commit_version")
@patch("devx.ci.release.get_all_tags")
def test_tag_version_mismatch(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None:
mock_tags.return_value = ["v0.2.0"]
mock_cv.return_value = "0.1.0"
errors = verify_tag_consistency()
assert len(errors) == 1
assert "0.1.0" in errors[0]
assert "0.2.0" in errors[0]
@patch("devx.ci.release.get_all_tags")
def test_no_tags(self, mock_tags: MagicMock) -> None:
mock_tags.return_value = []
assert verify_tag_consistency() == []
class TestGetInitVersion:
def test_returns_version(self, tmp_path, monkeypatch) -> None:
init_file = tmp_path / "__init__.py"
init_file.write_text('__version__ = "0.4.4"\n')
monkeypatch.setattr("devx.ci.release.INIT_FILE", str(init_file))
assert get_init_version() == "0.4.4"
def test_file_not_found(self, monkeypatch) -> None:
monkeypatch.setattr("devx.ci.release.INIT_FILE", "/nonexistent/path/__init__.py")
assert get_init_version() is None
def test_no_version_string(self, tmp_path, monkeypatch) -> None:
init_file = tmp_path / "__init__.py"
init_file.write_text('"""module"""\n')
monkeypatch.setattr("devx.ci.release.INIT_FILE", str(init_file))
assert get_init_version() is None
class TestGetChangelogVersions:
def test_returns_versions(self, tmp_path, monkeypatch) -> None:
changelog = tmp_path / "CHANGELOG.md"
changelog.write_text(
"# Changelog\n\n## [0.4.4] - 2026-06-21\n\n### Features\n- new\n\n"
"## [0.4.3] - 2026-06-20\n\n### Fixes\n- fix\n\n## [0.4.2] - 2026-06-19\n"
)
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", str(changelog))
versions = get_changelog_versions()
assert versions == ["0.4.4", "0.4.3", "0.4.2"]
def test_file_not_found(self, monkeypatch) -> None:
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", "/nonexistent/CHANGELOG.md")
assert get_changelog_versions() == []
class TestVerifyAlignment:
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_all_aligned(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify alignment passes when everything is consistent."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4", "v0.4.3"]
mock_vtc.return_value = [] # no tag errors
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.4", "0.4.3"]
# run_cmd is called for untagged release commits check
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 0
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_misaligned_tags(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify alignment fails when tags are misaligned."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = [" v0.1.0 → bad"]
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.4"]
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 1
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_version_mismatch(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify alignment fails when __version__ != latest tag."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = []
mock_iv.return_value = "0.4.3" # mismatch
mock_cv.return_value = ["0.4.4"]
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 1
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_changelog_duplicates(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify alignment fails when CHANGELOG has duplicate versions."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = []
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.4", "0.4.4"] # duplicate
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 1
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_changelog_out_of_order(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify alignment fails when CHANGELOG versions are not descending."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = []
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.3", "0.4.4"] # out of order
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 1
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_changelog_latest_mismatch(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify alignment fails when CHANGELOG latest != latest tag."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = []
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.3"] # doesn't match tag
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 1
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_changelog_unreleased_section(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify passes when CHANGELOG has one unreleased section ahead of tag."""
mock_lt.return_value = "v0.6.3"
mock_tags.return_value = ["v0.6.3", "v0.6.2"]
mock_vtc.return_value = []
mock_iv.return_value = "0.6.3"
mock_cv.return_value = ["0.6.4", "0.6.3"] # 0.6.4 is unreleased
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 0
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_changelog_tag_at_wrong_position(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify fails when latest tag is deep in CHANGELOG (not at position 0 or 1)."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = []
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.5.0", "0.4.5", "0.4.4"] # tag at position 2
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 1
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_duplicate_release_commits_info(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify reports duplicate release commits as info, not error."""
mock_lt.return_value = "v0.6.1"
mock_tags.return_value = ["v0.6.1"] # tag for 0.6.1 exists
mock_vtc.return_value = []
mock_iv.return_value = "0.6.1"
mock_cv.return_value = ["0.6.1"]
# git log finds 2 release commits for v0.6.1, neither has tag pointing at it
# (the tag points to a third commit)
commits = "abc123 release: v0.6.1 [skip ci]\ndef456 release: v0.6.1 [skip ci]\n"
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout=commits, stderr=""),
MagicMock(returncode=0, stdout="", stderr=""), # no tag at abc123
MagicMock(returncode=0, stdout="", stderr=""), # no tag at def456
]
# Should return 0 — duplicates are informational, not errors
assert verify_alignment() == 0
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_many_duplicate_release_commits(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify handles >5 duplicate release commits (truncation message)."""
mock_lt.return_value = "v0.6.1"
mock_tags.return_value = ["v0.6.1"]
mock_vtc.return_value = []
mock_iv.return_value = "0.6.1"
mock_cv.return_value = ["0.6.1"]
# Generate 7 duplicate release commits for v0.6.1
commits = "\n".join(f"abc{i:03d} release: v0.6.1 [skip ci]" for i in range(7))
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout=commits + "\n", stderr=""),
] + [MagicMock(returncode=0, stdout="", stderr="") for _ in range(7)]
assert verify_alignment() == 0
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_untagged_release_commits(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify alignment fails when there are untagged release commits."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = []
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.4"]
# git log finds release commits, then tag --points-at finds nothing
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="abc123 release: v0.3.0 [skip ci]\n", stderr=""),
MagicMock(returncode=0, stdout="", stderr=""), # no tags at abc123
]
assert verify_alignment() == 1
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_no_init_version(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify alignment fails when __version__ is not found."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = []
mock_iv.return_value = None # not found
mock_cv.return_value = ["0.4.4"]
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 1
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_all_release_commits_tagged(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify passes when all release commits have tags."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = []
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.4"]
# git log finds release commit, tag --points-at finds the tag
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="abc123 release: v0.4.4 [skip ci]\n", stderr=""),
MagicMock(returncode=0, stdout="v0.4.4\n", stderr=""), # tag found
]
assert verify_alignment() == 0
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_no_release_commits_found(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify handles case with no release commits at all."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = []
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.4"]
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="")
assert verify_alignment() == 0
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_many_untagged_release_commits(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify handles >10 untagged release commits (truncation message)."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = []
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.4"]
# Generate 15 untagged release commits
commits = "\n".join(f"abc{i:03d} release: v0.1.{i} [skip ci]" for i in range(15))
# First call returns all commits, subsequent calls return empty (no tags)
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout=commits + "\n", stderr=""),
] + [MagicMock(returncode=0, stdout="", stderr="") for _ in range(15)]
assert verify_alignment() == 1
class TestUpdateChangelog: class TestUpdateChangelog:
def test_creates_new_file(self, tmp_path, monkeypatch) -> None: def test_creates_new_file(self, tmp_path, monkeypatch) -> None:
changelog_file = tmp_path / "CHANGELOG.md" changelog_file = tmp_path / "CHANGELOG.md"
@@ -250,9 +787,17 @@ class TestCreateAndPushTag:
assert call.args[0][0:2] != ["git", "push"] assert call.args[0][0:2] != ["git", "push"]
assert call.args[0][0:2] != ["git", "tag"] assert call.args[0][0:2] != ["git", "tag"]
@patch("devx.ci.release.get_head_commit", return_value="abc123")
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
@patch("devx.ci.release.tag_exists", return_value=True) @patch("devx.ci.release.tag_exists", return_value=True)
@patch("devx.ci.release.run_cmd") @patch("devx.ci.release.run_cmd")
def test_tag_exists_skips_creation(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None: def test_tag_exists_skips_creation(
self,
mock_run_cmd: MagicMock,
mock_tag_exists: MagicMock,
mock_tag_commit: MagicMock,
mock_head_commit: MagicMock,
) -> None:
result = create_and_push_tag("0.1.0", "changelog", dry_run=False) result = create_and_push_tag("0.1.0", "changelog", dry_run=False)
assert result is False assert result is False
# Should not create tag, but should ensure it's pushed # Should not create tag, but should ensure it's pushed
@@ -260,13 +805,38 @@ class TestCreateAndPushTag:
assert ["git", "tag", "-a"] not in [c[:3] for c in calls] assert ["git", "tag", "-a"] not in [c[:3] for c in calls]
assert ["git", "push", "origin", "v0.1.0"] in calls assert ["git", "push", "origin", "v0.1.0"] in calls
@patch("devx.ci.release.get_head_commit", return_value="def456")
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
@patch("devx.ci.release.tag_exists", return_value=True) @patch("devx.ci.release.tag_exists", return_value=True)
@patch("devx.ci.release.run_cmd") @patch("devx.ci.release.run_cmd")
def test_tag_exists_dry_run_no_push(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None: def test_tag_exists_mismatch_raises(
self,
mock_run_cmd: MagicMock,
mock_tag_exists: MagicMock,
mock_tag_commit: MagicMock,
mock_head_commit: MagicMock,
) -> None:
"""Tag exists but points to different commit than HEAD → error."""
with pytest.raises(click.ClickException, match="misalignment"):
create_and_push_tag("0.1.0", "changelog", dry_run=False)
@patch("devx.ci.release.get_head_commit", return_value="abc123")
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
@patch("devx.ci.release.tag_exists", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_tag_exists_dry_run_no_push(
self,
mock_run_cmd: MagicMock,
mock_tag_exists: MagicMock,
mock_tag_commit: MagicMock,
mock_head_commit: MagicMock,
) -> None:
result = create_and_push_tag("0.1.0", "changelog", dry_run=True) result = create_and_push_tag("0.1.0", "changelog", dry_run=True)
assert result is False assert result is False
# No git commands at all in dry-run when tag exists # No push in dry-run when tag exists, but alignment check still runs
mock_run_cmd.assert_not_called() for call in mock_run_cmd.call_args_list:
assert call.args[0][0:2] != ["git", "push"]
assert call.args[0][0:2] != ["git", "tag"]
class TestRunTests: class TestRunTests:
@@ -302,6 +872,13 @@ class TestRunTests:
class TestMain: class TestMain:
"""Tests for the main release command.
All tests mock fetch_tags and verify_tag_consistency since these
are pre-flight checks that call git commands. Tests that need to
verify specific git call sequences mock run_cmd with side_effect.
"""
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("devx.ci.release.run_cmd") @patch("devx.ci.release.run_cmd")
def test_not_on_master_exits(self, mock_run_cmd: MagicMock) -> None: def test_not_on_master_exits(self, mock_run_cmd: MagicMock) -> None:
@@ -312,9 +889,13 @@ class TestMain:
assert "master" in result.output assert "master" in result.output
@patch.dict("os.environ", {}) @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.has_user_facing_changes", return_value=False)
@patch("devx.ci.release.run_cmd") @patch("devx.ci.release.run_cmd")
def test_dry_run_on_non_master_warns(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None: def test_dry_run_on_non_master_warns(
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.""" """Dry-run mode should not fail on non-master branches."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="") mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="")
runner = CliRunner() runner = CliRunner()
@@ -323,13 +904,22 @@ class TestMain:
assert "Dry-run mode" in result.output assert "Dry-run mode" in result.output
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("devx.ci.release.get_head_commit", return_value="abc123")
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True) @patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_cmd") @patch("devx.ci.release.run_cmd")
def test_release_lock_skips_when_head_is_release_commit_and_tag_exists( def test_release_lock_skips_when_head_is_release_commit_and_tag_exists(
self, mock_run_cmd: MagicMock, mock_uf: MagicMock self,
mock_run_cmd: MagicMock,
mock_uf: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
mock_tc: MagicMock,
mock_hc: MagicMock,
) -> None: ) -> None:
"""If HEAD is a release commit and the tag exists, skip.""" """If HEAD is a release commit and the tag exists, skip."""
# git rev-parse, git log -1, git tag -l (tag exists)
mock_run_cmd.side_effect = [ mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="master\n", stderr=""), MagicMock(returncode=0, stdout="master\n", stderr=""),
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""), MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
@@ -342,14 +932,47 @@ class TestMain:
assert "Skipping" in result.output assert "Skipping" in result.output
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("devx.ci.release.get_head_commit", return_value="def456")
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_release_lock_tag_points_elsewhere(
self,
mock_run_cmd: MagicMock,
mock_uf: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
mock_tc: MagicMock,
mock_hc: MagicMock,
) -> None:
"""If HEAD is a release commit but tag points elsewhere, error."""
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="master\n", stderr=""),
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
MagicMock(returncode=0, stdout="v0.5.0\n", stderr=""), # tag -l finds tag
]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code != 0
assert "misalignment" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.get_changelog", return_value="## changelog") @patch("devx.ci.release.get_changelog", return_value="## changelog")
@patch("devx.ci.release.create_and_push_tag", return_value=True) @patch("devx.ci.release.create_and_push_tag", return_value=True)
@patch("devx.ci.release.run_cmd") @patch("devx.ci.release.run_cmd")
def test_release_lock_recovers_when_tag_missing( def test_release_lock_recovers_when_tag_missing(
self, mock_run_cmd: MagicMock, mock_create_tag: MagicMock, mock_changelog: MagicMock self,
mock_run_cmd: MagicMock,
mock_create_tag: MagicMock,
mock_changelog: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None: ) -> None:
"""If HEAD is a release commit but the tag is missing, create the tag.""" """If HEAD is a release commit but the tag is missing, create the tag."""
# git rev-parse, git log -1, git tag -l (tag NOT found)
mock_run_cmd.side_effect = [ mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="master\n", stderr=""), MagicMock(returncode=0, stdout="master\n", stderr=""),
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""), MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
@@ -363,6 +986,8 @@ class TestMain:
mock_create_tag.assert_called_once_with("0.5.0", "## changelog", False) mock_create_tag.assert_called_once_with("0.5.0", "## changelog", False)
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True) @patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.has_unreleased_changes", return_value=False) @patch("devx.ci.release.has_unreleased_changes", return_value=False)
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0") @patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@@ -373,6 +998,8 @@ class TestMain:
mock_bumped: MagicMock, mock_bumped: MagicMock,
mock_has: MagicMock, mock_has: MagicMock,
mock_user: MagicMock, mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None: ) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner() runner = CliRunner()
@@ -381,6 +1008,7 @@ class TestMain:
assert "No unreleased changes" in result.output assert "No unreleased changes" in result.output
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.has_user_facing_changes", return_value=True) @patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.create_and_push_tag") @patch("devx.ci.release.create_and_push_tag")
@patch("devx.ci.release.commit_release_changes") @patch("devx.ci.release.commit_release_changes")
@@ -403,6 +1031,7 @@ class TestMain:
mock_commit: MagicMock, mock_commit: MagicMock,
mock_tag: MagicMock, mock_tag: MagicMock,
mock_user: MagicMock, mock_user: MagicMock,
mock_vtc: MagicMock,
) -> None: ) -> None:
"""Empty changelog should fail, not warn.""" """Empty changelog should fail, not warn."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
@@ -412,6 +1041,7 @@ class TestMain:
assert "empty changelog" in result.output.lower() assert "empty changelog" in result.output.lower()
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.has_user_facing_changes", return_value=True) @patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.create_and_push_tag") @patch("devx.ci.release.create_and_push_tag")
@patch("devx.ci.release.commit_release_changes") @patch("devx.ci.release.commit_release_changes")
@@ -434,6 +1064,7 @@ class TestMain:
mock_commit: MagicMock, mock_commit: MagicMock,
mock_tag: MagicMock, mock_tag: MagicMock,
mock_user: MagicMock, mock_user: MagicMock,
mock_vtc: MagicMock,
) -> None: ) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner() runner = CliRunner()
@@ -446,6 +1077,8 @@ class TestMain:
mock_tag.assert_not_called() mock_tag.assert_not_called()
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.get_latest_tag", return_value="v0.3.0") @patch("devx.ci.release.get_latest_tag", return_value="v0.3.0")
@patch("devx.ci.release.has_user_facing_changes", return_value=False) @patch("devx.ci.release.has_user_facing_changes", return_value=False)
@patch("devx.ci.release.run_cmd") @patch("devx.ci.release.run_cmd")
@@ -454,6 +1087,8 @@ class TestMain:
mock_run_cmd: MagicMock, mock_run_cmd: MagicMock,
mock_user_facing: MagicMock, mock_user_facing: MagicMock,
mock_latest: MagicMock, mock_latest: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None: ) -> None:
"""Release is skipped when only workflow/infra files changed.""" """Release is skipped when only workflow/infra files changed."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
@@ -464,6 +1099,8 @@ class TestMain:
assert "Skipping release" in result.output assert "Skipping release" in result.output
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True) @patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_tests") @patch("devx.ci.release.run_tests")
@patch("devx.ci.release.create_and_push_tag", return_value=True) @patch("devx.ci.release.create_and_push_tag", return_value=True)
@@ -488,6 +1125,8 @@ class TestMain:
mock_tag: MagicMock, mock_tag: MagicMock,
mock_run_tests: MagicMock, mock_run_tests: MagicMock,
mock_user: MagicMock, mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None: ) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner() runner = CliRunner()
@@ -501,6 +1140,8 @@ class TestMain:
mock_tag.assert_called_once_with("0.2.0", "changelog", False) mock_tag.assert_called_once_with("0.2.0", "changelog", False)
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True) @patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_tests") @patch("devx.ci.release.run_tests")
@patch("devx.ci.release.create_and_push_tag", return_value=False) @patch("devx.ci.release.create_and_push_tag", return_value=False)
@@ -525,6 +1166,8 @@ class TestMain:
mock_tag: MagicMock, mock_tag: MagicMock,
mock_run_tests: MagicMock, mock_run_tests: MagicMock,
mock_user: MagicMock, mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None: ) -> None:
"""When tag already exists, still update files but report existing tag.""" """When tag already exists, still update files but report existing tag."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
@@ -535,6 +1178,8 @@ class TestMain:
mock_tag.assert_called_once_with("0.1.0", "changelog", False) mock_tag.assert_called_once_with("0.1.0", "changelog", False)
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True) @patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.create_and_push_tag", return_value=True) @patch("devx.ci.release.create_and_push_tag", return_value=True)
@patch("devx.ci.release.commit_release_changes", return_value=True) @patch("devx.ci.release.commit_release_changes", return_value=True)
@@ -557,6 +1202,8 @@ class TestMain:
mock_commit: MagicMock, mock_commit: MagicMock,
mock_tag: MagicMock, mock_tag: MagicMock,
mock_user: MagicMock, mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None: ) -> None:
"""--skip-tests bypasses test verification.""" """--skip-tests bypasses test verification."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
@@ -569,6 +1216,8 @@ class TestMain:
assert make_calls == [] assert make_calls == []
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True) @patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.create_and_push_tag") @patch("devx.ci.release.create_and_push_tag")
@patch("devx.ci.release.commit_release_changes") @patch("devx.ci.release.commit_release_changes")
@@ -591,6 +1240,8 @@ class TestMain:
mock_commit: MagicMock, mock_commit: MagicMock,
mock_tag: MagicMock, mock_tag: MagicMock,
mock_user: MagicMock, mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None: ) -> None:
"""If tests fail, release aborts — no commit, no tag.""" """If tests fail, release aborts — no commit, no tag."""
# Calls: git rev-parse (master), git log -1 (release lock check), # Calls: git rev-parse (master), git log -1 (release lock check),
@@ -609,6 +1260,8 @@ class TestMain:
mock_tag.assert_not_called() mock_tag.assert_not_called()
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True) @patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.create_and_push_tag") @patch("devx.ci.release.create_and_push_tag")
@patch("devx.ci.release.commit_release_changes") @patch("devx.ci.release.commit_release_changes")
@@ -631,6 +1284,8 @@ class TestMain:
mock_commit: MagicMock, mock_commit: MagicMock,
mock_tag: MagicMock, mock_tag: MagicMock,
mock_user: MagicMock, mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None: ) -> None:
"""If lint fails, release aborts — no commit, no tag.""" """If lint fails, release aborts — no commit, no tag."""
# Calls: git rev-parse (master), git log -1 (release lock check), # Calls: git rev-parse (master), git log -1 (release lock check),
@@ -646,3 +1301,38 @@ class TestMain:
assert "Lint failed" in result.output assert "Lint failed" in result.output
mock_commit.assert_not_called() mock_commit.assert_not_called()
mock_tag.assert_not_called() mock_tag.assert_not_called()
@patch.dict("os.environ", {})
@patch("devx.ci.release.get_changelog_versions", return_value=[])
@patch("devx.ci.release.get_init_version", return_value="0.1.0")
@patch("devx.ci.release.get_all_tags", return_value=[])
@patch("devx.ci.release.get_latest_tag", return_value="")
@patch("devx.ci.release.run_cmd")
def test_verify_mode_no_tags(
self,
mock_run_cmd: MagicMock,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
) -> None:
"""--verify checks alignment and exits without releasing."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
runner = CliRunner()
result = runner.invoke(main, ["--verify"])
assert result.exit_code == 0
assert "Release Alignment Verification" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[" v0.1.0 → bad"])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.run_cmd")
def test_preflight_tag_consistency_fails(
self, mock_run_cmd: MagicMock, mock_ft: MagicMock, mock_vtc: MagicMock
) -> None:
"""Pre-flight tag consistency check aborts if tags are misaligned."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code != 0
assert "Tag consistency check failed" in result.output
+83
View File
@@ -152,6 +152,89 @@ class TestMain:
assert "task ID" in result.output assert "task ID" in result.output
class TestCustomPrefix:
"""Tests for custom task ID prefix (e.g., GRM-N instead of DEVX-N).
The prefix is configured via the DEVX_TASK_PREFIX environment variable.
This is critical for consumer projects like GRM that use their own
Vikunja project with a different identifier prefix.
"""
def _write_msg(self, content: str) -> str:
fd, path = tempfile.mkstemp()
with os.fdopen(fd, "w") as f:
f.write(content)
return path
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "GRM"})
def test_master_accepts_grm_prefix(self) -> None:
"""Master branch accepts GRM-N: prefix when DEVX_TASK_PREFIX=GRM."""
import importlib
import devx.ci.validate_commit_msg as vcm
import devx.config
importlib.reload(devx.config)
importlib.reload(vcm)
try:
msg_path = self._write_msg("GRM-66: fix: add scripts/** to infrastructure")
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
runner = CliRunner()
result = runner.invoke(vcm.main, [msg_path])
assert result.exit_code == 0
os.unlink(msg_path)
finally:
os.environ.pop("DEVX_TASK_PREFIX", None)
importlib.reload(devx.config)
importlib.reload(vcm)
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "GRM"})
def test_master_rejects_devx_prefix_when_grm_configured(self) -> None:
"""Master branch rejects DEVX-N: prefix when DEVX_TASK_PREFIX=GRM."""
import importlib
import devx.ci.validate_commit_msg as vcm
import devx.config
importlib.reload(devx.config)
importlib.reload(vcm)
try:
msg_path = self._write_msg("DEVX-8: fix: wrong prefix")
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
runner = CliRunner()
result = runner.invoke(vcm.main, [msg_path])
assert result.exit_code == 1
assert "GRM-N" in result.output
os.unlink(msg_path)
finally:
os.environ.pop("DEVX_TASK_PREFIX", None)
importlib.reload(devx.config)
importlib.reload(vcm)
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "GRM"})
def test_feature_branch_rejects_grm_prefix(self) -> None:
"""Feature branch rejects GRM-N: prefix when DEVX_TASK_PREFIX=GRM."""
import importlib
import devx.ci.validate_commit_msg as vcm
import devx.config
importlib.reload(devx.config)
importlib.reload(vcm)
try:
msg_path = self._write_msg("GRM-66: fix: should not have prefix on branch")
with patch("devx.ci.validate_commit_msg.get_branch", return_value="GRM-66-fix"):
runner = CliRunner()
result = runner.invoke(vcm.main, [msg_path])
assert result.exit_code == 1
assert "task ID" in result.output
os.unlink(msg_path)
finally:
os.environ.pop("DEVX_TASK_PREFIX", None)
importlib.reload(devx.config)
importlib.reload(vcm)
def test_main_module_block() -> None: def test_main_module_block() -> None:
import tempfile import tempfile