Compare commits

..
10 Commits
28 changed files with 1385 additions and 426 deletions
+1 -1
View File
@@ -1 +1 @@
DEVX-3 DEVX-8
+45 -16
View File
@@ -2,10 +2,49 @@
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.1.0] - 2026-06-22 ## [0.4.3] - 2026-06-22
## [0.1.0] - 2026-06-22 ### Bug Fixes
- Expand DEFAULT_INFRASTRUCTURE to cover all common project files
## [0.4.2] - 2026-06-22
### Bug Fixes
- Make all warnings into errors across devx tools
## [0.4.1] - 2026-06-22
### Bug Fixes
- Correct version tags, changelog, and release script recovery
## [0.4.0] - 2026-06-22
### Features
- Add DEFAULT_INFRASTRUCTURE and configurable task prefix
## [0.3.0] - 2026-06-22
### Features
- Add --no-ansible-collections option to setup tool
## [0.2.0] - 2026-06-22
### Features
- Pluggable change classification framework
## [0.1.2] - 2026-06-22
### Bug Fixes
- Make sync-wiki and vikunja depend on release
## [0.1.1] - 2026-06-22
### Bug Fixes
- 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 ## [0.1.0] - 2026-06-22
@@ -14,18 +53,8 @@ All notable changes to this project will be documented in this file.
- Extract reusable dev/CI tools from GRM into devx package - Extract reusable dev/CI tools from GRM into devx package
## [unreleased] ### Bug Fixes
### Features
- Extract reusable development and CI/CD tools from GRM into a standalone Python package
- Port core modules: config, exceptions, i18n, api_clients, gitea_cli
- Port 14 CI scripts: auto_merge, check_translations, classify_changes, detect_release_commit, discover_runners, doc_coverage, notify_failure, post_merge, pr_review, publish, push_badges, release, sync_wiki, validate_commit_msg
- Port 6 dev tools: check_test_speed, generate_badges, install_checkmake, install_tools, setup, configure_repo
- Port 5 molecule tools as optional extra: platforms, distribute_molecule, discover_runners, molecule_ci_guard, molecule_all
- Add CLI entry point with subcommands: devx ci, devx tools, devx molecule
- Add Gitea PyPI registry publishing support in publish.py
- Add configurable workflow-only patterns in classify_changes.py
- Add configurable version file path in release.py
- Replicate GRM's automated workflow: CI, auto-merge, post-merge, release, badges, wiki sync, Vikunja
- Use python3 and venv python in workflows and Makefile
- Fix post-merge job failures (configure-repo, badges, notify-failure)
- Allow release bot to push to protected master
+5 -2
View File
@@ -39,8 +39,8 @@ sort_commits = "oldest"
recurse_submodules = false recurse_submodules = false
commit_preprocessors = [ commit_preprocessors = [
# Strip DEVX-N task ID prefix from merge commits so git-cliff sees conventional commits # Strip DEVX-N: task ID prefix from squash-merge commits so git-cliff sees conventional commits
{ pattern = "^DEVX-\\d+\\s+", replace = "" }, { pattern = "^DEVX-\\d+:\\s+", replace = "" },
] ]
commit_parsers = [ commit_parsers = [
@@ -66,3 +66,6 @@ commit_parsers = [
features_always_bump_minor = true features_always_bump_minor = true
breaking_always_bump_major = false breaking_always_bump_major = false
initial_tag = "0.1.0" 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
+17 -27
View File
@@ -87,43 +87,33 @@ strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "s
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Change classification — determines which changes trigger a release # Change classification — determines which changes trigger a release
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Safe-by-default: any file NOT listed here defaults to user-facing # The framework provides DEFAULT_INFRASTRUCTURE (CI workflows, tests, docs,
# (requiring a release). This prevents new file types from silently # lint config, etc.) that applies to any Python project. We only specify
# skipping releases. # what's different about devx.
# #
# Rule priority (first match wins): # Rule priority (first match wins):
# 1. user_facing_overrides (safety — highest priority) # 1. user_facing_overrides (safety — highest priority)
# 2. infrastructure_overrides (explicit per-file) # 2. infrastructure_overrides (explicit per-file)
# 3. infrastructure (glob patterns) # 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns)
# 4. Default: user-facing (safe) # 4. Default: user-facing (safe)
[tool.devx.classify] [tool.devx.classify]
# Infrastructure paths — changes here don't trigger a release # use_defaults = true # (default) merge with DEFAULT_INFRASTRUCTURE
infrastructure = [
".gitea/**", # Project-specific infrastructure paths (merged with defaults).
"tests/**", # devx has no additional infrastructure paths — everything not in the
"docs/**", # defaults is user-facing (src/devx/**, pyproject.toml, translations.json).
"hooks/**", infrastructure = []
"Makefile",
"cliff.toml",
".pre-commit-config.yaml",
".env.example",
".gitignore",
".ruff.toml",
"AGENTS.md",
"README.md",
"CHANGELOG.md",
"TROUBLESHOOTING.md",
".ansible-lint",
".github/**",
]
# Infrastructure overrides — files that would default to user-facing # Infrastructure overrides — files that would default to user-facing
# but are actually infrastructure: # but are actually infrastructure:
# - __init__.py: only contains __version__ (release artifact, not code) # - __init__.py: only contains __version__ (set by release.py, not user code)
# - api_clients.py: used only by CI/CD scripts, not by the CLI #
# NOTE: api_clients.py is NOT here — it's used by devx's CI modules
# (auto_merge.py, release.py, pr_review.py, etc.) which consumer projects
# call via `python -m devx.ci.*`. Changes to api_clients.py affect consumer
# projects' CI behavior, so it IS user-facing.
infrastructure_overrides = [ infrastructure_overrides = [
"src/devx/__init__.py", "src/devx/__init__.py",
"src/devx/api_clients.py",
] ]
# User-facing overrides — safety override for broad infrastructure patterns # User-facing overrides — safety override for broad infrastructure patterns
@@ -133,4 +123,4 @@ user_facing_overrides = []
# Tag patterns — additional categories for CI conditional execution # Tag patterns — additional categories for CI conditional execution
# Orthogonal to release impact (user-facing vs infrastructure) # Orthogonal to release impact (user-facing vs infrastructure)
[tool.devx.classify.tags] [tool.devx.classify.tags]
ansible = ["ansible/**", ".ansible-lint"] # No tags needed for devx itself — it has no ansible/ directory
+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.1.0" __version__ = "0.4.3"
+2 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import time import time
from typing import Any from typing import Any
@@ -21,7 +22,7 @@ def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
try: try:
body: dict[str, Any] = response.json() if response is not None else {} body: dict[str, Any] = response.json() if response is not None else {}
message: str = body.get("message", str(e)) message: str = body.get("message", str(e))
except Exception: except (json.JSONDecodeError, ValueError, AttributeError):
message = str(e) message = str(e)
return status, message return status, message
+28 -19
View File
@@ -6,8 +6,11 @@ Runs as the final job in ci.yml. Reads the task ID from ``.taskid`` file
validates the PR title, and squash-merges with a conventional commit validates the PR title, and squash-merges with a conventional commit
message prefixed by the task ID. message prefixed by the task ID.
PR title format: ``DEVX-N: <vikunja task title>`` PR title format: ``{PREFIX}-N: <vikunja task title>``
Merge commit format: ``DEVX-N: <conventional commit message>`` Merge commit format: ``{PREFIX}-N <conventional commit message>``
The ``{PREFIX}`` is determined by ``DEVX_TASK_PREFIX`` (default: ``DEVX``).
Each project sets its own prefix (e.g., ``GRM``, ``INFRA``).
The conventional commit message is extracted from the PR commits. The conventional commit message is extracted from the PR commits.
This allows the PR title to be a human-friendly Vikunja task title This allows the PR title to be a human-friendly Vikunja task title
@@ -32,6 +35,7 @@ from devx.config import (
DEFAULT_PER_PAGE, DEFAULT_PER_PAGE,
GITEA_API_URL, GITEA_API_URL,
TASK_ID_RE, TASK_ID_RE,
TASK_PREFIX,
VIKUNJA_API_URL, VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID, VIKUNJA_PROJECT_ID,
) )
@@ -39,7 +43,7 @@ from devx.exceptions import APIError
from devx.i18n import _ from devx.i18n import _
TASKID_FILE = ".taskid" TASKID_FILE = ".taskid"
PR_TITLE_RE = re.compile(r"^DEVX-\d+:\s+.+") PR_TITLE_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+")
load_dotenv() load_dotenv()
@@ -84,14 +88,15 @@ def extract_task_id(branch: str) -> str:
def validate_pr_title(pr_title: str, task_id: str) -> None: def validate_pr_title(pr_title: str, task_id: str) -> None:
"""Raise ClickException if PR title does not follow the required format. """Raise ClickException if PR title does not follow the required format.
Expected: ``DEVX-N: <vikunja task title>`` Expected: ``{PREFIX}-N: <vikunja task title>``
""" """
if not PR_TITLE_RE.match(pr_title): if not PR_TITLE_RE.match(pr_title):
raise click.ClickException( raise click.ClickException(
_( _(
"Oops! PR title must follow format 'DEVX-N: <task title>'.\n" "Oops! PR title must follow format '{prefix}-N: <task title>'.\n"
" Expected: {task_id}: <task title>\n" " Expected: {task_id}: <task title>\n"
" Got: {pr_title}", " Got: {pr_title}",
prefix=TASK_PREFIX,
task_id=task_id, task_id=task_id,
pr_title=pr_title, pr_title=pr_title,
) )
@@ -109,12 +114,11 @@ def validate_pr_title(pr_title: str, task_id: str) -> None:
def get_vikunja_task_title(task_id: str) -> str: def get_vikunja_task_title(task_id: str) -> str:
"""Fetch the Vikunja task title for the given DEVX-N identifier. """Fetch the Vikunja task title for the given DEVX-N identifier.
Returns empty string if VIKUNJA_TOKEN is not set (local dev without token). Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found.
Raises ClickException if the token is set but the task is not found.
""" """
token = os.environ.get("VIKUNJA_TOKEN", "") token = os.environ.get("VIKUNJA_TOKEN", "")
if not token: if not token:
return "" raise click.ClickException(_("VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles."))
client = VikunjaClient(VIKUNJA_API_URL, token) client = VikunjaClient(VIKUNJA_API_URL, token)
page = 1 page = 1
while True: while True:
@@ -140,14 +144,10 @@ def get_vikunja_task_title(task_id: str) -> str:
def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None: def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None:
"""Validate that PR title matches the Vikunja task title. """Validate that PR title matches the Vikunja task title.
Skips validation if VIKUNJA_TOKEN is not set (local dev). Raises ClickException if VIKUNJA_TOKEN is not set, the task is not found,
Raises ClickException if the task is not found or the title doesn't match. or the title doesn't match.
""" """
vikunja_title = get_vikunja_task_title(task_id) vikunja_title = get_vikunja_task_title(task_id)
if not vikunja_title:
# VIKUNJA_TOKEN not set — skip validation (local dev)
click.echo(_("Warning: VIKUNJA_TOKEN not set, skipping title match validation."))
return
expected = f"{task_id}: {vikunja_title}" expected = f"{task_id}: {vikunja_title}"
if pr_title != expected: if pr_title != expected:
raise click.ClickException( raise click.ClickException(
@@ -188,7 +188,16 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
if not token: if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
owner, repo_name = repo.split("/") # Validate PR number is an integer
try:
pr_num = int(pr_number)
except ValueError:
raise click.ClickException(_("PR number must be an integer, got: {pr_number}", pr_number=pr_number)) from None
# Validate repo format
if "/" not in repo:
raise click.ClickException(_("Repo must be in 'owner/name' format, got: {repo}", repo=repo))
owner, repo_name = repo.split("/", 1)
client = GiteaClient(GITEA_API_URL, token, owner, repo_name) client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
task_id = read_taskid(branch) task_id = read_taskid(branch)
@@ -205,14 +214,14 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
validate_pr_title_matches_vikunja(pr_title, task_id) validate_pr_title_matches_vikunja(pr_title, task_id)
# Build merge title: DEVX-N: <conventional commit message> # Build merge title: DEVX-N: <conventional commit message>
commits = client.get_pr_commits(pr_number) commits = client.get_pr_commits(pr_num)
conv_msg = extract_conventional_msg(commits) conv_msg = extract_conventional_msg(commits)
if not conv_msg: if not conv_msg:
raise click.ClickException(_("Could not extract conventional commit message from PR commits.")) raise click.ClickException(_("Could not extract conventional commit message from PR commits."))
merge_title = f"{task_id}: {conv_msg}" merge_title = f"{task_id}: {conv_msg}"
try: try:
client.merge_pr(pr_number, merge_title) client.merge_pr(pr_num, merge_title)
except APIError as e: except APIError as e:
if e.status == 405 and "behind" in e.message.lower(): if e.status == 405 and "behind" in e.message.lower():
# Head branch is behind master — pull master and rebase, then retry # Head branch is behind master — pull master and rebase, then retry
@@ -224,7 +233,7 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
run_cmd(["git", "rebase", "origin/master"]) run_cmd(["git", "rebase", "origin/master"])
run_cmd(["git", "push", "--force-with-lease", "origin", f"HEAD:{branch}"]) run_cmd(["git", "push", "--force-with-lease", "origin", f"HEAD:{branch}"])
click.echo(_("Rebased and pushed. Retrying merge...")) click.echo(_("Rebased and pushed. Retrying merge..."))
client.merge_pr(pr_number, merge_title) client.merge_pr(pr_num, merge_title)
except (APIError, Exception) as retry_err: except (APIError, Exception) as retry_err:
raise click.ClickException( raise click.ClickException(
_( _(
@@ -245,7 +254,7 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
click.echo( click.echo(
_( _(
"Nice! PR #{pr_number} squash-merged with title: {merge_title}", "Nice! PR #{pr_number} squash-merged with title: {merge_title}",
pr_number=pr_number, pr_number=pr_num,
merge_title=merge_title, merge_title=merge_title,
) )
) )
+8 -17
View File
@@ -12,13 +12,13 @@ Checks performed (all fail with exit code 1 on error):
translations file. translations file.
- **Dead keys**: a key in a translations file is not used in any code. - **Dead keys**: a key in a translations file is not used in any code.
- **Missing languages**: a key exists but is missing one of the 5 supported - **Missing languages**: a key exists but is missing one of the 5 supported
languages (en, bg, de, ru, zh). Reported as a warning, not an error. languages (en, bg, de, ru, zh). This is an error — all supported languages
must have translations for every key.
Usage:: Usage::
python3 -m devx.ci.check_translations python3 -m devx.ci.check_translations
python3 -m devx.ci.check_translations --translations path/to/translations.json python3 -m devx.ci.check_translations --translations path/to/translations.json
python3 -m devx.ci.check_translations --strict # warnings are errors
""" """
from __future__ import annotations from __future__ import annotations
@@ -130,14 +130,15 @@ def check_translation_set(name: str, src_dir: Path, trans_file: Path) -> Transla
# Check for dead keys (in translations but not used in code) # Check for dead keys (in translations but not used in code)
result.dead_keys = result.defined_keys - result.used_keys result.dead_keys = result.defined_keys - result.used_keys
for key in sorted(result.dead_keys): for key in sorted(result.dead_keys):
result.warnings.append(f"Dead key in {name}: {key!r}") result.errors.append(f"Dead key in {name}: {key!r}")
# Check for missing languages # Check for missing languages — this is an error, not a warning.
# All supported languages must have translations for every key.
for key, langs in translations.items(): for key, langs in translations.items():
missing = [lang for lang in SUPPORTED_LANGS if lang not in langs] missing = [lang for lang in SUPPORTED_LANGS if lang not in langs]
if missing: if missing:
result.missing_langs[key] = missing result.missing_langs[key] = missing
result.warnings.append(f"Missing languages {missing} for key {key!r} in {name}") result.errors.append(f"Missing languages {missing} for key {key!r} in {name}")
return result return result
@@ -170,8 +171,7 @@ def print_result(result: TranslationCheckResult) -> None:
type=click.Path(exists=False, path_type=Path), type=click.Path(exists=False, path_type=Path),
help="Path to a translations JSON file to check (can be repeated). Defaults to src/devx/translations.json.", help="Path to a translations JSON file to check (can be repeated). Defaults to src/devx/translations.json.",
) )
@click.option("--strict", is_flag=True, default=False, help="Treat warnings as errors.") def main(translations: tuple[Path, ...]) -> None:
def main(translations: tuple[Path, ...], strict: bool) -> None:
"""Check translation files for gaps, dead keys, and missing languages.""" """Check translation files for gaps, dead keys, and missing languages."""
if not translations: if not translations:
# Default: check the devx package's own translations # Default: check the devx package's own translations
@@ -187,25 +187,16 @@ def main(translations: tuple[Path, ...], strict: bool) -> None:
results.append(check_translation_set(name, src_dir, trans_file)) results.append(check_translation_set(name, src_dir, trans_file))
has_errors = False has_errors = False
has_warnings = False
for result in results: for result in results:
print_result(result) print_result(result)
if result.errors: if result.errors:
has_errors = True has_errors = True
if result.warnings:
has_warnings = True
click.echo() click.echo()
if has_errors: if has_errors:
click.echo("FAIL: Translation check found errors.", err=True) click.echo("FAIL: Translation check found errors.", err=True)
sys.exit(1) sys.exit(1)
if strict and has_warnings: click.echo("PASS: All translations are complete and up to date.")
click.echo("FAIL: Translation check found warnings (--strict mode).", err=True)
sys.exit(1)
if has_warnings:
click.echo("PASS with warnings: Translation check passed (warnings present).")
else:
click.echo("PASS: All translations are complete and up to date.")
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
+172 -46
View File
@@ -15,6 +15,12 @@ affect the published package (user-facing) or only the CI/CD infrastructure
user-facing. This prevents new file types from accidentally skipping user-facing. This prevents new file types from accidentally skipping
releases — a critical safety property. When in doubt, release. releases — a critical safety property. When in doubt, release.
**Framework-provided defaults**: The framework ships with
``DEFAULT_INFRASTRUCTURE`` — a curated list of paths that are
infrastructure for ANY Python project (CI workflows, tests, docs,
lint config, etc.). Projects inherit these automatically and only
need to specify what's *different* about their project.
**Config-driven**: Classification rules are read from ``[tool.devx.classify]`` **Config-driven**: Classification rules are read from ``[tool.devx.classify]``
in ``pyproject.toml``. No project needs to modify the framework code. in ``pyproject.toml``. No project needs to modify the framework code.
Each project declares its own paths; the framework handles the logic. Each project declares its own paths; the framework handles the logic.
@@ -31,8 +37,9 @@ Each project declares its own paths; the framework handles the logic.
``__version__`` — a release artifact, not user-facing code). ``__version__`` — a release artifact, not user-facing code).
3. **Infrastructure patterns** (deny-list) 3. **Infrastructure patterns** (deny-list)
Path globs matching infrastructure files. Changes to these don't Path globs matching infrastructure files. This is the union of
trigger a release. Examples: ``.gitea/**``, ``tests/**``, ``docs/**``. ``DEFAULT_INFRASTRUCTURE`` and the project's ``infrastructure`` list.
Changes to these don't trigger a release.
4. **Default**: user-facing (lowest priority — safe default) 4. **Default**: user-facing (lowest priority — safe default)
@@ -41,27 +48,28 @@ Each project declares its own paths; the framework handles the logic.
conditional execution. A file can be both infrastructure (no release) conditional execution. A file can be both infrastructure (no release)
and tagged ``ansible`` (run molecule tests). Tags are evaluated and tagged ``ansible`` (run molecule tests). Tags are evaluated
independently of the user-facing/infrastructure classification. independently of the user-facing/infrastructure classification.
The ``--check`` CLI option accepts any tag name defined in the config,
and ``--github-output`` writes ``<tag>-changed`` for each configured tag.
== Configuration == == Configuration ==
In ``pyproject.toml``:: In ``pyproject.toml``::
[tool.devx.classify] [tool.devx.classify]
# Infrastructure paths — changes here don't trigger a release # Whether to merge with DEFAULT_INFRASTRUCTURE (default: true).
# Set to false to specify all patterns explicitly.
# use_defaults = true
# Project-specific infrastructure paths (merged with defaults).
# Only list paths NOT already in DEFAULT_INFRASTRUCTURE.
infrastructure = [ infrastructure = [
".gitea/**", "scripts/**", # e.g., if scripts/ is dev-only tooling
"tests/**",
"docs/**",
"Makefile",
"AGENTS.md",
"README.md",
"CHANGELOG.md",
] ]
# Infrastructure overrides — files that would default to user-facing # Infrastructure overrides — files that would default to user-facing
# but are actually infrastructure # but are actually infrastructure
infrastructure_overrides = [ infrastructure_overrides = [
"src/devx/__init__.py", "src/mypkg/__init__.py", # only contains __version__
] ]
# User-facing overrides — safety override for broad infrastructure patterns # User-facing overrides — safety override for broad infrastructure patterns
@@ -72,13 +80,36 @@ In ``pyproject.toml``::
[tool.devx.classify.tags] [tool.devx.classify.tags]
ansible = ["ansible/**", ".ansible-lint"] ansible = ["ansible/**", ".ansible-lint"]
== What counts as "user-facing" ==
A change is user-facing if it affects the behavior of the installed
package. For a library/CLI tool, this means:
- Source code in ``src/`` (except ``__init__.py`` which only holds
``__version__``)
- Package metadata (``pyproject.toml`` — dependencies, entry points)
- Ansible roles, playbooks, templates (if the project ships Ansible)
- Translation files (user-visible messages)
- Any file not explicitly classified as infrastructure
A change is infrastructure if it only affects the project's own
development/CI environment:
- CI/CD workflows (``.gitea/**``, ``.github/**``)
- Tests (``tests/**``)
- Documentation (``docs/**``, ``README.md``, ``CHANGELOG.md``)
- Linting/formatting config (``.ruff.toml``, ``.pre-commit-config.yaml``)
- Build tooling (``Makefile``, ``cliff.toml``)
- Git hooks (``hooks/**``)
- Generated scripts (``activate.sh``, ``activate.fish``, ``activate.zsh``)
== Glob Syntax == == Glob Syntax ==
Patterns support standard glob syntax: Patterns support standard glob syntax:
- ``**`` matches any number of path segments (including zero) - ``**`` matches any number of path segments (including zero)
- ``*`` matches any characters within a single path segment - ``*`` matches any characters within a single path segment
- ``?`` matches a single character within a path segment - ``?`` matches a single character within a single path segment
- Everything else is matched literally - Everything else is matched literally
Examples: Examples:
@@ -90,6 +121,8 @@ Examples:
Usage: Usage:
python3 -m devx.ci.classify_changes [--base <ref>] [--head <ref>] python3 -m devx.ci.classify_changes [--base <ref>] [--head <ref>]
python3 -m devx.ci.classify_changes --base v0.3.0 --head HEAD python3 -m devx.ci.classify_changes --base v0.3.0 --head HEAD
python3 -m devx.ci.classify_changes --check ansible --quiet
python3 -m devx.ci.classify_changes --github-output
""" """
from __future__ import annotations from __future__ import annotations
@@ -167,9 +200,9 @@ def _glob_to_regex(pattern: str) -> re.Pattern[str]:
"""Convert a glob pattern to a compiled regex. """Convert a glob pattern to a compiled regex.
Supports: Supports:
- ``**`` matches any number of path segments (including zero) - ``**`` -> matches any number of path segments (including zero)
- ``*`` matches any chars within a single path segment - ``*`` -> matches any chars within a single path segment
- ``?`` matches a single char within a path segment - ``?`` -> matches a single char within a path segment
- All other characters are matched literally - All other characters are matched literally
""" """
# Handle ** at the end (e.g., ".gitea/**") # Handle ** at the end (e.g., ".gitea/**")
@@ -214,45 +247,123 @@ def _matches_glob(file_path: str, pattern: str) -> bool:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Default infrastructure patterns
# ---------------------------------------------------------------------------
# Common infrastructure paths that apply to ANY Python project using devx.
# Projects inherit these automatically and only need to specify project-specific
# paths in their [tool.devx.classify] section.
#
# Rationale: these files/directories are development tooling, CI/CD config,
# or generated artifacts. Changes to them don't affect the installed package's
# behavior, so they don't warrant a release.
DEFAULT_INFRASTRUCTURE: list[str] = [
# CI/CD workflow definitions
".gitea/**",
".github/**",
# Test files
"tests/**",
# Documentation
"docs/**",
# Git hooks
"hooks/**",
# Build tooling
"Makefile",
"cliff.toml",
"uv.lock",
# Linting / formatting config
".pre-commit-config.yaml",
".ruff.toml",
".ansible-lint",
".checkmake.ini",
".editorconfig",
# Environment templates (not the actual .env which is gitignored)
".env.example",
# Git config
".gitignore",
".gitattributes",
# Project-level documentation (not part of the installed package)
"AGENTS.md",
"README.md",
"CHANGELOG.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`)
"activate.sh",
"activate.fish",
"activate.zsh",
# CI task tracking file (written by CI, not by developers)
".taskid",
]
@dataclass @dataclass
class ClassifierConfig: class ClassifierConfig:
"""Configuration for the change classifier. """Configuration for the change classifier.
Loaded from ``[tool.devx.classify]`` in ``pyproject.toml``. Loaded from ``[tool.devx.classify]`` in ``pyproject.toml``.
By default, the framework's ``DEFAULT_INFRASTRUCTURE`` patterns are
merged with the project's ``infrastructure`` list. Set
``use_defaults = false`` to disable defaults and specify all
patterns explicitly.
Attributes: Attributes:
infrastructure: Glob patterns for infrastructure paths. infrastructure: Glob patterns for infrastructure paths
(merged with DEFAULT_INFRASTRUCTURE unless use_defaults is False).
infrastructure_overrides: Exact paths that are infrastructure infrastructure_overrides: Exact paths that are infrastructure
despite not matching any infrastructure pattern. despite not matching any infrastructure pattern.
user_facing_overrides: Exact paths that are user-facing user_facing_overrides: Exact paths that are user-facing
despite matching an infrastructure pattern (safety override). despite matching an infrastructure pattern (safety override).
tags: Dict mapping tag name to list of glob patterns. tags: Dict mapping tag name to list of glob patterns.
use_defaults: If True (default), merge with DEFAULT_INFRASTRUCTURE.
""" """
infrastructure: list[str] = field(default_factory=list) infrastructure: list[str] = field(default_factory=list)
infrastructure_overrides: list[str] = field(default_factory=list) infrastructure_overrides: list[str] = field(default_factory=list)
user_facing_overrides: list[str] = field(default_factory=list) user_facing_overrides: list[str] = field(default_factory=list)
tags: dict[str, list[str]] = field(default_factory=dict) tags: dict[str, list[str]] = field(default_factory=dict)
use_defaults: bool = True
@classmethod @classmethod
def from_pyproject(cls, pyproject_path: str = "pyproject.toml") -> ClassifierConfig: def from_pyproject(cls, pyproject_path: str = "pyproject.toml") -> ClassifierConfig:
"""Load classifier config from pyproject.toml. """Load classifier config from pyproject.toml.
Reads the ``[tool.devx.classify]`` section. If the section or Reads the ``[tool.devx.classify]`` section. If the section or
file is missing, returns a config with empty lists (everything file is missing, returns a config with only DEFAULT_INFRASTRUCTURE
defaults to user-facing — safe-by-default). (everything else defaults to user-facing — safe-by-default).
""" """
path = Path(pyproject_path) path = Path(pyproject_path)
if not path.exists(): if not path.exists():
return cls() return cls(infrastructure=list(DEFAULT_INFRASTRUCTURE))
with open(path, "rb") as f: # noqa: PTH123 with open(path, "rb") as f: # noqa: PTH123
data: dict[str, Any] = tomllib.load(f) data: dict[str, Any] = tomllib.load(f)
classify_cfg = data.get("tool", {}).get("devx", {}).get("classify", {}) classify_cfg = data.get("tool", {}).get("devx", {}).get("classify", {})
use_defaults = classify_cfg.get("use_defaults", True)
project_infra = list(classify_cfg.get("infrastructure", []))
if use_defaults:
# Merge defaults with project-specific patterns (deduplicated)
merged = list(DEFAULT_INFRASTRUCTURE)
for p in project_infra:
if p not in merged:
merged.append(p)
infrastructure = merged
else:
infrastructure = project_infra
return cls( return cls(
infrastructure=list(classify_cfg.get("infrastructure", [])), infrastructure=infrastructure,
infrastructure_overrides=list(classify_cfg.get("infrastructure_overrides", [])), infrastructure_overrides=list(classify_cfg.get("infrastructure_overrides", [])),
user_facing_overrides=list(classify_cfg.get("user_facing_overrides", [])), user_facing_overrides=list(classify_cfg.get("user_facing_overrides", [])),
tags={k: list(v) for k, v in classify_cfg.get("tags", {}).items()}, tags={k: list(v) for k, v in classify_cfg.get("tags", {}).items()},
use_defaults=use_defaults,
) )
@@ -506,27 +617,30 @@ def _write_github_output(key: str, value: str) -> None:
@click.option("--quiet", is_flag=True, default=False, help="Only output true/false.") @click.option("--quiet", is_flag=True, default=False, help="Only output true/false.")
@click.option( @click.option(
"--check", "--check",
type=click.Choice(["all", "ansible", "user-facing"]),
default="all", default="all",
help="Check specific category: all (default), ansible, or user-facing.", help="Check specific category: 'all' (default), 'user-facing', or any tag name "
"defined in [tool.devx.classify.tags] (e.g., 'ansible').",
) )
@click.option( @click.option(
"--github-output", "--github-output",
"github_output", "github_output",
is_flag=True, is_flag=True,
default=False, default=False,
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).", help="Write results to $GITHUB_OUTPUT file (for CI workflow steps). "
"Outputs 'user-facing-changed' and '<tag>-changed' for each configured tag.",
) )
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None: def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None:
"""Classify git changes and output results.""" """Classify git changes and output results."""
classifier = _get_classifier() classifier = _get_classifier()
available_tags = list(classifier.config.tags.keys())
if base is None: if base is None:
base = get_latest_tag() base = get_latest_tag()
if not base: if not base:
if github_output: if github_output:
_write_github_output("ansible-changed", "true")
_write_github_output("user-facing-changed", "true") _write_github_output("user-facing-changed", "true")
for tag in available_tags:
_write_github_output(f"{tag}-changed", "true")
click.echo("No tags found — treating all changes as user-facing.") click.echo("No tags found — treating all changes as user-facing.")
return return
if quiet: if quiet:
@@ -538,8 +652,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
files = get_changed_files(base, head) files = get_changed_files(base, head)
if not files: if not files:
if github_output: if github_output:
_write_github_output("ansible-changed", "false")
_write_github_output("user-facing-changed", "false") _write_github_output("user-facing-changed", "false")
for tag in available_tags:
_write_github_output(f"{tag}-changed", "false")
click.echo(f"No changes between {base} and {head}.") click.echo(f"No changes between {base} and {head}.")
return return
if quiet: if quiet:
@@ -551,35 +666,40 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
result = classifier.classify(files) result = classifier.classify(files)
if github_output: if github_output:
_write_github_output("ansible-changed", "true" if result.has_tag("ansible") else "false")
_write_github_output("user-facing-changed", "true" if result.has_user_facing else "false") _write_github_output("user-facing-changed", "true" if result.has_user_facing else "false")
click.echo(f"Ansible files changed: {result.has_tag('ansible')}") for tag in available_tags:
_write_github_output(f"{tag}-changed", "true" if result.has_tag(tag) else "false")
click.echo(f"User-facing files changed: {result.has_user_facing}") click.echo(f"User-facing files changed: {result.has_user_facing}")
for tag in available_tags:
click.echo(f"{tag.capitalize()} files changed: {result.has_tag(tag)}")
return return
if check == "ansible": # --check: check a specific tag or user-facing
ansible_files = result.tags.get("ansible", []) if check != "all":
has_ansible = bool(ansible_files) if check == "user-facing":
checked_files = result.user_facing
has_checked = bool(checked_files)
label = "User-facing"
elif check in available_tags:
checked_files = result.tags.get(check, [])
has_checked = bool(checked_files)
label = check.capitalize()
else:
raise click.ClickException(
_(
"Unknown check category '{check}'. Available: all, user-facing{tags}",
check=check,
tags=", " + ", ".join(available_tags) if available_tags else "",
)
)
if quiet: if quiet:
click.echo("true" if has_ansible else "false") click.echo("true" if has_checked else "false")
return return
click.echo(_("\nAnsible files changed ({count}):", count=len(ansible_files))) click.echo(_("\n{label} files changed ({count}):", label=label, count=len(checked_files)))
for f in ansible_files: for f in checked_files:
click.echo(f" {f}")
click.echo(_("\nResult: {status}", status="Ansible changes detected" if has_ansible else "No Ansible changes"))
return
if check == "user-facing":
user_files = result.user_facing
has_user = bool(user_files)
if quiet:
click.echo("true" if has_user else "false")
return
click.echo(_("\nUser-facing files changed ({count}):", count=len(user_files)))
for f in user_files:
click.echo(f" {f}") click.echo(f" {f}")
click.echo( click.echo(
_("\nResult: {status}", status="User-facing changes detected" if has_user else "No user-facing changes") _("\nResult: {status}", status=f"{label} changes detected" if has_checked else f"No {label} changes")
) )
return return
@@ -596,6 +716,12 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
click.echo(_("\nWorkflow-only changes ({count}):", count=len(result.infrastructure))) click.echo(_("\nWorkflow-only changes ({count}):", count=len(result.infrastructure)))
for f in result.infrastructure: for f in result.infrastructure:
click.echo(f" {f}") click.echo(f" {f}")
for tag in available_tags:
tag_files = result.tags.get(tag, [])
if tag_files:
click.echo(_("\n{tag} files ({count}):", tag=tag.capitalize(), count=len(tag_files)))
for f in tag_files:
click.echo(f" {f}")
if has_user: if has_user:
status = "USER-FACING changes detected — release needed" status = "USER-FACING changes detected — release needed"
else: else:
+13 -7
View File
@@ -39,7 +39,7 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
Returns the total count of active runners. If the API call fails Returns the total count of active runners. If the API call fails
(e.g., no admin access for instance-level runners), falls back to (e.g., no admin access for instance-level runners), falls back to
what we can see. what we can see. Fallbacks are logged to stderr for debugging.
""" """
headers = {"Authorization": f"token {token}"} headers = {"Authorization": f"token {token}"}
total = 0 total = 0
@@ -54,8 +54,10 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
if r.status_code == 200: if r.status_code == 200:
data = r.json() data = r.json()
total += data.get("total_count", 0) total += data.get("total_count", 0)
except (requests.RequestException, ValueError): else:
pass click.echo(f"Warning: repo-level runners query returned HTTP {r.status_code}", err=True)
except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: repo-level runners query failed: {e}", err=True)
# 2. Organization-level runners # 2. Organization-level runners
try: try:
@@ -67,8 +69,10 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
if r.status_code == 200: if r.status_code == 200:
data = r.json() data = r.json()
total += data.get("total_count", 0) total += data.get("total_count", 0)
except (requests.RequestException, ValueError): else:
pass click.echo(f"Warning: org-level runners query returned HTTP {r.status_code}", err=True)
except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: org-level runners query failed: {e}", err=True)
# 3. Instance-level runners (requires admin scope) # 3. Instance-level runners (requires admin scope)
try: try:
@@ -80,8 +84,10 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
if r.status_code == 200: if r.status_code == 200:
data = r.json() data = r.json()
total += data.get("total_count", 0) total += data.get("total_count", 0)
except (requests.RequestException, ValueError): elif r.status_code != 403: # 403 is expected without admin scope
pass click.echo(f"Warning: instance-level runners query returned HTTP {r.status_code}", err=True)
except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: instance-level runners query failed: {e}", err=True)
return total return total
+12 -4
View File
@@ -15,7 +15,7 @@ Usage:
from __future__ import annotations from __future__ import annotations
import contextlib import logging
import os import os
import click import click
@@ -27,25 +27,33 @@ from devx.i18n import _
load_dotenv() load_dotenv()
logger = logging.getLogger("devx")
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.
Raises TeaCLIError if tea is not installed or the command fails. Raises TeaCLIError if tea is not installed or the command fails.
Label operations are best-effort — failures are logged but don't
prevent issue creation.
""" """
tea = TeaCLI(repo=repo) tea = TeaCLI(repo=repo)
# Check if "bug" label exists # Check if "bug" label exists (best-effort)
labels: list[str] = [] labels: list[str] = []
with contextlib.suppress(TeaCLIError): try:
existing_labels = tea.list_labels(repo) existing_labels = tea.list_labels(repo)
if any(label.get("name") == "bug" for label in existing_labels): if any(label.get("name") == "bug" for label in existing_labels):
labels = ["bug"] labels = ["bug"]
except TeaCLIError as e:
logger.warning("Could not fetch labels (best-effort): %s", e)
issue = tea.create_issue(repo, title=title, body=body, labels=labels if labels else None) issue = tea.create_issue(repo, title=title, body=body, labels=labels if labels else None)
if labels: if labels:
with contextlib.suppress(TeaCLIError): try:
tea.add_label(repo, issue["index"], labels) tea.add_label(repo, issue["index"], labels)
except TeaCLIError as e:
logger.warning("Could not add label to issue #%s (best-effort): %s", issue.get("index"), e)
return int(issue.get("index", 0)) return int(issue.get("index", 0))
+14 -13
View File
@@ -13,7 +13,7 @@ import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.api_clients import VikunjaClient from devx.api_clients import VikunjaClient
from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.exceptions import APIError from devx.exceptions import APIError
from devx.i18n import _ from devx.i18n import _
@@ -151,14 +151,16 @@ def main(commit_msg: str | None, commit_sha: str, from_git: bool, git_sha: str)
) )
) )
return return
# Non-infrastructure commits without DEVX-N prefix — warn but don't fail # Non-infrastructure commits without DEVX-N prefix — this is a
click.echo( # convention violation. Fail the post-merge job so the issue is visible.
raise click.ClickException(
_( _(
"Warning: No task ID (DEVX-N) found in commit message: {msg}. Skipping Vikunja update.", "No task ID ({prefix}-N) found in commit message: {msg}. "
"Every non-infrastructure commit must have a task ID.",
prefix=TASK_PREFIX,
msg=first_line, msg=first_line,
) )
) )
return
client = VikunjaClient(VIKUNJA_API_URL, token) client = VikunjaClient(VIKUNJA_API_URL, token)
vikunja_task_id = resolve_task_id(client, task_id) vikunja_task_id = resolve_task_id(client, task_id)
@@ -170,19 +172,18 @@ def main(commit_msg: str | None, commit_sha: str, from_git: bool, git_sha: str)
client.post_comment(vikunja_task_id, html) client.post_comment(vikunja_task_id, html)
client.update_task(vikunja_task_id, done=True) client.update_task(vikunja_task_id, done=True)
except APIError as e: except APIError as e:
# Vikunja is a project management tool — if it's down, the merge # Vikunja API failures must be visible — the task was not updated
# still succeeded. Warn but don't fail the post-merge workflow. # and needs manual intervention. Failing the CI job makes this visible.
click.echo( raise click.ClickException(
_( _(
"Warning: Vikunja API error (HTTP {status}): {message}. " "Vikunja API error (HTTP {status}): {message}. "
"Task {task_id} was NOT updated. The merge succeeded — " "Task {task_id} was NOT updated. "
"please update the Vikunja task manually.", "The merge succeeded but the Vikunja task needs manual update.",
status=e.status, status=e.status,
message=e.message, message=e.message,
task_id=task_id, task_id=task_id,
) )
) ) from e
return
click.echo( click.echo(
_( _(
+35 -6
View File
@@ -86,7 +86,13 @@ def get_bumped_version() -> str:
if not version: if not version:
raise click.ClickException(_("git-cliff returned empty version.")) raise click.ClickException(_("git-cliff returned empty version."))
# git-cliff may return with or without 'v' prefix # git-cliff may return with or without 'v' prefix
return version.lstrip("v") version = version.lstrip("v")
# Validate semver format
if not re.match(r"^\d+\.\d+\.\d+$", version):
raise click.ClickException(
_("git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", version=version)
)
return version
def get_changelog(new_version: str) -> str: def get_changelog(new_version: str) -> str:
@@ -271,16 +277,34 @@ def main(dry_run: bool, skip_tests: bool) -> None:
) )
) )
# Release lock: if HEAD is already a release commit, another release # Release lock: if HEAD is already a release commit, check if the tag
# run is in progress (or already completed). Skip to prevent duplicate tags. # exists. If the tag is missing (e.g., tag push failed in a previous run),
# create and push it instead of skipping — this recovers from the
# common failure mode where the commit was pushed but the tag was not.
head_msg = run_cmd(["git", "log", "-1", "--pretty=%s"]).stdout.strip() head_msg = run_cmd(["git", "log", "-1", "--pretty=%s"]).stdout.strip()
if 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:
release_version = release_match.group(1)
release_tag = f"v{release_version}"
if tag_exists(release_tag):
click.echo(
_(
"HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.",
msg=head_msg,
tag=release_tag,
)
)
return
# Tag is missing — recover by creating and pushing it
click.echo( click.echo(
_( _(
"HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping.", "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
msg=head_msg, msg=head_msg,
tag=release_tag,
) )
) )
changelog = get_changelog(release_version)
create_and_push_tag(release_version, changelog, dry_run)
return return
# Check if any user-facing files changed since the last tag. # Check if any user-facing files changed since the last tag.
@@ -315,7 +339,12 @@ def main(dry_run: bool, skip_tests: bool) -> None:
# Generate changelog # Generate changelog
changelog = get_changelog(new_version) changelog = get_changelog(new_version)
if not changelog: if not changelog:
click.echo(_("Warning: git-cliff generated empty changelog.")) raise click.ClickException(
_(
"git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
version=new_version,
)
)
if dry_run: if dry_run:
click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog)) click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog))
+19 -8
View File
@@ -39,9 +39,20 @@ MAPPING_FILE = DOCS_DIR / "mapping.json"
def load_mapping() -> dict[str, str]: def load_mapping() -> dict[str, str]:
"""Load the file-to-wiki-page mapping from mapping.json.""" """Load the file-to-wiki-page mapping from mapping.json.
Validates that the mapping is a dict of string-to-string pairs.
"""
with open(MAPPING_FILE) as f: with open(MAPPING_FILE) as f:
return json.load(f) data = json.load(f)
if not isinstance(data, dict):
raise click.ClickException(
_("mapping.json must be a dict of file-path -> page-title, got {type}", type=type(data).__name__)
)
for k, v in data.items():
if not isinstance(k, str) or not isinstance(v, str):
raise click.ClickException(_("mapping.json keys and values must be strings, got {k}={v}", k=k, v=v))
return data
def read_doc_content(file_path: str) -> str: def read_doc_content(file_path: str) -> str:
@@ -239,14 +250,14 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
try: try:
content = read_doc_content(file_path) content = read_doc_content(file_path)
except FileNotFoundError: except FileNotFoundError:
click.echo(_("WARNING: File {file} not found — skipping.", file=file_path)) raise click.ClickException(
skipped += 1 _("Mapped file {file} not found. Update mapping.json or create the file.", file=file_path)
continue ) from None
if not content.strip(): if not content.strip():
click.echo(_("WARNING: File {file} is empty — skipping.", file=file_path)) raise click.ClickException(
skipped += 1 _("Mapped file {file} is empty. Update the content or remove from mapping.json.", file=file_path)
continue ) from None
result = sync_page(client, page_title, content, existing_pages, dry_run) result = sync_page(client, page_title, content, existing_pages, dry_run)
if result == "created": if result == "created":
+4
View File
@@ -174,6 +174,10 @@ def cli(
_write_github_env("SKIP", "true") _write_github_env("SKIP", "true")
return return
# Validate runner index is in range
if runner_index < 1:
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
# Convert 1-based CLI index to 0-based internal index # Convert 1-based CLI index to 0-based internal index
zero_based = runner_index - 1 zero_based = runner_index - 1
assigned = pairs_for_runner(pairs, zero_based, max_runners) assigned = pairs_for_runner(pairs, zero_based, max_runners)
+5 -2
View File
@@ -159,8 +159,11 @@ def cli(pairs: tuple[str, ...]) -> None:
if failed_event.is_set(): if failed_event.is_set():
sys.exit(1) sys.exit(1)
scenario = pair.split("|")[0] parts = pair.split("|")
platform_name = pair.split("|")[1] 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)
+22 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Project setup: install Python deps, pre-commit hooks, and tea CLI login. """Project setup: install Python deps, Ansible collections, pre-commit hooks, and tea CLI login.
Usage:: Usage::
@@ -38,6 +38,16 @@ def _install_pre_commit_hooks(bin_dir: str) -> None:
_run([pre_commit, "install", "--hook-type", hook_type]) _run([pre_commit, "install", "--hook-type", hook_type])
def _install_ansible_collections(bin_dir: str) -> None:
"""Install required Ansible Galaxy collections if requirements exist."""
galaxy = str(Path(bin_dir) / "ansible-galaxy")
requirements = Path("ansible/requirements.yml")
if not requirements.exists():
click.echo(" ansible/requirements.yml not found — skipping collections.")
return
_run([galaxy, "collection", "install", "-r", str(requirements)])
def _configure_tea_login() -> None: def _configure_tea_login() -> None:
"""Configure tea CLI login from .env if REPO_TOKEN is set. """Configure tea CLI login from .env if REPO_TOKEN is set.
@@ -119,11 +129,18 @@ def _verify(bin_dir: str) -> None:
default=False, default=False,
help="Skip tea CLI login configuration.", help="Skip tea CLI login configuration.",
) )
@click.option(
"--no-ansible-collections",
is_flag=True,
default=False,
help="Skip Ansible Galaxy collection installation.",
)
def main( def main(
bin_dir: str, bin_dir: str,
extras: str, extras: str,
no_pre_commit: bool, no_pre_commit: bool,
no_tea_login: bool, no_tea_login: bool,
no_ansible_collections: bool,
) -> None: ) -> None:
"""Install Python deps, pre-commit hooks, and configure tea CLI.""" """Install Python deps, pre-commit hooks, and configure tea CLI."""
if not Path(bin_dir).exists(): if not Path(bin_dir).exists():
@@ -132,6 +149,10 @@ def main(
click.echo(f"Installing Python dependencies (extras: {extras})...") click.echo(f"Installing Python dependencies (extras: {extras})...")
_install_python_deps(bin_dir, extras) _install_python_deps(bin_dir, extras)
if not no_ansible_collections:
click.echo("Installing Ansible Galaxy collections...")
_install_ansible_collections(bin_dir)
if not no_pre_commit: if not no_pre_commit:
click.echo("Installing pre-commit hooks...") click.echo("Installing pre-commit hooks...")
_install_pre_commit_hooks(bin_dir) _install_pre_commit_hooks(bin_dir)
+574 -172
View File
@@ -1,63 +1,129 @@
{ {
"\nAll documentation coverage checks passed!": { "\nAll documentation coverage checks passed!": {
"en": "\nAll documentation coverage checks passed!" "en": "\nAll documentation coverage checks passed!",
}, "bg": "\nAll documentation coverage checks passed!",
"\nAnsible files changed ({count}):": { "de": "\nAll documentation coverage checks passed!",
"en": "\nAnsible files changed ({count}):" "ru": "\nAll documentation coverage checks passed!",
"zh": "\nAll documentation coverage checks passed!"
}, },
"\nChecking CI script documentation in ci-cd-workflow.md...": { "\nChecking CI script documentation in ci-cd-workflow.md...": {
"en": "\nChecking CI script documentation in ci-cd-workflow.md..." "en": "\nChecking CI script documentation in ci-cd-workflow.md...",
"bg": "\nChecking CI script documentation in ci-cd-workflow.md...",
"de": "\nChecking CI script documentation in ci-cd-workflow.md...",
"ru": "\nChecking CI script documentation in ci-cd-workflow.md...",
"zh": "\nChecking CI script documentation in ci-cd-workflow.md..."
}, },
"\nChecking module documentation in architecture.md...": { "\nChecking module documentation in architecture.md...": {
"en": "\nChecking module documentation in architecture.md..." "en": "\nChecking module documentation in architecture.md...",
"bg": "\nChecking module documentation in architecture.md...",
"de": "\nChecking module documentation in architecture.md...",
"ru": "\nChecking module documentation in architecture.md...",
"zh": "\nChecking module documentation in architecture.md..."
}, },
"\nDoc coverage: {covered}/{total} ({pct}%)": { "\nDoc coverage: {covered}/{total} ({pct}%)": {
"en": "\nDoc coverage: {covered}/{total} ({pct}%)" "en": "\nDoc coverage: {covered}/{total} ({pct}%)",
"bg": "\nDoc coverage: {covered}/{total} ({pct}%)",
"de": "\nDoc coverage: {covered}/{total} ({pct}%)",
"ru": "\nDoc coverage: {covered}/{total} ({pct}%)",
"zh": "\nDoc coverage: {covered}/{total} ({pct}%)"
}, },
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}": { "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}": {
"en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}" "en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"bg": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"de": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
}, },
"\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": { "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": {
"en": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce." "en": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
"bg": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
"de": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
"ru": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
"zh": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce."
}, },
"\nIntegrity check FAILED ({count} issues):": { "\nIntegrity check FAILED ({count} issues):": {
"en": "\nIntegrity check FAILED ({count} issues):" "en": "\nIntegrity check FAILED ({count} issues):",
"bg": "\nIntegrity check FAILED ({count} issues):",
"de": "\nIntegrity check FAILED ({count} issues):",
"ru": "\nIntegrity check FAILED ({count} issues):",
"zh": "\nIntegrity check FAILED ({count} issues):"
}, },
"\nIntegrity check passed — all {count} pages verified.": { "\nIntegrity check passed — all {count} pages verified.": {
"en": "\nIntegrity check passed — all {count} pages verified." "en": "\nIntegrity check passed — all {count} pages verified.",
"bg": "\nIntegrity check passed — all {count} pages verified.",
"de": "\nIntegrity check passed — all {count} pages verified.",
"ru": "\nIntegrity check passed — all {count} pages verified.",
"zh": "\nIntegrity check passed — all {count} pages verified."
}, },
"\nMissing documentation:": { "\nMissing documentation:": {
"en": "\nMissing documentation:" "en": "\nMissing documentation:",
"bg": "\nMissing documentation:",
"de": "\nMissing documentation:",
"ru": "\nMissing documentation:",
"zh": "\nMissing documentation:"
}, },
"\nResult: {status}": { "\nResult: {status}": {
"en": "\nResult: {status}" "en": "\nResult: {status}",
"bg": "\nResult: {status}",
"de": "\nResult: {status}",
"ru": "\nResult: {status}",
"zh": "\nResult: {status}"
}, },
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).": { "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).": {
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)." "en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
}, },
"\nRunning full wiki integrity check...": { "\nRunning full wiki integrity check...": {
"en": "\nRunning full wiki integrity check..." "en": "\nRunning full wiki integrity check...",
"bg": "\nRunning full wiki integrity check...",
"de": "\nRunning full wiki integrity check...",
"ru": "\nRunning full wiki integrity check...",
"zh": "\nRunning full wiki integrity check..."
}, },
"\nUser-facing changes ({count}):": { "\nUser-facing changes ({count}):": {
"en": "\nUser-facing changes ({count}):" "en": "\nUser-facing changes ({count}):",
}, "bg": "\nUser-facing changes ({count}):",
"\nUser-facing files changed ({count}):": { "de": "\nUser-facing changes ({count}):",
"en": "\nUser-facing files changed ({count}):" "ru": "\nUser-facing changes ({count}):",
"zh": "\nUser-facing changes ({count}):"
}, },
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!": { "\nVerification FAILED: {failures} page(s) have empty or mismatched content!": {
"en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!" "en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"bg": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"de": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"ru": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"zh": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!"
}, },
"\nVerification passed — all wiki pages have correct content.": { "\nVerification passed — all wiki pages have correct content.": {
"en": "\nVerification passed — all wiki pages have correct content." "en": "\nVerification passed — all wiki pages have correct content.",
"bg": "\nVerification passed — all wiki pages have correct content.",
"de": "\nVerification passed — all wiki pages have correct content.",
"ru": "\nVerification passed — all wiki pages have correct content.",
"zh": "\nVerification passed — all wiki pages have correct content."
}, },
"\nVerifying wiki pages have content...": { "\nVerifying wiki pages have content...": {
"en": "\nVerifying wiki pages have content..." "en": "\nVerifying wiki pages have content...",
"bg": "\nVerifying wiki pages have content...",
"de": "\nVerifying wiki pages have content...",
"ru": "\nVerifying wiki pages have content...",
"zh": "\nVerifying wiki pages have content..."
}, },
"\nWorkflow-only changes ({count}):": { "\nWorkflow-only changes ({count}):": {
"en": "\nWorkflow-only changes ({count}):" "en": "\nWorkflow-only changes ({count}):",
"bg": "\nWorkflow-only changes ({count}):",
"de": "\nWorkflow-only changes ({count}):",
"ru": "\nWorkflow-only changes ({count}):",
"zh": "\nWorkflow-only changes ({count}):"
}, },
"\n[dry-run] Changelog:\n{changelog}": { "\n[dry-run] Changelog:\n{changelog}": {
"en": "\n[dry-run] Changelog:\n{changelog}" "en": "\n[dry-run] Changelog:\n{changelog}",
"bg": "\n[dry-run] Changelog:\n{changelog}",
"de": "\n[dry-run] Changelog:\n{changelog}",
"ru": "\n[dry-run] Changelog:\n{changelog}",
"zh": "\n[dry-run] Changelog:\n{changelog}"
}, },
" - Auto-delete branch after merge: yes": { " - Auto-delete branch after merge: yes": {
"en": " - Auto-delete branch after merge: yes", "en": " - Auto-delete branch after merge: yes",
@@ -81,7 +147,11 @@
"zh": " - 阻止被拒绝的审查: 是" "zh": " - 阻止被拒绝的审查: 是"
}, },
" - Direct pushes: BLOCKED (require PR, whitelisted users can push)": { " - Direct pushes: BLOCKED (require PR, whitelisted users can push)": {
"en": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)" "en": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
"bg": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
"de": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
"ru": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
"zh": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)"
}, },
" - Dismiss stale approvals: yes": { " - Dismiss stale approvals: yes": {
"en": " - Dismiss stale approvals: yes", "en": " - Dismiss stale approvals: yes",
@@ -105,10 +175,18 @@
"zh": " - 必需状态检查: {checks}" "zh": " - 必需状态检查: {checks}"
}, },
" Created: {title}": { " Created: {title}": {
"en": " Created: {title}" "en": " Created: {title}",
"bg": " Created: {title}",
"de": " Created: {title}",
"ru": " Created: {title}",
"zh": " Created: {title}"
}, },
" FAIL: {title} — content mismatch or empty!": { " FAIL: {title} — content mismatch or empty!": {
"en": " FAIL: {title} — content mismatch or empty!" "en": " FAIL: {title} — content mismatch or empty!",
"bg": " FAIL: {title} — content mismatch or empty!",
"de": " FAIL: {title} — content mismatch or empty!",
"ru": " FAIL: {title} — content mismatch or empty!",
"zh": " FAIL: {title} — content mismatch or empty!"
}, },
" MISSING: devx {cmd}": { " MISSING: devx {cmd}": {
"en": " MISSING: devx {cmd}", "en": " MISSING: devx {cmd}",
@@ -117,14 +195,19 @@
"ru": " ОТСУТСТВУЕТ: devx {cmd}", "ru": " ОТСУТСТВУЕТ: devx {cmd}",
"zh": " 缺失: devx {cmd}" "zh": " 缺失: devx {cmd}"
}, },
" MISSING: grm {cmd}": {
"en": " MISSING: grm {cmd}"
},
" MISSING: {module}": { " MISSING: {module}": {
"en": " MISSING: {module}" "en": " MISSING: {module}",
"bg": " MISSING: {module}",
"de": " MISSING: {module}",
"ru": " MISSING: {module}",
"zh": " MISSING: {module}"
}, },
" MISSING: {script}": { " MISSING: {script}": {
"en": " MISSING: {script}" "en": " MISSING: {script}",
"bg": " MISSING: {script}",
"de": " MISSING: {script}",
"ru": " MISSING: {script}",
"zh": " MISSING: {script}"
}, },
" OK: devx {cmd}": { " OK: devx {cmd}": {
"en": " OK: devx {cmd}", "en": " OK: devx {cmd}",
@@ -133,41 +216,82 @@
"ru": " ОК: devx {cmd}", "ru": " ОК: devx {cmd}",
"zh": " 正常: devx {cmd}" "zh": " 正常: devx {cmd}"
}, },
" OK: grm {cmd}": {
"en": " OK: grm {cmd}"
},
" OK: {module}": { " OK: {module}": {
"en": " OK: {module}" "en": " OK: {module}",
"bg": " OK: {module}",
"de": " OK: {module}",
"ru": " OK: {module}",
"zh": " OK: {module}"
}, },
" OK: {script}": { " OK: {script}": {
"en": " OK: {script}" "en": " OK: {script}",
"bg": " OK: {script}",
"de": " OK: {script}",
"ru": " OK: {script}",
"zh": " OK: {script}"
}, },
" OK: {title} ({chars} chars)": { " OK: {title} ({chars} chars)": {
"en": " OK: {title} ({chars} chars)" "en": " OK: {title} ({chars} chars)",
"bg": " OK: {title} ({chars} chars)",
"de": " OK: {title} ({chars} chars)",
"ru": " OK: {title} ({chars} chars)",
"zh": " OK: {title} ({chars} chars)"
}, },
" Updated: {title}": { " Updated: {title}": {
"en": " Updated: {title}" "en": " Updated: {title}",
"bg": " Updated: {title}",
"de": " Updated: {title}",
"ru": " Updated: {title}",
"zh": " Updated: {title}"
}, },
"API poll warning: {exc}": { "API poll warning: {exc}": {
"en": "API poll warning: {exc}" "en": "API poll warning: {exc}",
"bg": "API poll warning: {exc}",
"de": "API poll warning: {exc}",
"ru": "API poll warning: {exc}",
"zh": "API poll warning: {exc}"
}, },
"All molecule tests passed.": { "All molecule tests passed.": {
"en": "All molecule tests passed." "en": "All molecule tests passed.",
"bg": "All molecule tests passed.",
"de": "All molecule tests passed.",
"ru": "All molecule tests passed.",
"zh": "All molecule tests passed."
}, },
"Another molecule runner failed. Stopping this runner early.": { "Another molecule runner failed. Stopping this runner early.": {
"en": "Another molecule runner failed. Stopping this runner early." "en": "Another molecule runner failed. Stopping this runner early.",
"bg": "Another molecule runner failed. Stopping this runner early.",
"de": "Another molecule runner failed. Stopping this runner early.",
"ru": "Another molecule runner failed. Stopping this runner early.",
"zh": "Another molecule runner failed. Stopping this runner early."
}, },
"Bumping version: {current} -> v{new_version}": { "Bumping version: {current} -> v{new_version}": {
"en": "Bumping version: {current} -> v{new_version}" "en": "Bumping version: {current} -> v{new_version}",
"bg": "Bumping version: {current} -> v{new_version}",
"de": "Bumping version: {current} -> v{new_version}",
"ru": "Bumping version: {current} -> v{new_version}",
"zh": "Bumping version: {current} -> v{new_version}"
}, },
"Checking CLI command documentation...": { "Checking CLI command documentation...": {
"en": "Checking CLI command documentation..." "en": "Checking CLI command documentation...",
"bg": "Checking CLI command documentation...",
"de": "Checking CLI command documentation...",
"ru": "Checking CLI command documentation...",
"zh": "Checking CLI command documentation..."
}, },
"Command failed ({cmd}): {stderr}": { "Command failed ({cmd}): {stderr}": {
"en": "Command failed ({cmd}): {stderr}" "en": "Command failed ({cmd}): {stderr}",
"bg": "Command failed ({cmd}): {stderr}",
"de": "Command failed ({cmd}): {stderr}",
"ru": "Command failed ({cmd}): {stderr}",
"zh": "Command failed ({cmd}): {stderr}"
}, },
"Comparing {base}..{head} ({count} files changed)": { "Comparing {base}..{head} ({count} files changed)": {
"en": "Comparing {base}..{head} ({count} files changed)" "en": "Comparing {base}..{head} ({count} files changed)",
"bg": "Comparing {base}..{head} ({count} files changed)",
"de": "Comparing {base}..{head} ({count} files changed)",
"ru": "Comparing {base}..{head} ({count} files changed)",
"zh": "Comparing {base}..{head} ({count} files changed)"
}, },
"Configuring branch protection for {branch}...": { "Configuring branch protection for {branch}...": {
"en": "Configuring branch protection for {branch}...", "en": "Configuring branch protection for {branch}...",
@@ -184,25 +308,53 @@
"zh": "正在配置仓库设置..." "zh": "正在配置仓库设置..."
}, },
"Could not extract conventional commit message from PR commits.": { "Could not extract conventional commit message from PR commits.": {
"en": "Could not extract conventional commit message from PR commits." "en": "Could not extract conventional commit message from PR commits.",
"bg": "Could not extract conventional commit message from PR commits.",
"de": "Could not extract conventional commit message from PR commits.",
"ru": "Could not extract conventional commit message from PR commits.",
"zh": "Could not extract conventional commit message from PR commits."
}, },
"Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": { "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": {
"en": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task." "en": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"bg": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"de": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"ru": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"zh": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task."
}, },
"Could not find __version__ in {file}": { "Could not find __version__ in {file}": {
"en": "Could not find __version__ in {file}" "en": "Could not find __version__ in {file}",
"bg": "Could not find __version__ in {file}",
"de": "Could not find __version__ in {file}",
"ru": "Could not find __version__ in {file}",
"zh": "Could not find __version__ in {file}"
}, },
"Could not parse test execution time from output.": { "Could not parse test execution time from output.": {
"en": "Could not parse test execution time from output." "en": "Could not parse test execution time from output.",
"bg": "Could not parse test execution time from output.",
"de": "Could not parse test execution time from output.",
"ru": "Could not parse test execution time from output.",
"zh": "Could not parse test execution time from output."
}, },
"Created issue #{issue_id}: {title}": { "Created issue #{issue_id}: {title}": {
"en": "Created issue #{issue_id}: {title}" "en": "Created issue #{issue_id}: {title}",
"bg": "Created issue #{issue_id}: {title}",
"de": "Created issue #{issue_id}: {title}",
"ru": "Created issue #{issue_id}: {title}",
"zh": "Created issue #{issue_id}: {title}"
}, },
"Created release commit.": { "Created release commit.": {
"en": "Created release commit." "en": "Created release commit.",
"bg": "Created release commit.",
"de": "Created release commit.",
"ru": "Created release commit.",
"zh": "Created release commit."
}, },
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": { "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": {
"en": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently." "en": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"ru": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"zh": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently."
}, },
"ERROR: REPO_TOKEN is not set.": { "ERROR: REPO_TOKEN is not set.": {
"en": "ERROR: REPO_TOKEN is not set.", "en": "ERROR: REPO_TOKEN is not set.",
@@ -226,22 +378,39 @@
"zh": "错误:未指定仓库名称。请使用 --repo 或设置 DEVX_REPO_NAME。" "zh": "错误:未指定仓库名称。请使用 --repo 或设置 DEVX_REPO_NAME。"
}, },
"ERROR: mapping.json not found at {path}": { "ERROR: mapping.json not found at {path}": {
"en": "ERROR: mapping.json not found at {path}" "en": "ERROR: mapping.json not found at {path}",
"bg": "ERROR: mapping.json not found at {path}",
"de": "ERROR: mapping.json not found at {path}",
"ru": "ERROR: mapping.json not found at {path}",
"zh": "ERROR: mapping.json not found at {path}"
}, },
"FAILED: {pair} exited with code {code}": { "FAILED: {pair} exited with code {code}": {
"en": "FAILED: {pair} exited with code {code}" "en": "FAILED: {pair} exited with code {code}",
"bg": "FAILED: {pair} exited with code {code}",
"de": "FAILED: {pair} exited with code {code}",
"ru": "FAILED: {pair} exited with code {code}",
"zh": "FAILED: {pair} exited with code {code}"
}, },
"Failed to create issue via tea: {error}": { "Failed to create issue via tea: {error}": {
"en": "Failed to create issue via tea: {error}" "en": "Failed to create issue via tea: {error}",
"bg": "Failed to create issue via tea: {error}",
"de": "Failed to create issue via tea: {error}",
"ru": "Failed to create issue via tea: {error}",
"zh": "Failed to create issue via tea: {error}"
}, },
"Found {count} existing wiki pages.": { "Found {count} existing wiki pages.": {
"en": "Found {count} existing wiki pages." "en": "Found {count} existing wiki pages.",
"bg": "Found {count} existing wiki pages.",
"de": "Found {count} existing wiki pages.",
"ru": "Found {count} existing wiki pages.",
"zh": "Found {count} existing wiki pages."
}, },
"GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": { "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
"en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation." "en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
}, "bg": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping.": { "de": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"en": "HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping." "ru": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"zh": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."
}, },
"HTTP error: {status} — {message}": { "HTTP error: {status} — {message}": {
"en": "HTTP error: {status} — {message}", "en": "HTTP error: {status} — {message}",
@@ -258,7 +427,11 @@
"zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。" "zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。"
}, },
"Head branch is behind master. Pulling and rebasing...": { "Head branch is behind master. Pulling and rebasing...": {
"en": "Head branch is behind master. Pulling and rebasing..." "en": "Head branch is behind master. Pulling and rebasing...",
"bg": "Head branch is behind master. Pulling and rebasing...",
"de": "Head branch is behind master. Pulling and rebasing...",
"ru": "Head branch is behind master. Pulling and rebasing...",
"zh": "Head branch is behind master. Pulling and rebasing..."
}, },
"Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": { "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": {
"en": "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}", "en": "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}",
@@ -267,17 +440,26 @@
"ru": "Инфраструктурный коммит (без ID задачи DEVX-N), пропуск обновления Vikunja: {msg}", "ru": "Инфраструктурный коммит (без ID задачи DEVX-N), пропуск обновления Vikunja: {msg}",
"zh": "基础设施提交(无 DEVX-N 任务 ID),跳过 Vikunja 更新: {msg}" "zh": "基础设施提交(无 DEVX-N 任务 ID),跳过 Vikunja 更新: {msg}"
}, },
"Infrastructure commit (no GRM-N task ID), skipping Vikunja update: {msg}": {
"en": "Infrastructure commit (no GRM-N task ID), skipping Vikunja update: {msg}"
},
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}": { "Lint failed — refusing to release. Fix lint errors first.\n{stderr}": {
"en": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}" "en": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"bg": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"de": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"ru": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"zh": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}"
}, },
"Lint passed.": { "Lint passed.": {
"en": "Lint passed." "en": "Lint passed.",
"bg": "Lint passed.",
"de": "Lint passed.",
"ru": "Lint passed.",
"zh": "Lint passed."
}, },
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.": { "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.": {
"en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually." "en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"bg": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"de": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"ru": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"zh": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually."
}, },
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": { "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": {
"en": "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.", "en": "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.",
@@ -315,7 +497,11 @@
"zh": "不错!PR #{pr_number} 已 squash 合并,标题: {merge_title}" "zh": "不错!PR #{pr_number} 已 squash 合并,标题: {merge_title}"
}, },
"Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.": { "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.": {
"en": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered." "en": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
"bg": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
"de": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
"ru": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
"zh": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered."
}, },
"Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.": { "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.": {
"en": "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.", "en": "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.",
@@ -325,22 +511,46 @@
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。" "zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
}, },
"No changes between {base} and {head}.": { "No changes between {base} and {head}.": {
"en": "No changes between {base} and {head}." "en": "No changes between {base} and {head}.",
"bg": "No changes between {base} and {head}.",
"de": "No changes between {base} and {head}.",
"ru": "No changes between {base} and {head}.",
"zh": "No changes between {base} and {head}."
}, },
"No staged changes — version and changelog already up to date.": { "No staged changes — version and changelog already up to date.": {
"en": "No staged changes — version and changelog already up to date." "en": "No staged changes — version and changelog already up to date.",
"bg": "No staged changes — version and changelog already up to date.",
"de": "No staged changes — version and changelog already up to date.",
"ru": "No staged changes — version and changelog already up to date.",
"zh": "No staged changes — version and changelog already up to date."
}, },
"No tags found — treating all changes as user-facing.": { "No tags found — treating all changes as user-facing.": {
"en": "No tags found — treating all changes as user-facing." "en": "No tags found — treating all changes as user-facing.",
"bg": "No tags found — treating all changes as user-facing.",
"de": "No tags found — treating all changes as user-facing.",
"ru": "No tags found — treating all changes as user-facing.",
"zh": "No tags found — treating all changes as user-facing."
}, },
"No unreleased changes found. Nothing to release.": { "No unreleased changes found. Nothing to release.": {
"en": "No unreleased changes found. Nothing to release." "en": "No unreleased changes found. Nothing to release.",
"bg": "No unreleased changes found. Nothing to release.",
"de": "No unreleased changes found. Nothing to release.",
"ru": "No unreleased changes found. Nothing to release.",
"zh": "No unreleased changes found. Nothing to release."
}, },
"No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.": { "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.": {
"en": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release." "en": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"bg": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"de": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"ru": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"zh": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release."
}, },
"Note: Self-approval not allowed. Posting COMMENT instead.": { "Note: Self-approval not allowed. Posting COMMENT instead.": {
"en": "Note: Self-approval not allowed. Posting COMMENT instead." "en": "Note: Self-approval not allowed. Posting COMMENT instead.",
"bg": "Note: Self-approval not allowed. Posting COMMENT instead.",
"de": "Note: Self-approval not allowed. Posting COMMENT instead.",
"ru": "Note: Self-approval not allowed. Posting COMMENT instead.",
"zh": "Note: Self-approval not allowed. Posting COMMENT instead."
}, },
"Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE": { "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": "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": "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",
@@ -356,13 +566,6 @@
"ru": "Ой! Не включайте ID задачи (DEVX-N) в коммиты feature-веток.\n ID задачи будет добавлен автоматически при слиянии через CI.", "ru": "Ой! Не включайте ID задачи (DEVX-N) в коммиты feature-веток.\n ID задачи будет добавлен автоматически при слиянии через CI.",
"zh": "哎呀!不要在 feature 分支的提交中包含任务 ID (DEVX-N)。\n 任务 ID 将在通过 CI 合并时自动添加。" "zh": "哎呀!不要在 feature 分支的提交中包含任务 ID (DEVX-N)。\n 任务 ID 将在通过 CI 合并时自动添加。"
}, },
"Oops! Do not include task ID (GRM-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": {
"en": "Oops! Do not include task ID (GRM-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
"bg": "Опа! Не включвайте идентификатор на задача (GRM-N) в commit-и от feature клонове.\n Идентификаторът ще бъде добавен автоматично при сливане чрез CI.",
"de": "Ups! Keine Task-ID (GRM-N) in Feature-Branch-Commits einfügen.\n Die Task-ID wird beim Merge automatisch über CI hinzugefügt.",
"ru": "Ой! Не включайте ID задачи (GRM-N) в коммиты feature-веток.\n ID задачи будет добавлен автоматически при слиянии через CI.",
"zh": "哎呀!不要在 feature 分支的提交中包含任务 ID (GRM-N)。\n 任务 ID 将在通过 CI 合并时自动添加。"
},
"Oops! Gitea PyPI registry publish failed:\n{stderr}": { "Oops! Gitea PyPI registry publish failed:\n{stderr}": {
"en": "Oops! Gitea PyPI registry publish failed:\n{stderr}", "en": "Oops! Gitea PyPI registry publish failed:\n{stderr}",
"bg": "Опа! Публикуването в Gitea PyPI registry неуспешно:\n{stderr}", "bg": "Опа! Публикуването в Gitea PyPI registry неуспешно:\n{stderr}",
@@ -377,13 +580,6 @@
"ru": "Ой! Коммит в ветку master после ID задачи должен соответствовать conventional формату.\n Ожидается: DEVX-N: <type>: <description>\n Получено: {subject}", "ru": "Ой! Коммит в ветку master после ID задачи должен соответствовать conventional формату.\n Ожидается: DEVX-N: <type>: <description>\n Получено: {subject}",
"zh": "哎呀!master 分支提交在任务 ID 后必须遵循 conventional commit 格式。\n 预期格式: DEVX-N: <type>: <description>\n 实际: {subject}" "zh": "哎呀!master 分支提交在任务 ID 后必须遵循 conventional commit 格式。\n 预期格式: DEVX-N: <type>: <description>\n 实际: {subject}"
}, },
"Oops! Master branch commit must follow conventional format after task ID.\n Expected: GRM-N: <type>: <description>\n Got: {subject}": {
"en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: GRM-N: <type>: <description>\n Got: {subject}",
"bg": "Опа! Commit-ът в клона master трябва да следва конвенционален формат след идентификатора.\n Очаква се: GRM-N: <type>: <description>\n Получено: {subject}",
"de": "Ups! Master-Branch-Commit muss nach der Task-ID dem konventionellen Format folgen.\n Erwartet: GRM-N: <type>: <description>\n Erhalten: {subject}",
"ru": "Ой! Коммит в ветку master после ID задачи должен соответствовать conventional формату.\n Ожидается: GRM-N: <type>: <description>\n Получено: {subject}",
"zh": "哎呀!master 分支提交在任务 ID 后必须遵循 conventional commit 格式。\n 预期格式: GRM-N: <type>: <description>\n 实际: {subject}"
},
"Oops! Master branch commits must start with a task ID.\n Expected: DEVX-N: <conventional commit message>\n Got: {subject}": { "Oops! Master branch commits must start with a task ID.\n Expected: DEVX-N: <conventional commit message>\n Got: {subject}": {
"en": "Oops! Master branch commits must start with a task ID.\n Expected: DEVX-N: <conventional commit message>\n Got: {subject}", "en": "Oops! Master branch commits must start with a task ID.\n Expected: DEVX-N: <conventional commit message>\n Got: {subject}",
"bg": "Опа! Commit-ите в клона master трябва да започват с идентификатор на задача.\n Очаква се: DEVX-N: <conventional commit message>\n Получено: {subject}", "bg": "Опа! Commit-ите в клона master трябва да започват с идентификатор на задача.\n Очаква се: DEVX-N: <conventional commit message>\n Получено: {subject}",
@@ -391,28 +587,19 @@
"ru": "Ой! Коммиты в ветку master должны начинаться с ID задачи.\n Ожидается: DEVX-N: <conventional commit message>\n Получено: {subject}", "ru": "Ой! Коммиты в ветку master должны начинаться с ID задачи.\n Ожидается: DEVX-N: <conventional commit message>\n Получено: {subject}",
"zh": "哎呀!master 分支的提交必须以任务 ID 开头。\n 预期格式: DEVX-N: <conventional commit message>\n 实际: {subject}" "zh": "哎呀!master 分支的提交必须以任务 ID 开头。\n 预期格式: DEVX-N: <conventional commit message>\n 实际: {subject}"
}, },
"Oops! Master branch commits must start with a task ID.\n Expected: GRM-N: <conventional commit message>\n Got: {subject}": {
"en": "Oops! Master branch commits must start with a task ID.\n Expected: GRM-N: <conventional commit message>\n Got: {subject}",
"bg": "Опа! Commit-ите в клона master трябва да започват с идентификатор на задача.\n Очаква се: GRM-N: <conventional commit message>\n Получено: {subject}",
"de": "Ups! Master-Branch-Commits müssen mit einer Task-ID beginnen.\n Erwartet: GRM-N: <conventional commit message>\n Erhalten: {subject}",
"ru": "Ой! Коммиты в ветку master должны начинаться с ID задачи.\n Ожидается: GRM-N: <conventional commit message>\n Получено: {subject}",
"zh": "哎呀!master 分支的提交必须以任务 ID 开头。\n 预期格式: GRM-N: <conventional commit message>\n 实际: {subject}"
},
"Oops! No task ID found in .taskid file or branch name '{branch}'.": { "Oops! No task ID found in .taskid file or branch name '{branch}'.": {
"en": "Oops! No task ID found in .taskid file or branch name '{branch}'." "en": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
}, "bg": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
"Oops! PR title must follow format 'DEVX-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": { "de": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
"en": "Oops! PR title must follow format 'DEVX-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}", "ru": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
"bg": "Опа! Заглавието на PR трябва да следва формата 'DEVX-N: <заглавие на задача>'.\n Очаква се: {task_id}: <заглавие на задача>\n Получено: {pr_title}", "zh": "Oops! No task ID found in .taskid file or branch name '{branch}'."
"de": "Ups! PR-Titel muss dem Format 'DEVX-N: <Task-Titel>' folgen.\n Erwartet: {task_id}: <Task-Titel>\n Erhalten: {pr_title}",
"ru": "Ой! Заголовок PR должен соответствовать формату 'DEVX-N: <название задачи>'.\n Ожидается: {task_id}: <название задачи>\n Получено: {pr_title}",
"zh": "哎呀!PR 标题必须遵循格式 'DEVX-N: <任务标题>'。\n 预期格式: {task_id}: <任务标题>\n 实际: {pr_title}"
},
"Oops! PR title must follow format 'GRM-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
"en": "Oops! PR title must follow format 'GRM-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}"
}, },
"Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}": { "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}": {
"en": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}" "en": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
"bg": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
"de": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
"ru": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
"zh": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}"
}, },
"Oops! Package build failed:\n{stderr}": { "Oops! Package build failed:\n{stderr}": {
"en": "Oops! Package build failed:\n{stderr}", "en": "Oops! Package build failed:\n{stderr}",
@@ -429,10 +616,18 @@
"zh": "哎呀!PyPI 发布失败:\n{stderr}" "zh": "哎呀!PyPI 发布失败:\n{stderr}"
}, },
"PASSED: {pair}": { "PASSED: {pair}": {
"en": "PASSED: {pair}" "en": "PASSED: {pair}",
"bg": "PASSED: {pair}",
"de": "PASSED: {pair}",
"ru": "PASSED: {pair}",
"zh": "PASSED: {pair}"
}, },
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": { "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": {
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}" "en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}"
}, },
"PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.": { "PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.": {
"en": "PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.", "en": "PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.",
@@ -441,13 +636,6 @@
"ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.", "ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
"zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。" "zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
}, },
"PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release.": {
"en": "PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release.",
"bg": "PYPI_TOKEN не е зададен — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.",
"de": "PYPI_TOKEN nicht gesetzt — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.",
"ru": "PYPI_TOKEN не задан — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
"zh": "未设置 PYPI_TOKEN — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
},
"Published to Gitea PyPI registry.": { "Published to Gitea PyPI registry.": {
"en": "Published to Gitea PyPI registry.", "en": "Published to Gitea PyPI registry.",
"bg": "Публикувано в Gitea PyPI registry.", "bg": "Публикувано в Gitea PyPI registry.",
@@ -463,16 +651,32 @@
"zh": "已发布到 PyPI。" "zh": "已发布到 PyPI。"
}, },
"Pushed release commit to master.": { "Pushed release commit to master.": {
"en": "Pushed release commit to master." "en": "Pushed release commit to master.",
"bg": "Pushed release commit to master.",
"de": "Pushed release commit to master.",
"ru": "Pushed release commit to master.",
"zh": "Pushed release commit to master."
}, },
"Rebased and pushed. Retrying merge...": { "Rebased and pushed. Retrying merge...": {
"en": "Rebased and pushed. Retrying merge..." "en": "Rebased and pushed. Retrying merge...",
"bg": "Rebased and pushed. Retrying merge...",
"de": "Rebased and pushed. Retrying merge...",
"ru": "Rebased and pushed. Retrying merge...",
"zh": "Rebased and pushed. Retrying merge..."
}, },
"Release creation failed: {error}": { "Release creation failed: {error}": {
"en": "Release creation failed: {error}" "en": "Release creation failed: {error}",
"bg": "Release creation failed: {error}",
"de": "Release creation failed: {error}",
"ru": "Release creation failed: {error}",
"zh": "Release creation failed: {error}"
}, },
"Release must be run on master, currently on '{branch}'.": { "Release must be run on master, currently on '{branch}'.": {
"en": "Release must be run on master, currently on '{branch}'." "en": "Release must be run on master, currently on '{branch}'.",
"bg": "Release must be run on master, currently on '{branch}'.",
"de": "Release must be run on master, currently on '{branch}'.",
"ru": "Release must be run on master, currently on '{branch}'.",
"zh": "Release must be run on master, currently on '{branch}'."
}, },
"Repository configuration complete.": { "Repository configuration complete.": {
"en": "Repository configuration complete.", "en": "Repository configuration complete.",
@@ -489,101 +693,172 @@
"zh": "Runner 索引 {index} 超出范围 (0..{max})" "zh": "Runner 索引 {index} 超出范围 (0..{max})"
}, },
"Running lint checks...": { "Running lint checks...": {
"en": "Running lint checks..." "en": "Running lint checks...",
"bg": "Running lint checks...",
"de": "Running lint checks...",
"ru": "Running lint checks...",
"zh": "Running lint checks..."
}, },
"Running tests...": { "Running tests...": {
"en": "Running tests..." "en": "Running tests...",
"bg": "Running tests...",
"de": "Running tests...",
"ru": "Running tests...",
"zh": "Running tests..."
}, },
"Running: {scenario} on {platform}": { "Running: {scenario} on {platform}": {
"en": "Running: {scenario} on {platform}" "en": "Running: {scenario} on {platform}",
"bg": "Running: {scenario} on {platform}",
"de": "Running: {scenario} on {platform}",
"ru": "Running: {scenario} on {platform}",
"zh": "Running: {scenario} on {platform}"
}, },
"Skipping commit push — no staged changes.": { "Skipping commit push — no staged changes.": {
"en": "Skipping commit push — no staged changes." "en": "Skipping commit push — no staged changes.",
"bg": "Skipping commit push — no staged changes.",
"de": "Skipping commit push — no staged changes.",
"ru": "Skipping commit push — no staged changes.",
"zh": "Skipping commit push — no staged changes."
}, },
"Syncing {count} documentation pages to wiki...": { "Syncing {count} documentation pages to wiki...": {
"en": "Syncing {count} documentation pages to wiki..." "en": "Syncing {count} documentation pages to wiki...",
"bg": "Syncing {count} documentation pages to wiki...",
"de": "Syncing {count} documentation pages to wiki...",
"ru": "Syncing {count} documentation pages to wiki...",
"zh": "Syncing {count} documentation pages to wiki..."
}, },
"Tag v{version} already existed. Publish workflow should already have been triggered.": { "Tag v{version} already existed. Publish workflow should already have been triggered.": {
"en": "Tag v{version} already existed. Publish workflow should already have been triggered." "en": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"bg": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"de": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"ru": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"zh": "Tag v{version} already existed. Publish workflow should already have been triggered."
}, },
"Tag {tag} already exists, skipping creation.": { "Tag {tag} already exists, skipping creation.": {
"en": "Tag {tag} already exists, skipping creation." "en": "Tag {tag} already exists, skipping creation.",
"bg": "Tag {tag} already exists, skipping creation.",
"de": "Tag {tag} already exists, skipping creation.",
"ru": "Tag {tag} already exists, skipping creation.",
"zh": "Tag {tag} already exists, skipping creation."
}, },
"Task ID: {task_id}": { "Task ID: {task_id}": {
"en": "Task ID: {task_id}" "en": "Task ID: {task_id}",
"bg": "Task ID: {task_id}",
"de": "Task ID: {task_id}",
"ru": "Task ID: {task_id}",
"zh": "Task ID: {task_id}"
}, },
"Tests failed — refusing to release. Fix test failures first.\n{stderr}": { "Tests failed — refusing to release. Fix test failures first.\n{stderr}": {
"en": "Tests failed — refusing to release. Fix test failures first.\n{stderr}" "en": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
"bg": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
"de": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
"ru": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
"zh": "Tests failed — refusing to release. Fix test failures first.\n{stderr}"
}, },
"Tests passed.": { "Tests passed.": {
"en": "Tests passed." "en": "Tests passed.",
"bg": "Tests passed.",
"de": "Tests passed.",
"ru": "Tests passed.",
"zh": "Tests passed."
}, },
"Unit tests passed in {duration:.2f}s (under {max}s limit).": { "Unit tests passed in {duration:.2f}s (under {max}s limit).": {
"en": "Unit tests passed in {duration:.2f}s (under {max}s limit)." "en": "Unit tests passed in {duration:.2f}s (under {max}s limit).",
"bg": "Unit tests passed in {duration:.2f}s (under {max}s limit).",
"de": "Unit tests passed in {duration:.2f}s (under {max}s limit).",
"ru": "Unit tests passed in {duration:.2f}s (under {max}s limit).",
"zh": "Unit tests passed in {duration:.2f}s (under {max}s limit)."
}, },
"Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.": { "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.": {
"en": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures." "en": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
"bg": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
"de": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
"ru": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
"zh": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures."
}, },
"Updated version in {init}": { "Updated version in {init}": {
"en": "Updated version in {init}" "en": "Updated version in {init}",
"bg": "Updated version in {init}",
"de": "Updated version in {init}",
"ru": "Updated version in {init}",
"zh": "Updated version in {init}"
}, },
"Updated {changelog_file}": { "Updated {changelog_file}": {
"en": "Updated {changelog_file}" "en": "Updated {changelog_file}",
"bg": "Updated {changelog_file}",
"de": "Updated {changelog_file}",
"ru": "Updated {changelog_file}",
"zh": "Updated {changelog_file}"
}, },
"WARNING: --skip-tests passed — skipping test verification.": { "WARNING: --skip-tests passed — skipping test verification.": {
"en": "WARNING: --skip-tests passed — skipping test verification." "en": "WARNING: --skip-tests passed — skipping test verification.",
}, "bg": "WARNING: --skip-tests passed — skipping test verification.",
"WARNING: File {file} is empty — skipping.": { "de": "WARNING: --skip-tests passed — skipping test verification.",
"en": "WARNING: File {file} is empty — skipping." "ru": "WARNING: --skip-tests passed — skipping test verification.",
}, "zh": "WARNING: --skip-tests passed — skipping test verification."
"WARNING: File {file} not found — skipping.": {
"en": "WARNING: File {file} not found — skipping."
},
"Warning: No task ID (DEVX-N) found in commit message: {msg}. Skipping Vikunja update.": {
"en": "Warning: No task ID (DEVX-N) found in commit message: {msg}. Skipping Vikunja update.",
"bg": "Предупреждение: Не е намерен идентификатор на задача (DEVX-N) в съобщението за commit: {msg}. Пропускаме обновяването на Vikunja.",
"de": "Warnung: Keine Task-ID (DEVX-N) in Commit-Nachricht gefunden: {msg}. Vikunja-Update wird übersprungen.",
"ru": "Предупреждение: ID задачи (DEVX-N) не найден в сообщении коммита: {msg}. Пропуск обновления Vikunja.",
"zh": "警告:提交消息中未找到任务 ID (DEVX-N): {msg}。跳过 Vikunja 更新。"
},
"Warning: No task ID (GRM-N) found in commit message: {msg}. Skipping Vikunja update.": {
"en": "Warning: No task ID (GRM-N) found in commit message: {msg}. Skipping Vikunja update."
},
"Warning: VIKUNJA_TOKEN not set, skipping title match validation.": {
"en": "Warning: VIKUNJA_TOKEN not set, skipping title match validation."
},
"Warning: Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded — please update the Vikunja task manually.": {
"en": "Warning: Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded — please update the Vikunja task manually."
},
"Warning: git-cliff generated empty changelog.": {
"en": "Warning: git-cliff generated empty changelog."
}, },
"Wiki integrity check failed — {count} issue(s)": { "Wiki integrity check failed — {count} issue(s)": {
"en": "Wiki integrity check failed — {count} issue(s)" "en": "Wiki integrity check failed — {count} issue(s)",
"bg": "Wiki integrity check failed — {count} issue(s)",
"de": "Wiki integrity check failed — {count} issue(s)",
"ru": "Wiki integrity check failed — {count} issue(s)",
"zh": "Wiki integrity check failed — {count} issue(s)"
}, },
"Wiki verification failed — {failures} page(s) empty or mismatched": { "Wiki verification failed — {failures} page(s) empty or mismatched": {
"en": "Wiki verification failed — {failures} page(s) empty or mismatched" "en": "Wiki verification failed — {failures} page(s) empty or mismatched",
"bg": "Wiki verification failed — {failures} page(s) empty or mismatched",
"de": "Wiki verification failed — {failures} page(s) empty or mismatched",
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched",
"zh": "Wiki verification failed — {failures} page(s) empty or mismatched"
}, },
"[dry-run] Would commit: release: v{version}": { "[dry-run] Would commit: release: v{version}": {
"en": "[dry-run] Would commit: release: v{version}" "en": "[dry-run] Would commit: release: v{version}",
"bg": "[dry-run] Would commit: release: v{version}",
"de": "[dry-run] Would commit: release: v{version}",
"ru": "[dry-run] Would commit: release: v{version}",
"zh": "[dry-run] Would commit: release: v{version}"
}, },
"[dry-run] Would create tag: v{version}": { "[dry-run] Would create tag: v{version}": {
"en": "[dry-run] Would create tag: v{version}" "en": "[dry-run] Would create tag: v{version}",
"bg": "[dry-run] Would create tag: v{version}",
"de": "[dry-run] Would create tag: v{version}",
"ru": "[dry-run] Would create tag: v{version}",
"zh": "[dry-run] Would create tag: v{version}"
}, },
"[dry-run] Would create tag: {tag}": { "[dry-run] Would create tag: {tag}": {
"en": "[dry-run] Would create tag: {tag}" "en": "[dry-run] Would create tag: {tag}",
"bg": "[dry-run] Would create tag: {tag}",
"de": "[dry-run] Would create tag: {tag}",
"ru": "[dry-run] Would create tag: {tag}",
"zh": "[dry-run] Would create tag: {tag}"
}, },
"[dry-run] Would push commit to master": { "[dry-run] Would push commit to master": {
"en": "[dry-run] Would push commit to master" "en": "[dry-run] Would push commit to master",
"bg": "[dry-run] Would push commit to master",
"de": "[dry-run] Would push commit to master",
"ru": "[dry-run] Would push commit to master",
"zh": "[dry-run] Would push commit to master"
}, },
"[dry-run] Would sync page: {title} ({chars} chars)": { "[dry-run] Would sync page: {title} ({chars} chars)": {
"en": "[dry-run] Would sync page: {title} ({chars} chars)" "en": "[dry-run] Would sync page: {title} ({chars} chars)",
"bg": "[dry-run] Would sync page: {title} ({chars} chars)",
"de": "[dry-run] Would sync page: {title} ({chars} chars)",
"ru": "[dry-run] Would sync page: {title} ({chars} chars)",
"zh": "[dry-run] Would sync page: {title} ({chars} chars)"
}, },
"[dry-run] Would update {changelog_file}": { "[dry-run] Would update {changelog_file}": {
"en": "[dry-run] Would update {changelog_file}" "en": "[dry-run] Would update {changelog_file}",
"bg": "[dry-run] Would update {changelog_file}",
"de": "[dry-run] Would update {changelog_file}",
"ru": "[dry-run] Would update {changelog_file}",
"zh": "[dry-run] Would update {changelog_file}"
}, },
"[dry-run] Would update {init}": { "[dry-run] Would update {init}": {
"en": "[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": { "active": {
"en": "active", "en": "active",
@@ -607,10 +882,18 @@
"zh": "失败" "zh": "失败"
}, },
"git command failed ({cmd}): {stderr}": { "git command failed ({cmd}): {stderr}": {
"en": "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 returned empty version.": { "git-cliff returned empty version.": {
"en": "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."
}, },
"inactive": { "inactive": {
"en": "inactive", "en": "inactive",
@@ -639,5 +922,124 @@
"de": "unbekannt", "de": "unbekannt",
"ru": "неизвестно", "ru": "неизвестно",
"zh": "未知" "zh": "未知"
},
"\n{label} files changed ({count}):": {
"en": "\n{label} files changed ({count}):",
"bg": "\n{label} files changed ({count}):",
"de": "\n{label} files changed ({count}):",
"ru": "\n{label} files changed ({count}):",
"zh": "\n{label} files changed ({count}):"
},
"\n{tag} files ({count}):": {
"en": "\n{tag} files ({count}):",
"bg": "\n{tag} files ({count}):",
"de": "\n{tag} files ({count}):",
"ru": "\n{tag} files ({count}):",
"zh": "\n{tag} files ({count}):"
},
"Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
"en": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"bg": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"de": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"ru": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"zh": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}"
},
"Unknown check category '{check}'. Available: all, user-facing{tags}": {
"en": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"bg": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"de": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"ru": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"zh": "Unknown check category '{check}'. Available: all, user-facing{tags}"
},
"HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.": {
"en": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
"bg": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
"de": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
"ru": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
"zh": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag."
},
"HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.": {
"en": "HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.",
"bg": "HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.",
"de": "HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.",
"ru": "HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.",
"zh": "HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping."
},
"Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.": {
"en": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"bg": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"de": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"ru": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"zh": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update."
},
"PR number must be an integer, got: {pr_number}": {
"en": "PR number must be an integer, got: {pr_number}",
"bg": "PR number must be an integer, got: {pr_number}",
"de": "PR number must be an integer, got: {pr_number}",
"ru": "PR number must be an integer, got: {pr_number}",
"zh": "PR number must be an integer, got: {pr_number}"
},
"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."
},
"Repo must be in 'owner/name' format, got: {repo}": {
"en": "Repo must be in 'owner/name' format, got: {repo}",
"bg": "Repo must be in 'owner/name' format, got: {repo}",
"de": "Repo must be in 'owner/name' format, got: {repo}",
"ru": "Repo must be in 'owner/name' format, got: {repo}",
"zh": "Repo must be in 'owner/name' format, got: {repo}"
},
"Mapped file {file} is empty. Update the content or remove from mapping.json.": {
"en": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"de": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"ru": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"zh": "Mapped file {file} is empty. Update the content or remove from mapping.json."
},
"VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.": {
"en": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"bg": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"de": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"ru": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"zh": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles."
},
"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}"
},
"Mapped file {file} not found. Update mapping.json or create the file.": {
"en": "Mapped file {file} not found. Update mapping.json or create the file.",
"bg": "Mapped file {file} not found. Update mapping.json or create the file.",
"de": "Mapped file {file} not found. Update mapping.json or create the file.",
"ru": "Mapped file {file} not found. Update mapping.json or create the file.",
"zh": "Mapped file {file} not found. Update mapping.json or create the file."
},
"No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.": {
"en": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"bg": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"de": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID."
},
"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)."
} }
} }
+49 -13
View File
@@ -77,9 +77,10 @@ class TestValidatePrTitle:
class TestValidatePrTitleMatchesVikunja: class TestValidatePrTitleMatchesVikunja:
@patch.dict("os.environ", {}, clear=True) @patch.dict("os.environ", {}, clear=True)
def test_skips_when_no_token(self) -> None: def test_raises_when_no_token(self) -> None:
# Should not raise — just warn """Should raise ClickException when VIKUNJA_TOKEN is not set."""
validate_pr_title_matches_vikunja("DEVX-19: test", "DEVX-19") with pytest.raises(click.ClickException, match="VIKUNJA_TOKEN is not set"):
validate_pr_title_matches_vikunja("DEVX-19: test", "DEVX-19")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.VikunjaClient") @patch("devx.ci.auto_merge.VikunjaClient")
@@ -192,9 +193,12 @@ class TestRunCmd:
class TestMain: class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": ""}, clear=True) @patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient") @patch("devx.ci.auto_merge.GiteaClient")
def test_full_merge_flow(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] def test_full_merge_flow(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n") (tmp_path / ".taskid").write_text("DEVX-19\n")
@@ -210,7 +214,7 @@ class TestMain:
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"], ["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
mock_client.merge_pr.assert_called_once_with("7", "DEVX-19: fix: resolve timeout") mock_client.merge_pr.assert_called_once_with(7, "DEVX-19: fix: resolve timeout")
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) @patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
def test_no_token_raises(self) -> None: def test_no_token_raises(self) -> None:
@@ -240,9 +244,12 @@ class TestMain:
assert result.exit_code != 0 assert result.exit_code != 0
assert "format" in result.output.lower() assert "format" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient") @patch("devx.ci.auto_merge.GiteaClient")
def test_merge_behind_master_rebases(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] def test_merge_behind_master_rebases(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n") (tmp_path / ".taskid").write_text("DEVX-19\n")
@@ -267,9 +274,12 @@ class TestMain:
# Should have fetched, rebased, and pushed # Should have fetched, rebased, and pushed
assert mock_run.call_count == 5 # config name, config email, fetch, rebase, push assert mock_run.call_count == 5 # config name, config email, fetch, rebase, push
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient") @patch("devx.ci.auto_merge.GiteaClient")
def test_merge_failure_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] def test_merge_failure_raises(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n") (tmp_path / ".taskid").write_text("DEVX-19\n")
@@ -288,9 +298,12 @@ class TestMain:
assert result.exit_code != 0 assert result.exit_code != 0
assert "Merge failed" in result.output assert "Merge failed" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient") @patch("devx.ci.auto_merge.GiteaClient")
def test_no_conventional_msg_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] def test_no_conventional_msg_raises(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""When no conventional commit message is found in PR commits, raises.""" """When no conventional commit message is found in PR commits, raises."""
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n") (tmp_path / ".taskid").write_text("DEVX-19\n")
@@ -308,8 +321,31 @@ class TestMain:
assert "conventional commit" in result.output.lower() assert "conventional commit" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
def test_invalid_pr_number_raises(self, tmp_path, monkeypatch) -> None:
"""Non-integer PR number should raise."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
runner = CliRunner()
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: Test", "owner/repo", "not-a-number"])
assert result.exit_code != 0
assert "PR number must be an integer" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
def test_invalid_repo_format_raises(self, tmp_path, monkeypatch) -> None:
"""Repo without owner/name should raise."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
runner = CliRunner()
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: Test", "invalidrepo", "7"])
assert result.exit_code != 0
assert "owner/name" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient") @patch("devx.ci.auto_merge.GiteaClient")
def test_rebase_retry_failure_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] def test_rebase_retry_failure_raises(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""When rebase retry also fails, raises with helpful message.""" """When rebase retry also fails, raises with helpful message."""
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n") (tmp_path / ".taskid").write_text("DEVX-19\n")
+14 -19
View File
@@ -67,7 +67,7 @@ class TestCheckTranslationSet:
trans_file.write_text(json.dumps({"Used": {"en": "Used"}, "Dead": {"en": "Dead"}})) trans_file.write_text(json.dumps({"Used": {"en": "Used"}, "Dead": {"en": "Dead"}}))
result = check_translations.check_translation_set("test", src_dir, trans_file) result = check_translations.check_translation_set("test", src_dir, trans_file)
assert any("Dead key" in w for w in result.warnings) assert any("Dead key" in e for e in result.errors)
def test_missing_language(self, tmp_path: Path) -> None: def test_missing_language(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src" src_dir = tmp_path / "src"
@@ -77,7 +77,7 @@ class TestCheckTranslationSet:
trans_file.write_text(json.dumps({"Hello": {"en": "Hello"}})) trans_file.write_text(json.dumps({"Hello": {"en": "Hello"}}))
result = check_translations.check_translation_set("test", src_dir, trans_file) result = check_translations.check_translation_set("test", src_dir, trans_file)
assert any("Missing languages" in w for w in result.warnings) assert any("Missing languages" in e for e in result.errors)
def test_missing_translations_file(self, tmp_path: Path) -> None: def test_missing_translations_file(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src" src_dir = tmp_path / "src"
@@ -108,23 +108,19 @@ class TestMain:
result = runner.invoke(check_translations.main, []) result = runner.invoke(check_translations.main, [])
assert result.exit_code == 0 assert result.exit_code == 0
def test_strict_fails_on_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_errors_fail(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""--strict should fail if there are missing language warnings.""" """Errors should cause exit code 1."""
warn_result = check_translations.TranslationCheckResult( error_result = check_translations.TranslationCheckResult(
name="devx", name="devx",
src_dir=Path("/tmp"), src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"), trans_file=Path("/tmp/t.json"),
used_keys={"a"}, used_keys={"a"},
defined_keys={"a"}, defined_keys={"a"},
warnings=["Dead key: 'bar'"], errors=["Dead key: 'bar'"],
)
monkeypatch.setattr(
check_translations,
"check_translation_set",
lambda name, src, trans: warn_result,
) )
monkeypatch.setattr(check_translations, "check_translation_set", lambda name, src, trans: error_result)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(check_translations.main, ["--strict"]) result = runner.invoke(check_translations.main, [])
assert result.exit_code == 1 assert result.exit_code == 1
def test_fails_on_errors(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_fails_on_errors(self, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -311,26 +307,25 @@ class TestCollectKeys:
assert "in_progress" in keys assert "in_progress" in keys
class TestMainWarnings: class TestMainCleanPass:
def test_passes_with_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_passes_clean(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Should pass with exit code 0 and 'PASS with warnings' message.""" """Should pass with exit code 0 and 'PASS:' message when no errors."""
warn_result = check_translations.TranslationCheckResult( ok_result = check_translations.TranslationCheckResult(
name="devx", name="devx",
src_dir=Path("/tmp"), src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"), trans_file=Path("/tmp/t.json"),
used_keys={"a"}, used_keys={"a"},
defined_keys={"a"}, defined_keys={"a"},
warnings=["Dead key: 'bar'"],
) )
monkeypatch.setattr( monkeypatch.setattr(
check_translations, check_translations,
"check_translation_set", "check_translation_set",
lambda name, src, trans: warn_result, lambda name, src, trans: ok_result,
) )
runner = CliRunner() runner = CliRunner()
result = runner.invoke(check_translations.main, []) result = runner.invoke(check_translations.main, [])
assert result.exit_code == 0 assert result.exit_code == 0
assert "PASS with warnings" in result.output assert "PASS:" in result.output
class TestI18nProjectTranslations: class TestI18nProjectTranslations:
+169 -17
View File
@@ -21,6 +21,7 @@ from click.testing import CliRunner
import devx.ci.classify_changes as classify_changes_mod import devx.ci.classify_changes as classify_changes_mod
from devx.ci.classify_changes import ( from devx.ci.classify_changes import (
DEFAULT_INFRASTRUCTURE,
ChangeClassifier, ChangeClassifier,
ClassificationResult, ClassificationResult,
ClassifierConfig, ClassifierConfig,
@@ -108,11 +109,11 @@ class TestMatchesGlob:
class TestClassifierConfig: class TestClassifierConfig:
def test_from_pyproject_loads_config(self, tmp_path: Path) -> None: def test_from_pyproject_merges_with_defaults(self, tmp_path: Path) -> None:
pyproject = tmp_path / "pyproject.toml" pyproject = tmp_path / "pyproject.toml"
pyproject.write_text( pyproject.write_text(
"[tool.devx.classify]\n" "[tool.devx.classify]\n"
'infrastructure = [".gitea/**", "tests/**"]\n' 'infrastructure = ["scripts/**"]\n'
'infrastructure_overrides = ["src/pkg/__init__.py"]\n' 'infrastructure_overrides = ["src/pkg/__init__.py"]\n'
'user_facing_overrides = ["docs/important.py"]\n' 'user_facing_overrides = ["docs/important.py"]\n'
"\n" "\n"
@@ -120,39 +121,95 @@ class TestClassifierConfig:
'ansible = ["ansible/**"]\n' 'ansible = ["ansible/**"]\n'
) )
config = ClassifierConfig.from_pyproject(str(pyproject)) config = ClassifierConfig.from_pyproject(str(pyproject))
assert config.infrastructure == [".gitea/**", "tests/**"] # Project-specific path is merged with defaults
assert "scripts/**" in config.infrastructure
assert ".gitea/**" in config.infrastructure # from DEFAULT_INFRASTRUCTURE
assert "tests/**" in config.infrastructure # from DEFAULT_INFRASTRUCTURE
assert config.use_defaults is True
assert config.infrastructure_overrides == ["src/pkg/__init__.py"] assert config.infrastructure_overrides == ["src/pkg/__init__.py"]
assert config.user_facing_overrides == ["docs/important.py"] assert config.user_facing_overrides == ["docs/important.py"]
assert config.tags == {"ansible": ["ansible/**"]} assert config.tags == {"ansible": ["ansible/**"]}
def test_from_pyproject_missing_file(self) -> None: def test_from_pyproject_use_defaults_false(self, tmp_path: Path) -> None:
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx.classify]\nuse_defaults = false\ninfrastructure = [".gitea/**"]\n')
config = ClassifierConfig.from_pyproject(str(pyproject))
assert config.infrastructure == [".gitea/**"]
assert "tests/**" not in config.infrastructure # no defaults
assert config.use_defaults is False
def test_from_pyproject_missing_file_returns_defaults(self) -> None:
config = ClassifierConfig.from_pyproject("/nonexistent/pyproject.toml") config = ClassifierConfig.from_pyproject("/nonexistent/pyproject.toml")
assert config.infrastructure == [] assert config.infrastructure == list(DEFAULT_INFRASTRUCTURE)
assert config.infrastructure_overrides == [] assert config.infrastructure_overrides == []
assert config.user_facing_overrides == [] assert config.user_facing_overrides == []
assert config.tags == {} assert config.tags == {}
assert config.use_defaults is True
def test_from_pyproject_missing_section(self, tmp_path: Path) -> None: def test_from_pyproject_missing_section_returns_defaults(self, tmp_path: Path) -> None:
pyproject = tmp_path / "pyproject.toml" pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[project]\nname = "test"\n') pyproject.write_text('[project]\nname = "test"\n')
config = ClassifierConfig.from_pyproject(str(pyproject)) config = ClassifierConfig.from_pyproject(str(pyproject))
assert config.infrastructure == [] assert config.infrastructure == list(DEFAULT_INFRASTRUCTURE)
def test_from_pyproject_partial_config(self, tmp_path: Path) -> None: def test_from_pyproject_partial_config(self, tmp_path: Path) -> None:
pyproject = tmp_path / "pyproject.toml" pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx.classify]\ninfrastructure = [".gitea/**"]\n') pyproject.write_text('[tool.devx.classify]\ninfrastructure = ["scripts/**"]\n')
config = ClassifierConfig.from_pyproject(str(pyproject)) config = ClassifierConfig.from_pyproject(str(pyproject))
assert config.infrastructure == [".gitea/**"] assert "scripts/**" in config.infrastructure
assert ".gitea/**" in config.infrastructure # merged with defaults
assert config.infrastructure_overrides == [] assert config.infrastructure_overrides == []
assert config.user_facing_overrides == [] assert config.user_facing_overrides == []
assert config.tags == {} assert config.tags == {}
def test_defaults_are_empty(self) -> None: def test_defaults_are_empty_for_bare_constructor(self) -> None:
"""ClassifierConfig() without from_pyproject has empty lists."""
config = ClassifierConfig() config = ClassifierConfig()
assert config.infrastructure == [] assert config.infrastructure == []
assert config.infrastructure_overrides == [] assert config.infrastructure_overrides == []
assert config.user_facing_overrides == [] assert config.user_facing_overrides == []
assert config.tags == {} assert config.tags == {}
assert config.use_defaults is True
def test_default_infrastructure_is_non_empty(self) -> None:
"""The framework ships with a curated default infrastructure list."""
assert len(DEFAULT_INFRASTRUCTURE) > 0
assert ".gitea/**" in DEFAULT_INFRASTRUCTURE
assert "tests/**" 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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -438,6 +495,26 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
assert "release needed" in result.output assert "release needed" in result.output
@patch("devx.ci.classify_changes._get_classifier")
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_default_mode_displays_tags(
self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock
) -> None:
"""Default mode shows tag files when tags are configured."""
mock_changes.return_value = ["src/devx/cli.py", "ansible/tasks/main.yml"]
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={"ansible": ["ansible/**"]},
)
)
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "Ansible files" in result.output
assert "ansible/tasks/main.yml" in result.output
@patch("devx.ci.classify_changes.get_latest_tag", return_value="") @patch("devx.ci.classify_changes.get_latest_tag", return_value="")
def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None: def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None:
runner = CliRunner() runner = CliRunner()
@@ -480,19 +557,33 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
assert "release needed" in result.output assert "release needed" in result.output
@patch("devx.ci.classify_changes._get_classifier")
@patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_ansible_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: def test_check_ansible_true(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None:
mock_changes.return_value = ["ansible/tasks/main.yml", ".gitea/workflows/ci.yml"] mock_changes.return_value = ["ansible/tasks/main.yml", ".gitea/workflows/ci.yml"]
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={"ansible": ["ansible/**"]},
)
)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible", "--quiet"]) result = runner.invoke(main, ["--check", "ansible", "--quiet"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "true" in result.output assert "true" in result.output
@patch("devx.ci.classify_changes._get_classifier")
@patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_ansible_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: def test_check_ansible_false(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None:
mock_changes.return_value = ["src/devx/cli.py", ".gitea/workflows/ci.yml"] mock_changes.return_value = ["src/devx/cli.py", ".gitea/workflows/ci.yml"]
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={"ansible": ["ansible/**"]},
)
)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible", "--quiet"]) result = runner.invoke(main, ["--check", "ansible", "--quiet"])
assert result.exit_code == 0 assert result.exit_code == 0
@@ -516,15 +607,38 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
assert "false" in result.output assert "false" in result.output
@patch("devx.ci.classify_changes._get_classifier")
@patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_ansible_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: def test_check_ansible_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None:
mock_changes.return_value = ["ansible/tasks/main.yml"] mock_changes.return_value = ["ansible/tasks/main.yml"]
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={"ansible": ["ansible/**"]},
)
)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible"]) result = runner.invoke(main, ["--check", "ansible"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "Ansible changes detected" in result.output assert "Ansible changes detected" in result.output
@patch("devx.ci.classify_changes._get_classifier")
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_unknown_tag_raises(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None:
mock_changes.return_value = ["src/devx/cli.py"]
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={"ansible": ["ansible/**"]},
)
)
runner = CliRunner()
result = runner.invoke(main, ["--check", "nonexistent"])
assert result.exit_code != 0
assert "Unknown check category" in result.output
@patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_user_facing_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: def test_check_user_facing_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
@@ -536,7 +650,17 @@ class TestMain:
class TestGithubOutput: class TestGithubOutput:
def test_writes_outputs(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def _make_classifier_with_ansible(self) -> ChangeClassifier:
return ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**", "AGENTS.md"],
tags={"ansible": ["ansible/**"]},
)
)
@patch("devx.ci.classify_changes._get_classifier")
def test_writes_outputs(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt" gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object( with patch.object(
@@ -549,7 +673,9 @@ class TestGithubOutput:
assert "ansible-changed=true" in content assert "ansible-changed=true" in content
assert "user-facing-changed=true" in content assert "user-facing-changed=true" in content
def test_no_changes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @patch("devx.ci.classify_changes._get_classifier")
def test_no_changes(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt" gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]): with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
@@ -560,7 +686,9 @@ class TestGithubOutput:
assert "ansible-changed=false" in content assert "ansible-changed=false" in content
assert "user-facing-changed=false" in content assert "user-facing-changed=false" in content
def test_no_tags(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @patch("devx.ci.classify_changes._get_classifier")
def test_no_tags(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt" gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(classify_changes_mod, "get_latest_tag", return_value=""): with patch.object(classify_changes_mod, "get_latest_tag", return_value=""):
@@ -578,7 +706,9 @@ class TestGithubOutput:
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"]) result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
assert result.exit_code != 0 assert result.exit_code != 0
def test_workflow_only(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @patch("devx.ci.classify_changes._get_classifier")
def test_workflow_only(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt" gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object( with patch.object(
@@ -590,3 +720,25 @@ class TestGithubOutput:
content = gh_file.read_text() content = gh_file.read_text()
assert "ansible-changed=false" in content assert "ansible-changed=false" in content
assert "user-facing-changed=false" in content assert "user-facing-changed=false" in content
@patch("devx.ci.classify_changes._get_classifier")
def test_no_tags_outputs_all_tags_true(
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""When no tags exist, only user-facing-changed is written."""
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={},
)
)
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(classify_changes_mod, "get_latest_tag", return_value=""):
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "user-facing-changed=true" in content
# No tag outputs since no tags are configured
assert "ansible-changed" not in content
+29
View File
@@ -120,6 +120,35 @@ class TestQueryRunners:
result = query_runners("https://api.example.com", "token", "owner", "repo") result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 0 assert result == 0
@patch("devx.ci.discover_runners.requests.get")
def test_query_runners_403_no_warning(self, mock_get: MagicMock, capsys: pytest.CaptureFixture[str]) -> None:
"""403 on instance-level runners should not produce a warning (expected without admin scope)."""
responses = [
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(status_code=403, json=lambda: {"message": "forbidden"}),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
captured = capsys.readouterr()
assert "instance-level" not in captured.err
@patch("devx.ci.discover_runners.requests.get")
def test_instance_level_non_403_warns(self, mock_get: MagicMock, capsys: pytest.CaptureFixture[str]) -> None:
"""Non-200, non-403 status on instance-level runners should produce a warning."""
responses = [
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(status_code=500, json=lambda: {"message": "server error"}),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 2
captured = capsys.readouterr()
assert "instance-level" in captured.err
assert "500" in captured.err
class TestGetRunnerCount: class TestGetRunnerCount:
@patch("devx.ci.discover_runners.query_runners", return_value=5) @patch("devx.ci.discover_runners.query_runners", return_value=5)
+10
View File
@@ -240,6 +240,16 @@ class TestGithubEnv:
assert result.exit_code != 0 assert result.exit_code != 0
class TestRunnerIndexValidation:
def test_runner_index_zero_raises(self) -> None:
"""Runner index < 1 should raise."""
with patch("devx.molecule.distribute_molecule.discover_scenarios", return_value=["dummy"]):
runner = CliRunner()
result = runner.invoke(cli, ["--runner-index", "0", "--max-runners", "3"])
assert result.exit_code != 0
assert "out of range" in result.output
def test_main_module_block() -> None: def test_main_module_block() -> None:
import devx.molecule.distribute_molecule as dm import devx.molecule.distribute_molecule as dm
+9
View File
@@ -168,6 +168,15 @@ class TestCli:
assert result.exit_code == 0 assert result.exit_code == 0
assert "All molecule tests passed" in result.output assert "All molecule tests passed" in result.output
def test_invalid_pair_format_raises(self) -> None:
"""Pair with fewer than 2 parts should raise."""
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(cli, ["invalid_no_pipe"])
assert result.exit_code != 0
assert "Invalid pair format" in result.output
def test_failure_exits_nonzero(self) -> None: def test_failure_exits_nonzero(self) -> None:
from click.testing import CliRunner from click.testing import CliRunner
+11 -14
View File
@@ -132,13 +132,12 @@ class TestMain:
assert "VIKUNJA_TOKEN" in result.output assert "VIKUNJA_TOKEN" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_no_task_id_non_release_warns(self) -> None: def test_no_task_id_non_release_fails(self) -> None:
"""Non-release commits without DEVX-N prefix should warn, not fail.""" """Non-release commits without DEVX-N prefix should fail."""
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["fix: resolve bug"]) result = runner.invoke(main, ["fix: resolve bug"])
assert result.exit_code == 0 assert result.exit_code != 0
assert "No task ID" in result.output assert "No task ID" in result.output
assert "Skipping" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_release_commit_without_task_id_skips(self) -> None: def test_release_commit_without_task_id_skips(self) -> None:
@@ -179,8 +178,8 @@ class TestMain:
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("devx.ci.post_merge.VikunjaClient") @patch("devx.ci.post_merge.VikunjaClient")
def test_post_comment_failure_warns(self, mock_client_cls: MagicMock) -> None: def test_post_comment_failure_fails(self, mock_client_cls: MagicMock) -> None:
"""Vikunja API errors should warn, not fail — the merge already succeeded.""" """Vikunja API errors should fail — the task was not updated."""
mock_client = MagicMock() mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [ mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "DEVX-20"}, {"id": 267, "identifier": "DEVX-20"},
@@ -189,14 +188,13 @@ class TestMain:
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["DEVX-20: fix: bug"]) result = runner.invoke(main, ["DEVX-20: fix: bug"])
assert result.exit_code == 0 assert result.exit_code != 0
assert "Warning" in result.output assert "Vikunja API error" in result.output
assert "not updated" in result.output.lower()
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("devx.ci.post_merge.VikunjaClient") @patch("devx.ci.post_merge.VikunjaClient")
def test_mark_done_failure_warns(self, mock_client_cls: MagicMock) -> None: def test_mark_done_failure_fails(self, mock_client_cls: MagicMock) -> None:
"""Vikunja API errors should warn, not fail — the merge already succeeded.""" """Vikunja API errors should fail — the task was not updated."""
mock_client = MagicMock() mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [ mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "DEVX-20"}, {"id": 267, "identifier": "DEVX-20"},
@@ -206,9 +204,8 @@ class TestMain:
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["DEVX-20: fix: bug"]) result = runner.invoke(main, ["DEVX-20: fix: bug"])
assert result.exit_code == 0 assert result.exit_code != 0
assert "Warning" in result.output assert "Vikunja API error" in result.output
assert "not updated" in result.output.lower()
class TestGetGitCommitMessage: class TestGetGitCommitMessage:
+38 -6
View File
@@ -84,6 +84,13 @@ class TestGetBumpedVersion:
with pytest.raises(click.ClickException): with pytest.raises(click.ClickException):
get_bumped_version() get_bumped_version()
@patch("devx.ci.release.run_cmd")
def test_invalid_version_format_raises(self, mock_run_cmd: MagicMock) -> None:
"""Non-semver version from git-cliff should raise."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="not-a-version\n", stderr="")
with pytest.raises(click.ClickException, match="invalid version format"):
get_bumped_version()
class TestGetChangelog: class TestGetChangelog:
@patch("devx.ci.release.run_cmd") @patch("devx.ci.release.run_cmd")
@@ -318,12 +325,15 @@ class TestMain:
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@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(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None: def test_release_lock_skips_when_head_is_release_commit_and_tag_exists(
"""If HEAD is already a release commit, should skip to prevent duplicate releases.""" self, mock_run_cmd: MagicMock, mock_uf: MagicMock
# First call: git rev-parse (master), second: git log -1 (release commit) ) -> None:
"""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=""),
MagicMock(returncode=0, stdout="v0.5.0\n", stderr=""), # tag -l finds tag
] ]
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, []) result = runner.invoke(main, [])
@@ -331,6 +341,27 @@ class TestMain:
assert "already a release commit" in result.output assert "already a release commit" in result.output
assert "Skipping" in result.output assert "Skipping" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.get_changelog", return_value="## changelog")
@patch("devx.ci.release.create_and_push_tag", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_release_lock_recovers_when_tag_missing(
self, mock_run_cmd: MagicMock, mock_create_tag: MagicMock, mock_changelog: MagicMock
) -> None:
"""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 = [
MagicMock(returncode=0, stdout="master\n", stderr=""),
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
MagicMock(returncode=0, stdout="", stderr=""), # tag -l finds nothing
]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "tag v0.5.0 is missing" in result.output
assert "Recovering" in result.output
mock_create_tag.assert_called_once_with("0.5.0", "## changelog", False)
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@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)
@@ -360,7 +391,7 @@ class TestMain:
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0") @patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True) @patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.run_cmd") @patch("devx.ci.release.run_cmd")
def test_dry_run_empty_changelog( def test_dry_run_empty_changelog_fails(
self, self,
mock_run_cmd: MagicMock, mock_run_cmd: MagicMock,
mock_has: MagicMock, mock_has: MagicMock,
@@ -373,11 +404,12 @@ class TestMain:
mock_tag: MagicMock, mock_tag: MagicMock,
mock_user: MagicMock, mock_user: MagicMock,
) -> None: ) -> None:
"""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="")
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--dry-run"]) result = runner.invoke(main, ["--dry-run"])
assert result.exit_code == 0 assert result.exit_code != 0
assert "empty changelog" in result.output assert "empty changelog" in result.output.lower()
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("devx.ci.release.has_user_facing_changes", return_value=True) @patch("devx.ci.release.has_user_facing_changes", return_value=True)
+56 -6
View File
@@ -9,6 +9,7 @@ from click.testing import CliRunner
from devx.tools.setup import ( from devx.tools.setup import (
_configure_tea_login, _configure_tea_login,
_install_ansible_collections,
_install_pre_commit_hooks, _install_pre_commit_hooks,
_install_python_deps, _install_python_deps,
_run, _run,
@@ -58,6 +59,24 @@ class TestInstallPreCommitHooks:
assert "pre-push" in hook_types assert "pre-push" in hook_types
class TestInstallAnsibleCollections:
@patch("devx.tools.setup._run")
def test_installs_from_requirements(self, mock_run: MagicMock, tmp_path: Path) -> None:
req = tmp_path / "ansible" / "requirements.yml"
req.parent.mkdir(parents=True)
req.write_text("collections: []")
with patch("devx.tools.setup.Path") as mock_path:
mock_path.return_value.exists.return_value = True
mock_path.return_value.__str__ = lambda _: str(req)
_install_ansible_collections(".venv/bin")
mock_run.assert_called_once()
@patch("devx.tools.setup._run")
def test_skips_when_no_requirements(self, mock_run: MagicMock) -> None:
_install_ansible_collections(".venv/bin")
mock_run.assert_not_called()
class TestConfigureTeaLogin: class TestConfigureTeaLogin:
@patch("devx.tools.setup.shutil.which", return_value=None) @patch("devx.tools.setup.shutil.which", return_value=None)
def test_tea_not_installed(self, mock_which: MagicMock) -> None: def test_tea_not_installed(self, mock_which: MagicMock) -> None:
@@ -142,10 +161,12 @@ class TestMain:
@patch("devx.tools.setup._configure_tea_login") @patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._verify") @patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks") @patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps") @patch("devx.tools.setup._install_python_deps")
def test_main_success( def test_main_success(
self, self,
mock_install_deps: MagicMock, mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock, mock_install_hooks: MagicMock,
mock_verify: MagicMock, mock_verify: MagicMock,
mock_tea: MagicMock, mock_tea: MagicMock,
@@ -157,6 +178,7 @@ class TestMain:
result = runner.invoke(main, ["--bin", str(bin_dir)]) result = runner.invoke(main, ["--bin", str(bin_dir)])
assert result.exit_code == 0 assert result.exit_code == 0
mock_install_deps.assert_called_once() mock_install_deps.assert_called_once()
mock_install_ansible.assert_called_once()
mock_install_hooks.assert_called_once() mock_install_hooks.assert_called_once()
mock_verify.assert_called_once() mock_verify.assert_called_once()
mock_tea.assert_called_once() mock_tea.assert_called_once()
@@ -164,10 +186,12 @@ class TestMain:
@patch("devx.tools.setup._configure_tea_login") @patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._verify") @patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks") @patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps") @patch("devx.tools.setup._install_python_deps")
def test_main_no_pre_commit( def test_main_no_pre_commit(
self, self,
mock_install_deps: MagicMock, mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock, mock_install_hooks: MagicMock,
mock_verify: MagicMock, mock_verify: MagicMock,
mock_tea: MagicMock, mock_tea: MagicMock,
@@ -184,10 +208,33 @@ class TestMain:
@patch("devx.tools.setup._configure_tea_login") @patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._verify") @patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks") @patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps")
def test_main_no_ansible_collections(
self,
mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock,
mock_verify: MagicMock,
mock_tea: MagicMock,
tmp_path: Path,
) -> None:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
runner = CliRunner()
result = runner.invoke(main, ["--bin", str(bin_dir), "--no-ansible-collections"])
assert result.exit_code == 0
mock_install_ansible.assert_not_called()
@patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps") @patch("devx.tools.setup._install_python_deps")
def test_main_custom_extras( def test_main_custom_extras(
self, self,
mock_install_deps: MagicMock, mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock, mock_install_hooks: MagicMock,
mock_verify: MagicMock, mock_verify: MagicMock,
mock_tea: MagicMock, mock_tea: MagicMock,
@@ -203,10 +250,12 @@ class TestMain:
@patch("devx.tools.setup._configure_tea_login") @patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._verify") @patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks") @patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps") @patch("devx.tools.setup._install_python_deps")
def test_main_no_tea_login( def test_main_no_tea_login(
self, self,
mock_install_deps: MagicMock, mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock, mock_install_hooks: MagicMock,
mock_verify: MagicMock, mock_verify: MagicMock,
mock_tea: MagicMock, mock_tea: MagicMock,
@@ -233,9 +282,10 @@ def test_main_module_block(tmp_path: Path) -> None:
with patch.dict("os.environ", {}, clear=True): with patch.dict("os.environ", {}, clear=True):
with patch("devx.tools.setup._install_python_deps") as mock_deps: with patch("devx.tools.setup._install_python_deps") as mock_deps:
with patch("devx.tools.setup._install_pre_commit_hooks"): with patch("devx.tools.setup._install_pre_commit_hooks"):
with patch("devx.tools.setup._configure_tea_login"): with patch("devx.tools.setup._install_ansible_collections"):
with patch("devx.tools.setup._verify"): with patch("devx.tools.setup._configure_tea_login"):
runner = CliRunner() with patch("devx.tools.setup._verify"):
result = runner.invoke(main, ["--bin", str(bin_dir)]) runner = CliRunner()
assert result.exit_code == 0 result = runner.invoke(main, ["--bin", str(bin_dir)])
mock_deps.assert_called_once() assert result.exit_code == 0
mock_deps.assert_called_once()
+23 -8
View File
@@ -5,6 +5,7 @@ import json
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import click
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
@@ -63,6 +64,22 @@ class TestLoadMapping:
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
load_mapping() load_mapping()
def test_non_dict_mapping_raises(self, tmp_path: Path) -> None:
"""Non-dict mapping.json should raise."""
mapping_file = tmp_path / "mapping.json"
mapping_file.write_text('["not", "a", "dict"]')
with patch("devx.ci.sync_wiki.MAPPING_FILE", mapping_file):
with pytest.raises(click.ClickException, match="must be a dict"):
load_mapping()
def test_non_string_values_raise(self, tmp_path: Path) -> None:
"""Non-string values in mapping.json should raise."""
mapping_file = tmp_path / "mapping.json"
mapping_file.write_text('{"file.md": 123}')
with patch("devx.ci.sync_wiki.MAPPING_FILE", mapping_file):
with pytest.raises(click.ClickException, match="must be strings"):
load_mapping()
class TestReadDocContent: class TestReadDocContent:
def test_reads_file(self, tmp_path: Path) -> None: def test_reads_file(self, tmp_path: Path) -> None:
@@ -332,8 +349,8 @@ class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_file_not_found_warning(self, mock_client_cls: MagicMock) -> None: def test_file_not_found_fails(self, mock_client_cls: MagicMock) -> None:
"""Test that missing doc files are skipped with a warning.""" """Test that missing doc files cause an error, not a warning."""
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping: with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"missing.md": "Missing"}): with patch("devx.ci.sync_wiki.load_mapping", return_value={"missing.md": "Missing"}):
@@ -341,14 +358,13 @@ class TestMain:
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}): with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}):
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"]) result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
assert result.exit_code == 0 assert result.exit_code != 0
assert "not found" in result.output assert "not found" in result.output
assert "Skipped: 1" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_empty_doc_file_skipped(self, mock_client_cls: MagicMock) -> None: def test_empty_doc_file_fails(self, mock_client_cls: MagicMock) -> None:
"""Test that empty doc files are skipped with a warning.""" """Test that empty doc files cause an error, not a warning."""
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping: with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"empty.md": "Empty-Page"}): with patch("devx.ci.sync_wiki.load_mapping", return_value={"empty.md": "Empty-Page"}):
@@ -356,9 +372,8 @@ class TestMain:
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}): with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}):
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"]) result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
assert result.exit_code == 0 assert result.exit_code != 0
assert "empty" in result.output.lower() assert "empty" in result.output.lower()
assert "Skipped: 1" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")