Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
037d7b0d16 | ||
|
|
9cb706e387 | ||
|
|
5bd6158f2a | ||
|
|
05aa2ffe76 | ||
|
|
b9c3b55680 | ||
|
|
d398c8e971 | ||
|
|
39526d8e6a | ||
|
|
37730e2187 | ||
|
|
e2f66ca70a | ||
|
|
0b88c211f1 | ||
|
|
ea4ee0d303 | ||
|
|
16fba17b03 | ||
|
|
011cf3e093 | ||
|
|
daf99c5fed | ||
|
|
7154e3ad7c | ||
|
|
6053fb9fba | ||
|
|
e76741bfad | ||
|
|
f206a9cd8d | ||
|
|
b4b7428f9c | ||
|
|
0d9e76a838 | ||
|
|
034cbde2f7 | ||
|
|
e0abe6f176 | ||
|
|
15f6837dc2 | ||
|
|
b4dda91e24 | ||
|
|
3e21e774f7 | ||
|
|
7c11215e57 | ||
|
|
5384269c83 | ||
|
|
b3d0dd8ca7 | ||
|
|
a7dcaee5c6 | ||
|
|
02f8d3757b | ||
|
|
4311fb7648 | ||
|
|
2ead959fcf | ||
|
|
9a60009d29 | ||
|
|
c20dfd185a | ||
|
|
547fef4f27 | ||
|
|
23183df7c7 | ||
|
|
f382408115 | ||
|
|
19bec24f45 | ||
|
|
37772f21a9 | ||
|
|
b07132e3c6 | ||
|
|
7b3b604c2c | ||
|
|
53990dc10c | ||
|
|
8ea044a942 | ||
|
|
8411c92c95 | ||
|
|
621b9936c8 | ||
|
|
33063038a1 |
@@ -27,7 +27,7 @@ jobs:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 10
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
|
||||
- name: Documentation coverage check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
|
||||
@@ -55,16 +55,20 @@ src/devx/
|
||||
├── translations.json # Translation strings (en, bg)
|
||||
├── ci/ # CI/CD automation modules (run by workflows)
|
||||
│ ├── release.py # Automated versioning, tagging, changelog
|
||||
│ ├── publish.py # Build and publish to Gitea PyPI registry
|
||||
│ ├── publish.py # Build and publish to Gitea PyPI registry (--skip-build for non-Python repos)
|
||||
│ ├── auto_merge.py # Squash-merge PRs with task ID validation
|
||||
│ ├── _shared.py # Shared utilities (get_latest_tag)
|
||||
│ ├── classify_changes.py # User-facing vs workflow-only change detection
|
||||
│ ├── detect_release_commit.py # Detect release commits on master
|
||||
│ ├── validate_commit_msg.py # Conventional commit validation
|
||||
│ ├── pr_review.py # Automated PR review
|
||||
│ ├── post_merge.py # Vikunja task updates after merge
|
||||
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
|
||||
│ ├── push_badges.py # Generate and push quality badges
|
||||
│ ├── notify_failure.py # Create Gitea issues on CI failures
|
||||
│ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures)
|
||||
│ ├── notify_failure.py # Create Gitea issues on CI failures (--auto-login)
|
||||
│ ├── merge_junit.py # Merge JUnit XML reports from parallel runners
|
||||
│ ├── distribute_files.py # Distribute files across parallel runners
|
||||
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast + JUnit output
|
||||
│ ├── check_translations.py # Translation completeness check
|
||||
│ └── doc_coverage.py # Documentation coverage check
|
||||
├── tools/ # Developer tooling modules (run locally or by CI)
|
||||
@@ -73,7 +77,13 @@ src/devx/
|
||||
│ ├── check_test_speed.py # Measure unit test execution time
|
||||
│ ├── configure_repo.py # Branch protection and label setup
|
||||
│ └── generate_badges.py # Badge SVG generation
|
||||
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
|
||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||
├── distribute_molecule.py # Distribute molecule scenarios across runners (--roles-root for multi-role)
|
||||
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast + JUnit output (--roles-root, --junit-output)
|
||||
├── molecule_all.py # Run all molecule scenarios locally
|
||||
└── platforms.py # Supported molecule platforms
|
||||
```
|
||||
|
||||
### Key Design Principles
|
||||
|
||||
+158
-19
@@ -2,15 +2,164 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.1.0] - 2026-06-22
|
||||
## [0.9.7] - 2026-06-24
|
||||
|
||||
## [0.1.0] - 2026-06-22
|
||||
### Bug Fixes
|
||||
|
||||
## [0.1.0] - 2026-06-22
|
||||
- Add rootless socket fallback and GITHUB_ENV export
|
||||
|
||||
## [0.1.0] - 2026-06-22
|
||||
## [0.9.6] - 2026-06-24
|
||||
|
||||
## [0.1.0] - 2026-06-22
|
||||
### Bug Fixes
|
||||
|
||||
- Add Docker socket diagnostics to start_docker
|
||||
- Add Docker socket diagnostics to start_docker
|
||||
|
||||
## [0.9.5] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use host Docker socket with DOCKER_HOST fallback to local dockerd
|
||||
- Use host Docker socket with DOCKER_HOST fallback to local dockerd
|
||||
|
||||
## [0.9.4] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use separate Docker socket for DinD in CI
|
||||
|
||||
## [0.9.3] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use tempfile for dockerd log to fix CI permission error
|
||||
|
||||
## [0.9.2] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use vfs storage driver for Docker-in-Docker in CI
|
||||
|
||||
## [0.9.1] - 2026-06-23
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Always start dockerd in CI runner for molecule tests
|
||||
|
||||
## [0.9.0] - 2026-06-23
|
||||
|
||||
### Features
|
||||
|
||||
- Extract Docker daemon start to tested Python module
|
||||
|
||||
## [0.8.5] - 2026-06-23
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Retry pip install with --ignore-installed only on failure
|
||||
|
||||
## [0.8.4] - 2026-06-23
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add --ignore-installed to pip in CI to bypass debian packages
|
||||
|
||||
## [0.8.3] - 2026-06-23
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Lower check_test_speed threshold to 4 seconds
|
||||
- Pass --break-system-packages to pip in CI environments
|
||||
|
||||
## [0.8.2] - 2026-06-23
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Encode spaces in pair commands to survive shell word-splitting
|
||||
|
||||
## [0.8.1] - 2026-06-23
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Set fresh MOLECULE_HOME per pair to avoid stale config cache
|
||||
|
||||
## [0.8.0] - 2026-06-23
|
||||
|
||||
### Features
|
||||
|
||||
- Fix molecule platforms to use sleep infinity, add --platforms-file
|
||||
|
||||
## [0.7.0] - 2026-06-23
|
||||
|
||||
### Features
|
||||
|
||||
- Add per-test timing quality gate to check_test_speed
|
||||
|
||||
## [0.6.0] - 2026-06-23
|
||||
|
||||
### Features
|
||||
|
||||
- Add opentofu helpers, CLI entry points, shared utility, and CI improvements
|
||||
|
||||
## [0.5.0] - 2026-06-23
|
||||
|
||||
### Features
|
||||
|
||||
- Add tag verification, idempotency, and --verify mode to release script
|
||||
|
||||
## [0.4.4] - 2026-06-22
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Configurable task prefix and CWD-relative DOCS_DIR
|
||||
|
||||
## [0.4.3] - 2026-06-22
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Expand DEFAULT_INFRASTRUCTURE to cover all common project files
|
||||
|
||||
## [0.4.2] - 2026-06-22
|
||||
|
||||
### 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
|
||||
|
||||
@@ -18,18 +167,8 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
- Extract reusable dev/CI tools from GRM into devx package
|
||||
|
||||
## [unreleased]
|
||||
|
||||
### 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
|
||||
### Bug Fixes
|
||||
|
||||
- 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
@@ -39,8 +39,8 @@ sort_commits = "oldest"
|
||||
recurse_submodules = false
|
||||
|
||||
commit_preprocessors = [
|
||||
# Strip DEVX-N task ID prefix from merge commits so git-cliff sees conventional commits
|
||||
{ pattern = "^DEVX-\\d+\\s+", replace = "" },
|
||||
# Strip DEVX-N: task ID prefix from squash-merge commits so git-cliff sees conventional commits
|
||||
{ pattern = "^DEVX-\\d+:\\s+", replace = "" },
|
||||
]
|
||||
|
||||
commit_parsers = [
|
||||
@@ -66,3 +66,6 @@ commit_parsers = [
|
||||
features_always_bump_minor = true
|
||||
breaking_always_bump_major = false
|
||||
initial_tag = "0.1.0"
|
||||
# Refactor commits bump patch — structural changes to src/ or pyproject.toml
|
||||
# affect users even though no new feature was added.
|
||||
refactor_always_bump_patch = true
|
||||
|
||||
@@ -24,10 +24,22 @@ Detect whether the latest git commit is a release commit (`release: vX.Y.Z [skip
|
||||
|
||||
Discover available Gitea Actions runners for dynamic job distribution.
|
||||
|
||||
### `devx ci distribute-files`
|
||||
|
||||
Distribute files across parallel runners (round-robin). Used for splitting test suites or workloads across CI runners.
|
||||
|
||||
### `devx ci doc-coverage`
|
||||
|
||||
Check documentation coverage for CLI commands and major modules.
|
||||
|
||||
### `devx ci integration-guard`
|
||||
|
||||
Run pytest with cross-runner failure detection and JUnit XML output. Monitors other runners for failures and aborts early if a critical failure is detected.
|
||||
|
||||
### `devx ci merge-junit`
|
||||
|
||||
Merge multiple JUnit XML reports from parallel runners into a single consolidated report.
|
||||
|
||||
### `devx ci notify-failure`
|
||||
|
||||
Create a Gitea issue when a CI workflow fails.
|
||||
@@ -64,7 +76,13 @@ Validate commit messages for conventional commit format.
|
||||
|
||||
### `devx tools check-test-speed`
|
||||
|
||||
Run unit tests and enforce a maximum execution-time budget.
|
||||
Run unit tests and enforce execution-time budgets:
|
||||
- **Total suite time** must not exceed `--max-seconds` (default: 10s).
|
||||
- **Per-test time** — no individual test may exceed `--max-single-seconds` (default: 0.5s, 0 to disable).
|
||||
|
||||
```bash
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 10 --max-single-seconds 0.5
|
||||
```
|
||||
|
||||
### `devx tools configure-repo`
|
||||
|
||||
@@ -74,6 +92,17 @@ Configure repository: branch protection + labels via Gitea API.
|
||||
|
||||
Generate self-contained SVG badge files from project metrics.
|
||||
|
||||
### `devx tools generate-cliff-config`
|
||||
|
||||
Generate a `cliff.toml` configuration file with the correct task ID prefix.
|
||||
Eliminates the need to manually duplicate and maintain cliff.toml across
|
||||
repos that use devx.
|
||||
|
||||
```bash
|
||||
python -m devx.tools.generate_cliff_config --prefix GRM
|
||||
python -m devx.tools.generate_cliff_config --prefix GRM --force # overwrite existing
|
||||
```
|
||||
|
||||
### `devx tools install-checkmake`
|
||||
|
||||
Install checkmake (Makefile linter) if not already present.
|
||||
|
||||
+4
-3
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# pre-commit hook: fail if unit tests take longer than 10 seconds.
|
||||
# Aligned with CI timeout (ci.yml uses --max-seconds 10).
|
||||
# pre-commit hook: fail if unit tests are too slow.
|
||||
# Checks both total suite time (10s) and per-test time (0.5s).
|
||||
# Aligned with CI (ci.yml uses same thresholds).
|
||||
set -e
|
||||
export PYTHONPATH=src
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 10
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.9.7"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
@@ -21,7 +22,7 @@ def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
|
||||
try:
|
||||
body: dict[str, Any] = response.json() if response is not None else {}
|
||||
message: str = body.get("message", str(e))
|
||||
except Exception:
|
||||
except (json.JSONDecodeError, ValueError, AttributeError):
|
||||
message = str(e)
|
||||
return status, message
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Shared utilities for CI modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess # nosec B404
|
||||
|
||||
|
||||
def get_latest_tag() -> str:
|
||||
"""Get the latest git tag, or empty string if none exists."""
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["git", "describe", "--tags", "--abbrev=0"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
+18
-14
@@ -114,12 +114,11 @@ def validate_pr_title(pr_title: str, task_id: str) -> None:
|
||||
def get_vikunja_task_title(task_id: str) -> str:
|
||||
"""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 the token is set but the task is not found.
|
||||
Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found.
|
||||
"""
|
||||
token = os.environ.get("VIKUNJA_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)
|
||||
page = 1
|
||||
while True:
|
||||
@@ -145,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:
|
||||
"""Validate that PR title matches the Vikunja task title.
|
||||
|
||||
Skips validation if VIKUNJA_TOKEN is not set (local dev).
|
||||
Raises ClickException if the task is not found or the title doesn't match.
|
||||
Raises ClickException if VIKUNJA_TOKEN is not set, the task is not found,
|
||||
or the title doesn't match.
|
||||
"""
|
||||
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}"
|
||||
if pr_title != expected:
|
||||
raise click.ClickException(
|
||||
@@ -193,7 +188,16 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
||||
if not token:
|
||||
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)
|
||||
|
||||
task_id = read_taskid(branch)
|
||||
@@ -210,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)
|
||||
|
||||
# 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)
|
||||
if not conv_msg:
|
||||
raise click.ClickException(_("Could not extract conventional commit message from PR commits."))
|
||||
merge_title = f"{task_id}: {conv_msg}"
|
||||
|
||||
try:
|
||||
client.merge_pr(pr_number, merge_title)
|
||||
client.merge_pr(pr_num, merge_title)
|
||||
except APIError as e:
|
||||
if e.status == 405 and "behind" in e.message.lower():
|
||||
# Head branch is behind master — pull master and rebase, then retry
|
||||
@@ -229,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", "push", "--force-with-lease", "origin", f"HEAD:{branch}"])
|
||||
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:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
@@ -250,7 +254,7 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
||||
click.echo(
|
||||
_(
|
||||
"Nice! PR #{pr_number} squash-merged with title: {merge_title}",
|
||||
pr_number=pr_number,
|
||||
pr_number=pr_num,
|
||||
merge_title=merge_title,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -12,13 +12,13 @@ Checks performed (all fail with exit code 1 on error):
|
||||
translations file.
|
||||
- **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
|
||||
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::
|
||||
|
||||
python3 -m devx.ci.check_translations
|
||||
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
|
||||
@@ -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)
|
||||
result.dead_keys = result.defined_keys - result.used_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():
|
||||
missing = [lang for lang in SUPPORTED_LANGS if lang not in langs]
|
||||
if 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
|
||||
|
||||
@@ -170,8 +171,7 @@ def print_result(result: TranslationCheckResult) -> None:
|
||||
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.",
|
||||
)
|
||||
@click.option("--strict", is_flag=True, default=False, help="Treat warnings as errors.")
|
||||
def main(translations: tuple[Path, ...], strict: bool) -> None:
|
||||
def main(translations: tuple[Path, ...]) -> None:
|
||||
"""Check translation files for gaps, dead keys, and missing languages."""
|
||||
if not 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))
|
||||
|
||||
has_errors = False
|
||||
has_warnings = False
|
||||
for result in results:
|
||||
print_result(result)
|
||||
if result.errors:
|
||||
has_errors = True
|
||||
if result.warnings:
|
||||
has_warnings = True
|
||||
|
||||
click.echo()
|
||||
if has_errors:
|
||||
click.echo("FAIL: Translation check found errors.", err=True)
|
||||
sys.exit(1)
|
||||
if strict and has_warnings:
|
||||
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.")
|
||||
click.echo("PASS: All translations are complete and up to date.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -138,6 +138,7 @@ from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
from devx.ci._shared import get_latest_tag
|
||||
from devx.i18n import _
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -271,19 +272,28 @@ DEFAULT_INFRASTRUCTURE: list[str] = [
|
||||
# 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",
|
||||
@@ -485,19 +495,6 @@ def get_changed_files(base: str, head: str) -> list[str]:
|
||||
return output.split("\n")
|
||||
|
||||
|
||||
def get_latest_tag() -> str:
|
||||
"""Get the latest git tag, or empty string if none exists."""
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["git", "describe", "--tags", "--abbrev=0"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backward-compatible API (used by release.py and CI workflows)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
(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}"}
|
||||
total = 0
|
||||
@@ -54,8 +54,10 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
else:
|
||||
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
|
||||
try:
|
||||
@@ -67,8 +69,10 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
else:
|
||||
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)
|
||||
try:
|
||||
@@ -80,8 +84,10 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
elif r.status_code != 403: # 403 is expected without admin scope
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Distribute a list of files across N parallel runners (round-robin).
|
||||
|
||||
Generic file-based test distribution for CI matrix jobs. Discovers files
|
||||
matching a glob pattern, sorts them for deterministic ordering, then
|
||||
assigns them round-robin to *max_runners* groups. The assigned group for
|
||||
*runner_index* is written to ``$GITHUB_ENV`` for use by subsequent steps.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.ci.distribute_files \\
|
||||
--pattern "tests/integration/test_*.py" \\
|
||||
--runner-index 1 \\
|
||||
--max-runners 3 \\
|
||||
--github-env --skip-if-excess
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
|
||||
|
||||
def discover_files(pattern: str) -> list[str]:
|
||||
"""Return sorted list of file paths matching *pattern*."""
|
||||
return sorted(glob.glob(pattern))
|
||||
|
||||
|
||||
def distribute(files: list[str], max_runners: int) -> list[list[str]]:
|
||||
"""Split *files* into *max_runners* balanced groups (round-robin)."""
|
||||
groups: list[list[str]] = [[] for _ in range(max_runners)]
|
||||
for i, f in enumerate(files):
|
||||
groups[i % max_runners].append(f)
|
||||
return groups
|
||||
|
||||
|
||||
def files_for_runner(files: list[str], runner_index: int, max_runners: int) -> list[str]:
|
||||
"""Return the subset of files assigned to *runner_index* (0-based)."""
|
||||
groups = distribute(files, max_runners)
|
||||
if runner_index < 0 or runner_index >= len(groups):
|
||||
raise click.ClickException(
|
||||
_("Runner index {index} out of range (0..{max})", index=runner_index, max=max_runners - 1)
|
||||
)
|
||||
return groups[runner_index]
|
||||
|
||||
|
||||
def _write_github_env(key: str, value: str) -> None:
|
||||
gh_env = os.environ.get("GITHUB_ENV")
|
||||
if not gh_env:
|
||||
raise click.ClickException("GITHUB_ENV environment variable is not set")
|
||||
with open(gh_env, "a") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--pattern", required=True, help="Glob pattern for files to distribute.")
|
||||
@click.option(
|
||||
"--runner-index",
|
||||
type=int,
|
||||
default=None,
|
||||
help="One-based runner index. If omitted, prints all groups.",
|
||||
)
|
||||
@click.option(
|
||||
"--max-runners",
|
||||
type=int,
|
||||
default=DEFAULT_MAX_RUNNERS,
|
||||
show_default=True,
|
||||
help="Total number of parallel runners.",
|
||||
)
|
||||
@click.option(
|
||||
"--github-env",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Write ASSIGNED_FILES and SKIP to $GITHUB_ENV.",
|
||||
)
|
||||
@click.option(
|
||||
"--skip-if-excess",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.",
|
||||
)
|
||||
def main(pattern: str, runner_index: int | None, max_runners: int, github_env: bool, skip_if_excess: bool) -> None:
|
||||
files = discover_files(pattern)
|
||||
|
||||
if runner_index is None:
|
||||
groups = distribute(files, max_runners)
|
||||
for i, group in enumerate(groups):
|
||||
labels = " ".join(group) if group else "(none)"
|
||||
click.echo(f"Runner {i}: {labels}")
|
||||
return
|
||||
|
||||
if skip_if_excess and github_env and runner_index > max_runners:
|
||||
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
|
||||
_write_github_env("ASSIGNED_FILES", "")
|
||||
_write_github_env("SKIP", "true")
|
||||
return
|
||||
|
||||
if runner_index < 1:
|
||||
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
|
||||
|
||||
zero_based = runner_index - 1
|
||||
assigned = files_for_runner(files, zero_based, max_runners)
|
||||
encoded = "\n".join(assigned)
|
||||
|
||||
if github_env:
|
||||
_write_github_env("ASSIGNED_FILES", encoded)
|
||||
_write_github_env("SKIP", "false")
|
||||
click.echo(f"Assigned {len(assigned)} files to runner {runner_index}")
|
||||
return
|
||||
|
||||
click.echo(encoded)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run integration tests with cross-runner failure detection.
|
||||
|
||||
Wraps ``pytest`` with the same Gitea API polling mechanism used by
|
||||
``molecule_ci_guard``. If any other integration-tests matrix runner
|
||||
reports failure, the current pytest subprocess is killed and this runner
|
||||
exits early with code 1.
|
||||
|
||||
JUnit XML is generated via pytest's ``--junitxml`` flag (passed through
|
||||
to the pytest invocation).
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.ci.integration_guard \\
|
||||
--junit-output junit-results/runner-1.xml \\
|
||||
-- test_file1.py test_file2.py
|
||||
|
||||
# With pytest options
|
||||
python3 -m devx.ci.integration_guard \\
|
||||
--junit-output junit-results/runner-1.xml \\
|
||||
-- -x -v --tb=short test_file1.py
|
||||
|
||||
Environment variables:
|
||||
GITEA_URL Base URL of the Gitea instance.
|
||||
REPO_TOKEN API token with repo access.
|
||||
RUN_ID Workflow run ID (GITHUB_RUN_ID).
|
||||
JOB_NAME Base job name (GITHUB_JOB), e.g. "integration-tests".
|
||||
MATRIX_INDEX Current matrix index (runner-index).
|
||||
GITEA_REPOSITORY Repository in "owner/repo" format.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import signal
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
from devx.molecule.molecule_ci_guard import (
|
||||
poll_for_other_failures,
|
||||
)
|
||||
|
||||
POLL_INTERVAL = 10
|
||||
|
||||
|
||||
@click.command(context_settings={"ignore_unknown_options": True})
|
||||
@click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED, required=True)
|
||||
@click.option(
|
||||
"--junit-output",
|
||||
default=None,
|
||||
help="Path for JUnit XML output (passed to pytest as --junitxml).",
|
||||
)
|
||||
def cli(pytest_args: tuple[str, ...], junit_output: str | None) -> None:
|
||||
"""Run pytest with cross-runner failure detection."""
|
||||
gitea_url = os.environ.get("GITEA_URL", "")
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
run_id = int(os.environ.get("RUN_ID", "0"))
|
||||
job_name = os.environ.get("JOB_NAME", "integration-tests")
|
||||
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
|
||||
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx")
|
||||
owner, _sep, repo = repository.partition("/")
|
||||
if not owner or not repo:
|
||||
owner, repo = "oblachno-oss", "devx"
|
||||
|
||||
if not all([gitea_url, token, run_id]):
|
||||
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
|
||||
|
||||
stop_event = threading.Event()
|
||||
failed_event = threading.Event()
|
||||
|
||||
if gitea_url and token and run_id:
|
||||
poller = threading.Thread(
|
||||
target=poll_for_other_failures,
|
||||
args=(
|
||||
gitea_url,
|
||||
owner,
|
||||
repo,
|
||||
token,
|
||||
run_id,
|
||||
job_name,
|
||||
current_index,
|
||||
stop_event,
|
||||
failed_event,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
poller.start()
|
||||
|
||||
cmd = [sys.executable, "-m", "pytest"]
|
||||
if junit_output:
|
||||
cmd.extend(["--junitxml", junit_output])
|
||||
cmd.extend(pytest_args)
|
||||
|
||||
click.echo(f"Running: {' '.join(cmd)}")
|
||||
|
||||
process = subprocess.Popen( # nosec B603
|
||||
cmd,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
|
||||
try:
|
||||
while process.poll() is None:
|
||||
if failed_event.is_set():
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
process.wait()
|
||||
click.echo(_("Integration tests cancelled — another runner failed."))
|
||||
sys.exit(1)
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||
process.wait()
|
||||
sys.exit(1)
|
||||
finally:
|
||||
stop_event.set()
|
||||
|
||||
rc = process.returncode
|
||||
if rc != 0:
|
||||
click.echo(_("Integration tests failed with exit code {code}", code=rc))
|
||||
else:
|
||||
click.echo(_("Integration tests passed."))
|
||||
sys.exit(rc)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli()
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merge multiple JUnit XML reports into a single report.
|
||||
|
||||
Used by CI workflows to consolidate JUnit XML files produced by
|
||||
parallel matrix runners into a single merged report for archival
|
||||
and dashboard consumption.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.ci.merge_junit \\
|
||||
--pattern "junit-results/runner-*.xml" \\
|
||||
--output junit-merged.xml
|
||||
|
||||
Exit code is non-zero if any merged test suite reports failures,
|
||||
making this suitable as a CI gating step after matrix jobs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET # nosec B405
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
def merge_files(pattern: str) -> tuple[ET.Element, int, int]:
|
||||
"""Merge JUnit XML files matching *pattern* into a single ``<testsuites>`` element.
|
||||
|
||||
Returns ``(merged_element, total_tests, total_failures)``.
|
||||
If no files match, returns an empty ``<testsuites>`` with zero counts.
|
||||
"""
|
||||
files = sorted(glob.glob(pattern))
|
||||
merged = ET.Element("testsuites")
|
||||
total_tests = 0
|
||||
total_failures = 0
|
||||
|
||||
for f in files:
|
||||
tree = ET.parse(f) # nosec B314
|
||||
suite = tree.getroot()
|
||||
# Handle both <testsuites> (wrapper) and <testsuite> (single) roots
|
||||
if suite.tag == "testsuites":
|
||||
for child in suite:
|
||||
merged.append(child)
|
||||
total_tests += int(child.get("tests", 0))
|
||||
total_failures += int(child.get("failures", 0))
|
||||
else:
|
||||
merged.append(suite)
|
||||
total_tests += int(suite.get("tests", 0))
|
||||
total_failures += int(suite.get("failures", 0))
|
||||
|
||||
merged.set("tests", str(total_tests))
|
||||
merged.set("failures", str(total_failures))
|
||||
return merged, total_tests, total_failures
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--pattern",
|
||||
default="junit-results/runner-*.xml",
|
||||
show_default=True,
|
||||
help="Glob pattern for input JUnit XML files.",
|
||||
)
|
||||
@click.option(
|
||||
"--output",
|
||||
default="junit-merged.xml",
|
||||
show_default=True,
|
||||
help="Output path for the merged JUnit XML file.",
|
||||
)
|
||||
def main(pattern: str, output: str) -> None:
|
||||
merged, total_tests, total_failures = merge_files(pattern)
|
||||
|
||||
if total_tests == 0:
|
||||
click.echo(_("No JUnit reports found matching {pattern} — skipping merge.", pattern=pattern))
|
||||
return
|
||||
|
||||
ET.indent(merged)
|
||||
tree = ET.ElementTree(merged)
|
||||
tree.write(output, encoding="UTF-8", xml_declaration=True)
|
||||
click.echo(
|
||||
_(
|
||||
"Merged {count} reports: {tests} tests, {failures} failures → {output}",
|
||||
count=len(glob.glob(pattern)),
|
||||
tests=total_tests,
|
||||
failures=total_failures,
|
||||
output=output,
|
||||
)
|
||||
)
|
||||
|
||||
if total_failures > 0:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -10,13 +10,20 @@ Usage:
|
||||
--repo <owner/repo> \
|
||||
--run-id <run_id> \
|
||||
--workflow <workflow_name> \
|
||||
--commit <commit_sha>
|
||||
--commit <commit_sha> \
|
||||
--auto-login
|
||||
|
||||
With ``--auto-login``, the script configures the tea CLI login profile
|
||||
from ``REPO_TOKEN`` and ``DEVX_GITEA_API_URL`` before creating the issue,
|
||||
eliminating the need for a separate ``tea login add`` step in the workflow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
@@ -27,25 +34,76 @@ from devx.i18n import _
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger("devx")
|
||||
|
||||
|
||||
def _configure_tea_login(login_name: str = "devx") -> None:
|
||||
"""Configure tea CLI login from REPO_TOKEN and DEVX_GITEA_API_URL.
|
||||
|
||||
Idempotent: if a login with the same name already exists, it is not re-added.
|
||||
Skips silently if tea is not installed or REPO_TOKEN is not set.
|
||||
"""
|
||||
tea_bin = shutil.which("tea")
|
||||
if tea_bin is None:
|
||||
click.echo("notify_failure: tea not installed — skipping login configuration.")
|
||||
return
|
||||
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
click.echo("notify_failure: REPO_TOKEN not set — skipping login configuration.")
|
||||
return
|
||||
|
||||
gitea_url = GITEA_API_URL.replace("/api/v1", "")
|
||||
|
||||
result = subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "list", "--output", "simple"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0 and login_name in result.stdout:
|
||||
click.echo(f"notify_failure: tea login '{login_name}' already configured.")
|
||||
return
|
||||
|
||||
click.echo(f"notify_failure: configuring tea login '{login_name}' for {gitea_url}...")
|
||||
subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "default", login_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
|
||||
"""Create issue via tea CLI. Returns issue index.
|
||||
|
||||
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)
|
||||
|
||||
# Check if "bug" label exists
|
||||
# Check if "bug" label exists (best-effort)
|
||||
labels: list[str] = []
|
||||
with contextlib.suppress(TeaCLIError):
|
||||
try:
|
||||
existing_labels = tea.list_labels(repo)
|
||||
if any(label.get("name") == "bug" for label in existing_labels):
|
||||
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)
|
||||
if labels:
|
||||
with contextlib.suppress(TeaCLIError):
|
||||
try:
|
||||
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))
|
||||
|
||||
|
||||
@@ -54,11 +112,20 @@ def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
|
||||
@click.option("--run-id", required=True, help="CI run ID.")
|
||||
@click.option("--workflow", required=True, help="Workflow name.")
|
||||
@click.option("--commit", required=True, help="Commit SHA.")
|
||||
def main(repo: str, run_id: str, workflow: str, commit: str) -> None:
|
||||
@click.option(
|
||||
"--auto-login",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Configure tea CLI login from REPO_TOKEN before creating the issue.",
|
||||
)
|
||||
def main(repo: str, run_id: str, workflow: str, commit: str, auto_login: bool) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
if auto_login:
|
||||
_configure_tea_login()
|
||||
|
||||
title = f"[CI] {workflow} workflow failed (run #{run_id})"
|
||||
body = (
|
||||
f"The **{workflow}** workflow failed.\n\n"
|
||||
|
||||
+14
-13
@@ -13,7 +13,7 @@ import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
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.i18n import _
|
||||
|
||||
@@ -151,14 +151,16 @@ def main(commit_msg: str | None, commit_sha: str, from_git: bool, git_sha: str)
|
||||
)
|
||||
)
|
||||
return
|
||||
# Non-infrastructure commits without DEVX-N prefix — warn but don't fail
|
||||
click.echo(
|
||||
# Non-infrastructure commits without DEVX-N prefix — this is a
|
||||
# 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,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
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.update_task(vikunja_task_id, done=True)
|
||||
except APIError as e:
|
||||
# Vikunja is a project management tool — if it's down, the merge
|
||||
# still succeeded. Warn but don't fail the post-merge workflow.
|
||||
click.echo(
|
||||
# Vikunja API failures must be visible — the task was not updated
|
||||
# and needs manual intervention. Failing the CI job makes this visible.
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Warning: Vikunja API error (HTTP {status}): {message}. "
|
||||
"Task {task_id} was NOT updated. The merge succeeded — "
|
||||
"please update the Vikunja task manually.",
|
||||
"Vikunja API error (HTTP {status}): {message}. "
|
||||
"Task {task_id} was NOT updated. "
|
||||
"The merge succeeded but the Vikunja task needs manual update.",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
task_id=task_id,
|
||||
)
|
||||
)
|
||||
return
|
||||
) from e
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
|
||||
+24
-14
@@ -164,7 +164,14 @@ def _default_gitea_registry_url() -> str:
|
||||
"or a URL derived from GITEA_API_URL. When set, publishes to Gitea PyPI "
|
||||
"instead of standard PyPI (unless PYPI_TOKEN is also set).",
|
||||
)
|
||||
def main(tag: str, repo: str, registry_url: str | None) -> None:
|
||||
@click.option(
|
||||
"--skip-build",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip package build and PyPI publish (for non-Python repos that only "
|
||||
"need a Gitea release with git-cliff notes).",
|
||||
)
|
||||
def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> None:
|
||||
gitea_token = os.environ.get("REPO_TOKEN", "")
|
||||
if not gitea_token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
@@ -177,21 +184,24 @@ def main(tag: str, repo: str, registry_url: str | None) -> None:
|
||||
if not registry_url:
|
||||
registry_url = _default_gitea_registry_url()
|
||||
|
||||
build_package()
|
||||
if not skip_build:
|
||||
build_package()
|
||||
|
||||
if pypi_token:
|
||||
# Standard PyPI flow takes precedence when PYPI_TOKEN is set
|
||||
publish_to_pypi(pypi_token)
|
||||
elif registry_url:
|
||||
# Gitea PyPI registry flow
|
||||
publish_to_gitea_registry(registry_url, gitea_token)
|
||||
else:
|
||||
click.echo(
|
||||
_(
|
||||
"PYPI_TOKEN not set and no registry URL configured — "
|
||||
"skipping PyPI publish. No worries, we'll just create the Gitea release."
|
||||
if pypi_token:
|
||||
# Standard PyPI flow takes precedence when PYPI_TOKEN is set
|
||||
publish_to_pypi(pypi_token)
|
||||
elif registry_url:
|
||||
# Gitea PyPI registry flow
|
||||
publish_to_gitea_registry(registry_url, gitea_token)
|
||||
else:
|
||||
click.echo(
|
||||
_(
|
||||
"PYPI_TOKEN not set and no registry URL configured — "
|
||||
"skipping PyPI publish. No worries, we'll just create the Gitea release."
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
click.echo(_("--skip-build: skipping package build and PyPI publish."))
|
||||
|
||||
tea = TeaCLI(repo=repo)
|
||||
release_body = generate_release_notes(tag)
|
||||
|
||||
@@ -17,9 +17,11 @@ Usage::
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -160,13 +162,34 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
|
||||
default=False,
|
||||
help="Skip updating README with cache-busting URLs (for local testing).",
|
||||
)
|
||||
def main(output_dir: str, branch: str, no_readme_update: bool) -> None:
|
||||
@click.option(
|
||||
"--retries",
|
||||
default=1,
|
||||
type=int,
|
||||
help="Number of attempts on git push failures (default: 1, no retry). "
|
||||
"Between attempts, fetches latest master and waits 10s.",
|
||||
)
|
||||
def main(output_dir: str, branch: str, no_readme_update: bool, retries: int) -> None:
|
||||
"""Generate badges and push them to the badges branch."""
|
||||
fetch_latest_master(branch)
|
||||
generate_badges(output_dir)
|
||||
badges_sha = push_to_badges_branch(output_dir)
|
||||
if not no_readme_update:
|
||||
update_readme_with_badge_sha(badges_sha)
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
fetch_latest_master(branch)
|
||||
generate_badges(output_dir)
|
||||
badges_sha = push_to_badges_branch(output_dir)
|
||||
if not no_readme_update:
|
||||
update_readme_with_badge_sha(badges_sha)
|
||||
return
|
||||
except (subprocess.CalledProcessError, RuntimeError) as exc:
|
||||
last_error = exc
|
||||
if attempt < retries:
|
||||
click.echo(f"Badge push attempt {attempt}/{retries} failed — retrying: {exc}")
|
||||
time.sleep(10)
|
||||
with contextlib.suppress(subprocess.CalledProcessError):
|
||||
fetch_latest_master(branch)
|
||||
else:
|
||||
click.echo(f"Badge push failed after {retries} attempts: {exc}")
|
||||
raise click.ClickException(f"Badge push failed after {retries} attempts: {last_error}")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
+366
-20
@@ -23,8 +23,14 @@ This script is idempotent: if there are no new conventional commits since the
|
||||
last tag, it exits with a message and does nothing. If the tag already exists
|
||||
(e.g., from a partial previous run), it skips tag creation and only pushes.
|
||||
|
||||
**Tag consistency**: Before releasing, the script fetches remote tags and
|
||||
verifies all existing tags point to commits whose message matches the tag
|
||||
version. This prevents duplicate release commits (a common issue when CI
|
||||
checkouts don't fetch tags) and ensures tag/version/commit alignment.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 -m devx.ci.release [--dry-run] [--skip-tests]
|
||||
python3 -m devx.ci.release --verify # Check tag/version/release alignment
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -32,10 +38,12 @@ from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.ci._shared import get_latest_tag
|
||||
from devx.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=.
|
||||
from devx.i18n import _
|
||||
|
||||
@@ -65,20 +73,87 @@ def run_cmd(args: list[str], check: bool = True, capture: bool = True) -> subpro
|
||||
return result
|
||||
|
||||
|
||||
def get_latest_tag() -> str:
|
||||
"""Get the latest git tag, or empty string if none exists."""
|
||||
result = run_cmd(["git", "describe", "--tags", "--abbrev=0"], check=False)
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def tag_exists(tag: str) -> bool:
|
||||
"""Check if a git tag already exists."""
|
||||
result = run_cmd(["git", "tag", "-l", tag], check=False)
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
def get_tag_commit(tag: str) -> str:
|
||||
"""Get the commit hash a tag points to."""
|
||||
result = run_cmd(["git", "rev-list", "-n1", tag], check=False)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def get_head_commit() -> str:
|
||||
"""Get the current HEAD commit hash."""
|
||||
result = run_cmd(["git", "rev-parse", "HEAD"], check=False)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def fetch_tags() -> None:
|
||||
"""Fetch tags from remote to ensure local tag state is current.
|
||||
|
||||
This is critical in CI environments where a fresh checkout may not
|
||||
include tags from previous runs. Without this, the script may
|
||||
create duplicate release commits because ``tag_exists`` returns False
|
||||
for a tag that exists on the remote but wasn't fetched.
|
||||
"""
|
||||
result = run_cmd(["git", "fetch", "--tags", "origin"], check=False)
|
||||
if result.returncode != 0:
|
||||
# Don't fail hard — maybe there's no remote (local-only repo)
|
||||
click.echo(_("Warning: could not fetch tags from origin."))
|
||||
|
||||
|
||||
def get_all_tags() -> list[str]:
|
||||
"""Get all git tags sorted by version (newest first)."""
|
||||
result = run_cmd(["git", "tag", "-l", "--sort=-v:refname"], check=False)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
return [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
|
||||
|
||||
|
||||
def get_commit_version(commit: str) -> str | None:
|
||||
"""Extract version from a release commit message.
|
||||
|
||||
Returns the version string (e.g., '0.4.4') or None if the commit
|
||||
is not a release commit.
|
||||
"""
|
||||
result = run_cmd(["git", "log", "-1", "--pretty=%s", commit], check=False)
|
||||
match = re.match(r"^release: v(\d+\.\d+\.\d+)", result.stdout.strip())
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def verify_tag_consistency() -> list[str]:
|
||||
"""Verify all tags point to commits with matching version in message.
|
||||
|
||||
Returns a list of error messages for inconsistent tags.
|
||||
An empty list means all tags are consistent.
|
||||
|
||||
The first release (v0.1.0 or earliest tag) is exempt — initial releases
|
||||
often don't have a "release:" commit message (e.g., the initial commit
|
||||
serves as the first release).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
tags = get_all_tags()
|
||||
# Sort oldest first to identify the first tag
|
||||
sorted_tags = sorted(tags, key=lambda t: [int(x) for x in t.lstrip("v").split(".")])
|
||||
first_tag = sorted_tags[0] if sorted_tags else None
|
||||
for tag in tags:
|
||||
tag_version = tag.lstrip("v")
|
||||
commit_version = get_commit_version(tag)
|
||||
if commit_version is None:
|
||||
# First tag is allowed to point to a non-release commit (initial release)
|
||||
if tag == first_tag:
|
||||
continue
|
||||
errors.append(
|
||||
f" {tag} → points to non-release commit (expected 'release: v{tag_version}', got non-release commit)"
|
||||
)
|
||||
elif commit_version != tag_version:
|
||||
errors.append(f" {tag} → commit says 'release: v{commit_version}' (expected 'release: v{tag_version}')")
|
||||
return errors
|
||||
|
||||
|
||||
def get_bumped_version() -> str:
|
||||
"""Use git-cliff to calculate the next version from conventional commits."""
|
||||
result = run_cmd(["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG])
|
||||
@@ -86,7 +161,13 @@ def get_bumped_version() -> str:
|
||||
if not version:
|
||||
raise click.ClickException(_("git-cliff returned empty version."))
|
||||
# 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:
|
||||
@@ -117,9 +198,12 @@ def has_unreleased_changes(bumped_version: str | None = None) -> bool:
|
||||
latest = get_latest_tag()
|
||||
if not latest:
|
||||
return True
|
||||
# Check for any commits since the last tag
|
||||
# Check for any commits since the last tag, excluding release commits
|
||||
# (release commits themselves are not "unreleased changes" — they ARE
|
||||
# the release). This prevents duplicate release commits when the
|
||||
# script runs multiple times.
|
||||
result = run_cmd(
|
||||
["git", "log", f"{latest}..HEAD", "--oneline"],
|
||||
["git", "log", f"{latest}..HEAD", "--oneline", "--no-merges", "--invert-grep", "--grep=^release: v"],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
@@ -233,10 +317,27 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
||||
"""Create an annotated tag with the changelog as message and push it.
|
||||
|
||||
Returns True if the tag was created/pushed, False if it already existed.
|
||||
Raises an error if the tag exists but points to a different commit than HEAD.
|
||||
"""
|
||||
tag = f"v{new_version}"
|
||||
if tag_exists(tag):
|
||||
click.echo(_("Tag {tag} already exists, skipping creation.", tag=tag))
|
||||
# Verify the tag points to HEAD — if it points elsewhere, that's
|
||||
# a consistency error, not a skip condition.
|
||||
tag_commit = get_tag_commit(tag)
|
||||
head_commit = get_head_commit()
|
||||
if tag_commit != head_commit:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Tag {tag} already exists but points to {tag_commit} "
|
||||
"(expected HEAD {head_commit}). "
|
||||
"This indicates a tag/commit misalignment. "
|
||||
"Run 'python3 -m devx.ci.release --verify' for details.",
|
||||
tag=tag,
|
||||
tag_commit=tag_commit[:7],
|
||||
head_commit=head_commit[:7],
|
||||
)
|
||||
)
|
||||
click.echo(_("Tag {tag} already exists and points to HEAD. Skipping creation.", tag=tag))
|
||||
if not dry_run:
|
||||
# Ensure the existing tag is pushed
|
||||
run_cmd(["git", "push", "origin", tag], check=False)
|
||||
@@ -250,6 +351,178 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verification mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_init_version() -> str | None:
|
||||
"""Read __version__ from the version file."""
|
||||
try:
|
||||
with open(INIT_FILE) as f:
|
||||
content = f.read()
|
||||
match = re.search(r'^__version__\s*=\s*"([^"]*)"', content, flags=re.MULTILINE)
|
||||
return match.group(1) if match else None
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def get_changelog_versions() -> list[str]:
|
||||
"""Extract version numbers from CHANGELOG.md headers, in order."""
|
||||
try:
|
||||
with open(CHANGELOG_FILE) as f:
|
||||
content = f.read()
|
||||
return re.findall(r"^## \[(\d+\.\d+\.\d+)\]", content, flags=re.MULTILINE)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
|
||||
|
||||
def verify_alignment() -> int:
|
||||
"""Verify tag/version/changelog alignment. Returns exit code (0=ok, 1=issues)."""
|
||||
click.echo(_("=== Release Alignment Verification ===\n"))
|
||||
|
||||
has_issues = False
|
||||
|
||||
# 1. Check __version__ matches latest tag
|
||||
init_version = get_init_version()
|
||||
latest_tag = get_latest_tag()
|
||||
latest_tag_version = latest_tag.lstrip("v") if latest_tag else None
|
||||
|
||||
click.echo(_("Version file: {file}", file=INIT_FILE))
|
||||
if init_version:
|
||||
click.echo(f' __version__ = "{init_version}"')
|
||||
else:
|
||||
click.echo(" __version__ = NOT FOUND")
|
||||
has_issues = True
|
||||
|
||||
click.echo(_("\nLatest tag: {tag}", tag=latest_tag or "(none)"))
|
||||
if latest_tag_version and init_version:
|
||||
if latest_tag_version == init_version:
|
||||
click.echo(f" ✓ Tag version matches __version__ ({init_version})")
|
||||
else:
|
||||
click.echo(f" ✗ MISMATCH: tag={latest_tag_version}, __version__={init_version}")
|
||||
has_issues = True
|
||||
|
||||
# 2. Check all tags point to commits with matching version
|
||||
click.echo(_("\nTag → Commit alignment:"))
|
||||
tag_errors = verify_tag_consistency()
|
||||
all_tags = get_all_tags()
|
||||
if not all_tags:
|
||||
click.echo(" (no tags)")
|
||||
elif not tag_errors:
|
||||
click.echo(f" ✓ All {len(all_tags)} tags point to matching release commits")
|
||||
else:
|
||||
has_issues = True
|
||||
for err in tag_errors:
|
||||
click.echo(f" ✗ {err}")
|
||||
|
||||
# 3. Check CHANGELOG versions are in descending order
|
||||
click.echo(_("\nCHANGELOG version ordering:"))
|
||||
changelog_versions = get_changelog_versions()
|
||||
if not changelog_versions:
|
||||
click.echo(" (no versions in CHANGELOG)")
|
||||
else:
|
||||
# Check for duplicates
|
||||
seen: set[str] = set()
|
||||
duplicates: list[str] = []
|
||||
for v in changelog_versions:
|
||||
if v in seen:
|
||||
duplicates.append(v)
|
||||
seen.add(v)
|
||||
|
||||
# Check ordering (should be descending)
|
||||
is_ordered = all(changelog_versions[i] >= changelog_versions[i + 1] for i in range(len(changelog_versions) - 1))
|
||||
|
||||
if duplicates:
|
||||
has_issues = True
|
||||
click.echo(f" ✗ Duplicate entries: {', '.join(duplicates)}")
|
||||
elif not is_ordered:
|
||||
has_issues = True
|
||||
click.echo(f" ✗ Versions not in descending order: {changelog_versions}")
|
||||
else:
|
||||
click.echo(f" ✓ {len(changelog_versions)} versions, all in descending order")
|
||||
|
||||
# Check latest CHANGELOG version matches latest tag.
|
||||
# The CHANGELOG may have one unreleased section ahead of the latest tag
|
||||
# (e.g., CHANGELOG has 0.6.4 but latest tag is v0.6.3 — 0.6.4 is unreleased).
|
||||
if changelog_versions and latest_tag_version:
|
||||
if changelog_versions[0] == latest_tag_version:
|
||||
click.echo(f" ✓ Latest CHANGELOG version matches latest tag ({latest_tag_version})")
|
||||
elif latest_tag_version in changelog_versions:
|
||||
tag_idx = changelog_versions.index(latest_tag_version)
|
||||
# Latest tag should be at index 0 or 1 (0 = released, 1 = unreleased ahead)
|
||||
if tag_idx == 1:
|
||||
click.echo(
|
||||
f" ✓ Latest CHANGELOG version ({changelog_versions[0]}) is unreleased, "
|
||||
f"latest tag is {latest_tag_version}"
|
||||
)
|
||||
else:
|
||||
click.echo(
|
||||
f" ✗ MISMATCH: CHANGELOG latest={changelog_versions[0]}, "
|
||||
f"tag={latest_tag_version} (tag is at position {tag_idx})"
|
||||
)
|
||||
has_issues = True
|
||||
else:
|
||||
click.echo(f" ✗ MISMATCH: CHANGELOG latest={changelog_versions[0]}, tag={latest_tag_version}")
|
||||
has_issues = True
|
||||
|
||||
# 4. Check for untagged release commits.
|
||||
# Distinguish between:
|
||||
# - Truly untagged: no tag exists for that version (needs a tag)
|
||||
# - Duplicates: a tag for that version exists but on a different commit
|
||||
# (historical artifact from buggy release script — informational, not an error)
|
||||
click.echo(_("\nUntagged release commits:"))
|
||||
result = run_cmd(
|
||||
["git", "log", "--all", "--format=%h %s", "--grep=^release: v"],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
all_release_commits = result.stdout.strip().split("\n")
|
||||
all_tags_set = {t.lstrip("v") for t in get_all_tags()}
|
||||
truly_untagged: list[str] = []
|
||||
duplicates: list[str] = []
|
||||
for line in all_release_commits:
|
||||
short_hash = line.split()[0]
|
||||
tags_at = run_cmd(["git", "tag", "--points-at", short_hash], check=False)
|
||||
if not tags_at.stdout.strip():
|
||||
# Check if a tag for this version exists elsewhere
|
||||
match = re.search(r"release: v(\d+\.\d+\.\d+)", line)
|
||||
if match and match.group(1) in all_tags_set:
|
||||
duplicates.append(line)
|
||||
else:
|
||||
truly_untagged.append(line)
|
||||
if truly_untagged:
|
||||
has_issues = True
|
||||
click.echo(f" ✗ {len(truly_untagged)} untagged release commits (no tag for version):")
|
||||
for c in truly_untagged[:10]:
|
||||
click.echo(f" {c}")
|
||||
if len(truly_untagged) > 10:
|
||||
click.echo(f" ... and {len(truly_untagged) - 10} more")
|
||||
else:
|
||||
click.echo(" ✓ All release commits have tags")
|
||||
if duplicates:
|
||||
click.echo(f" ℹ {len(duplicates)} duplicate release commits (tag exists on different commit):")
|
||||
for c in duplicates[:5]:
|
||||
click.echo(f" {c}")
|
||||
if len(duplicates) > 5:
|
||||
click.echo(f" ... and {len(duplicates) - 5} more")
|
||||
else:
|
||||
click.echo(" (no release commits found)")
|
||||
|
||||
# Summary
|
||||
click.echo(_("\n=== Summary ==="))
|
||||
if has_issues:
|
||||
click.echo("✗ Issues found — see above for details.")
|
||||
return 1
|
||||
click.echo("✓ All checks passed — tags, versions, and changelog are aligned.")
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.")
|
||||
@click.option(
|
||||
@@ -258,7 +531,20 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
||||
default=False,
|
||||
help="Skip lint and test verification (NOT recommended — only for emergency releases).",
|
||||
)
|
||||
def main(dry_run: bool, skip_tests: bool) -> None:
|
||||
@click.option(
|
||||
"--verify",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Verify tag/version/changelog alignment and exit (no changes made).",
|
||||
)
|
||||
def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
||||
"""Automated release: calculate next version, update files, tag, and push.
|
||||
|
||||
Use --verify to check tag/version/changelog alignment without making changes.
|
||||
"""
|
||||
if verify:
|
||||
sys.exit(verify_alignment())
|
||||
|
||||
# Ensure we're on master (skip this check in dry-run mode for PR validation)
|
||||
branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
|
||||
if branch != "master" and not dry_run:
|
||||
@@ -271,16 +557,71 @@ def main(dry_run: bool, skip_tests: bool) -> None:
|
||||
)
|
||||
)
|
||||
|
||||
# Release lock: if HEAD is already a release commit, another release
|
||||
# run is in progress (or already completed). Skip to prevent duplicate tags.
|
||||
head_msg = run_cmd(["git", "log", "-1", "--pretty=%s"]).stdout.strip()
|
||||
if re.match(r"^release: v\d+\.\d+\.\d+", head_msg):
|
||||
# Fetch tags from remote to ensure local tag state is current.
|
||||
# This is critical in CI where a fresh checkout may not include tags
|
||||
# from previous runs. Without this, tag_exists() returns False for
|
||||
# tags that exist on the remote, leading to duplicate release commits.
|
||||
if not dry_run:
|
||||
fetch_tags()
|
||||
|
||||
# Pre-flight: verify existing tags are consistent. If any tag points
|
||||
# to a commit with a mismatched version, abort before creating more
|
||||
# inconsistencies.
|
||||
tag_errors = verify_tag_consistency()
|
||||
if tag_errors:
|
||||
click.echo(_("ERROR: Tag consistency check failed. Existing tags are misaligned:"))
|
||||
for err in tag_errors:
|
||||
click.echo(err)
|
||||
click.echo(
|
||||
_(
|
||||
"HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping.",
|
||||
msg=head_msg,
|
||||
"\nFix the misaligned tags before creating new releases. "
|
||||
"Run 'python3 -m devx.ci.release --verify' for a full report."
|
||||
)
|
||||
)
|
||||
raise click.ClickException(_("Tag consistency check failed."))
|
||||
|
||||
# Release lock: if HEAD is already a release commit, check if the tag
|
||||
# exists AND points to HEAD. If the tag is missing (e.g., tag push
|
||||
# failed in a previous run), create and push it. If the tag exists
|
||||
# but points elsewhere, that's an error.
|
||||
head_msg = run_cmd(["git", "log", "-1", "--pretty=%s"]).stdout.strip()
|
||||
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):
|
||||
tag_commit = get_tag_commit(release_tag)
|
||||
head_commit = get_head_commit()
|
||||
if tag_commit != head_commit:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"HEAD is a release commit for v{version} but tag {tag} "
|
||||
"points to a different commit ({tag_commit} vs HEAD {head_commit}). "
|
||||
"This indicates a tag/commit misalignment.",
|
||||
version=release_version,
|
||||
tag=release_tag,
|
||||
tag_commit=tag_commit[:7],
|
||||
head_commit=head_commit[:7],
|
||||
)
|
||||
)
|
||||
click.echo(
|
||||
_(
|
||||
"HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
|
||||
msg=head_msg,
|
||||
tag=release_tag,
|
||||
)
|
||||
)
|
||||
return
|
||||
# Tag is missing — recover by creating and pushing it
|
||||
click.echo(
|
||||
_(
|
||||
"HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
|
||||
msg=head_msg,
|
||||
tag=release_tag,
|
||||
)
|
||||
)
|
||||
changelog = get_changelog(release_version)
|
||||
create_and_push_tag(release_version, changelog, dry_run)
|
||||
return
|
||||
|
||||
# Check if any user-facing files changed since the last tag.
|
||||
@@ -315,7 +656,12 @@ def main(dry_run: bool, skip_tests: bool) -> None:
|
||||
# Generate changelog
|
||||
changelog = get_changelog(new_version)
|
||||
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:
|
||||
click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog))
|
||||
|
||||
@@ -34,14 +34,31 @@ from devx.i18n import _
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DOCS_DIR = Path(__file__).resolve().parent.parent.parent.parent / "docs"
|
||||
# DOCS_DIR is the repo's docs/ directory. When devx is installed as a
|
||||
# package (e.g., in .venv/lib/python3.12/site-packages/devx/), the
|
||||
# __file__-relative path would point inside the venv, not the repo.
|
||||
# Use DEVX_DOCS_DIR env var if set, otherwise fall back to ./docs
|
||||
# (relative to the current working directory, which is the repo root
|
||||
# in CI and local development).
|
||||
DOCS_DIR = Path(os.environ.get("DEVX_DOCS_DIR", "docs"))
|
||||
MAPPING_FILE = DOCS_DIR / "mapping.json"
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
@@ -239,14 +256,14 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
|
||||
try:
|
||||
content = read_doc_content(file_path)
|
||||
except FileNotFoundError:
|
||||
click.echo(_("WARNING: File {file} not found — skipping.", file=file_path))
|
||||
skipped += 1
|
||||
continue
|
||||
raise click.ClickException(
|
||||
_("Mapped file {file} not found. Update mapping.json or create the file.", file=file_path)
|
||||
) from None
|
||||
|
||||
if not content.strip():
|
||||
click.echo(_("WARNING: File {file} is empty — skipping.", file=file_path))
|
||||
skipped += 1
|
||||
continue
|
||||
raise click.ClickException(
|
||||
_("Mapped file {file} is empty. Update the content or remove from mapping.json.", file=file_path)
|
||||
) from None
|
||||
|
||||
result = sync_page(client, page_title, content, existing_pages, dry_run)
|
||||
if result == "created":
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
"""Validate commit messages for devx.
|
||||
|
||||
Rules:
|
||||
- On feature branches: conventional commits ONLY, must NOT include DEVX-N prefix.
|
||||
- On feature branches: conventional commits ONLY, must NOT include <PREFIX>-N prefix.
|
||||
- On master branch: must follow '<task-id>: <conventional commit>' pattern,
|
||||
e.g. 'DEVX-24: fix: resolve timeout'.
|
||||
|
||||
The task ID prefix is configurable via the ``DEVX_TASK_PREFIX`` environment
|
||||
variable (default: ``DEVX``). Projects consuming devx (e.g., GRM) set
|
||||
their own prefix (e.g., ``GRM``) so the validator enforces the correct
|
||||
task ID format for each project.
|
||||
"""
|
||||
|
||||
import re
|
||||
@@ -12,10 +17,10 @@ import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
from devx.config import CONVENTIONAL_RE
|
||||
from devx.config import CONVENTIONAL_RE, TASK_PREFIX
|
||||
from devx.i18n import _
|
||||
|
||||
MASTER_TASK_ID_RE = re.compile(r"^DEVX-\d+:")
|
||||
MASTER_TASK_ID_RE = re.compile(rf"^{TASK_PREFIX}-\d+:")
|
||||
|
||||
|
||||
def first_line(text: str) -> str:
|
||||
@@ -51,8 +56,9 @@ def main(commit_msg_file: str, branch: str | None) -> None:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Master branch commits must start with a task ID.\n"
|
||||
" Expected: DEVX-N: <conventional commit message>\n"
|
||||
" Expected: {prefix}-N: <conventional commit message>\n"
|
||||
" Got: {subject}",
|
||||
prefix=TASK_PREFIX,
|
||||
subject=subject,
|
||||
)
|
||||
)
|
||||
@@ -61,8 +67,9 @@ def main(commit_msg_file: str, branch: str | None) -> None:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Master branch commit must follow conventional format after task ID.\n"
|
||||
" Expected: DEVX-N: <type>: <description>\n"
|
||||
" Expected: {prefix}-N: <type>: <description>\n"
|
||||
" Got: {subject}",
|
||||
prefix=TASK_PREFIX,
|
||||
subject=subject,
|
||||
)
|
||||
)
|
||||
@@ -71,8 +78,9 @@ def main(commit_msg_file: str, branch: str | None) -> None:
|
||||
if MASTER_TASK_ID_RE.match(subject):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Do not include task ID (DEVX-N) in feature branch commits.\n"
|
||||
" The task ID will be added automatically on merge via CI."
|
||||
"Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n"
|
||||
" The task ID will be added automatically on merge via CI.",
|
||||
prefix=TASK_PREFIX,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -151,6 +151,27 @@ def ci_validate_commit_msg(args: tuple[str, ...]) -> None:
|
||||
_run_module("devx.ci.validate_commit_msg", list(args))
|
||||
|
||||
|
||||
@ci.command("distribute-files")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_distribute_files(args: tuple[str, ...]) -> None:
|
||||
"""Distribute files across parallel runners (round-robin)."""
|
||||
_run_module("devx.ci.distribute_files", list(args))
|
||||
|
||||
|
||||
@ci.command("merge-junit")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_merge_junit(args: tuple[str, ...]) -> None:
|
||||
"""Merge multiple JUnit XML reports into a single report."""
|
||||
_run_module("devx.ci.merge_junit", list(args))
|
||||
|
||||
|
||||
@ci.command("integration-guard")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_integration_guard(args: tuple[str, ...]) -> None:
|
||||
"""Run pytest with cross-runner failure detection and JUnit output."""
|
||||
_run_module("devx.ci.integration_guard", list(args))
|
||||
|
||||
|
||||
@cli.group()
|
||||
def tools() -> None:
|
||||
"""Development tool commands."""
|
||||
@@ -177,6 +198,13 @@ def tools_generate_badges(args: tuple[str, ...]) -> None:
|
||||
_run_module("devx.tools.generate_badges", list(args))
|
||||
|
||||
|
||||
@tools.command("generate-cliff-config")
|
||||
@click.argument("args", nargs=-1)
|
||||
def tools_generate_cliff_config(args: tuple[str, ...]) -> None:
|
||||
"""Generate a cliff.toml configuration file for the project."""
|
||||
_run_module("devx.tools.generate_cliff_config", list(args))
|
||||
|
||||
|
||||
@tools.command("install-checkmake")
|
||||
@click.argument("args", nargs=-1)
|
||||
def tools_install_checkmake(args: tuple[str, ...]) -> None:
|
||||
|
||||
@@ -25,10 +25,11 @@ from pathlib import Path
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
from devx.molecule.platforms import PLATFORMS
|
||||
from devx.molecule.platforms import PLATFORMS, load_platforms
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule")
|
||||
DEFAULT_ROLES_ROOT = Path("ansible/roles")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -40,7 +41,8 @@ class TestPair:
|
||||
|
||||
def encode(self) -> str:
|
||||
"""Serialize to a pipe-delimited string for CI consumption."""
|
||||
return f"{self.scenario}|{self.platform['name']}|{self.platform['image']}|{self.platform['command']}"
|
||||
cmd = self.platform["command"].replace(" ", "__SPACE__")
|
||||
return f"{self.scenario}|{self.platform['name']}|{self.platform['image']}|{cmd}"
|
||||
|
||||
@staticmethod
|
||||
def decode(encoded: str) -> TestPair:
|
||||
@@ -48,7 +50,31 @@ class TestPair:
|
||||
parts = encoded.split("|")
|
||||
return TestPair(
|
||||
scenario=parts[0],
|
||||
platform={"name": parts[1], "image": parts[2], "command": parts[3]},
|
||||
platform={"name": parts[1], "image": parts[2], "command": parts[3].replace("__SPACE__", " ")},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MultiRoleTestPair:
|
||||
"""A (role, scenario, platform) combination for multi-role projects."""
|
||||
|
||||
role: str
|
||||
scenario: str
|
||||
platform: dict[str, str]
|
||||
|
||||
def encode(self) -> str:
|
||||
"""Serialize to a pipe-delimited string: ``role|scenario|platform_name|image|command``."""
|
||||
cmd = self.platform["command"].replace(" ", "__SPACE__")
|
||||
return f"{self.role}|{self.scenario}|{self.platform['name']}|{self.platform['image']}|{cmd}"
|
||||
|
||||
@staticmethod
|
||||
def decode(encoded: str) -> MultiRoleTestPair:
|
||||
"""Deserialize from a pipe-delimited string."""
|
||||
parts = encoded.split("|")
|
||||
return MultiRoleTestPair(
|
||||
role=parts[0],
|
||||
scenario=parts[1],
|
||||
platform={"name": parts[2], "image": parts[3], "command": parts[4].replace("__SPACE__", " ")},
|
||||
)
|
||||
|
||||
|
||||
@@ -62,6 +88,33 @@ def discover_scenarios(root: Path | None = None) -> list[str]:
|
||||
return sorted(scenarios)
|
||||
|
||||
|
||||
def discover_multi_role_scenarios(roles_root: Path | None = None) -> list[tuple[str, str]]:
|
||||
"""Discover (role, scenario) pairs across all roles under *roles_root*.
|
||||
|
||||
Scans ``roles_root/*/molecule/*/`` for scenario directories, skipping
|
||||
``common`` and directories starting with ``_``. Returns a sorted list of
|
||||
``(role_name, scenario_name)`` tuples.
|
||||
"""
|
||||
if roles_root is None:
|
||||
roles_root = DEFAULT_ROLES_ROOT
|
||||
if not roles_root.is_dir():
|
||||
raise click.ClickException(_("Roles directory not found: {path}", path=str(roles_root)))
|
||||
pairs: list[tuple[str, str]] = []
|
||||
for role_dir in sorted(roles_root.iterdir()):
|
||||
if not role_dir.is_dir():
|
||||
continue
|
||||
mol_dir = role_dir / "molecule"
|
||||
if not mol_dir.is_dir():
|
||||
continue
|
||||
for scenario_dir in mol_dir.iterdir():
|
||||
if not scenario_dir.is_dir():
|
||||
continue
|
||||
if scenario_dir.name.startswith("_") or scenario_dir.name == "common":
|
||||
continue
|
||||
pairs.append((role_dir.name, scenario_dir.name))
|
||||
return pairs
|
||||
|
||||
|
||||
def build_pairs(scenarios: list[str], platforms: list[dict[str, str]] | None = None) -> list[TestPair]:
|
||||
"""Build the full cross-product of scenarios and platforms."""
|
||||
if platforms is None:
|
||||
@@ -69,6 +122,36 @@ def build_pairs(scenarios: list[str], platforms: list[dict[str, str]] | None = N
|
||||
return [TestPair(s, p) for s in scenarios for p in platforms]
|
||||
|
||||
|
||||
def build_multi_role_pairs(
|
||||
role_scenarios: list[tuple[str, str]],
|
||||
platforms: list[dict[str, str]] | None = None,
|
||||
) -> list[MultiRoleTestPair]:
|
||||
"""Build the full cross-product of (role, scenario) pairs and platforms."""
|
||||
if platforms is None:
|
||||
platforms = PLATFORMS
|
||||
return [MultiRoleTestPair(r, s, p) for r, s in role_scenarios for p in platforms]
|
||||
|
||||
|
||||
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
|
||||
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
|
||||
groups: list[list[MultiRoleTestPair]] = [[] for _ in range(max_runners)]
|
||||
for i, pair in enumerate(pairs):
|
||||
groups[i % max_runners].append(pair)
|
||||
return groups
|
||||
|
||||
|
||||
def multi_role_pairs_for_runner(
|
||||
pairs: list[MultiRoleTestPair], runner_index: int, max_runners: int
|
||||
) -> list[MultiRoleTestPair]:
|
||||
"""Return the subset of multi-role pairs assigned to *runner_index* (0-based)."""
|
||||
groups = distribute_multi_role(pairs, max_runners)
|
||||
if runner_index < 0 or runner_index >= len(groups):
|
||||
raise click.ClickException(
|
||||
_("Runner index {index} out of range (0..{max})", index=runner_index, max=max_runners - 1)
|
||||
)
|
||||
return groups[runner_index]
|
||||
|
||||
|
||||
def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]:
|
||||
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
|
||||
groups: list[list[TestPair]] = [[] for _ in range(max_runners)]
|
||||
@@ -142,6 +225,26 @@ def _write_github_env(key: str, value: str) -> None:
|
||||
default=False,
|
||||
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.",
|
||||
)
|
||||
@click.option(
|
||||
"--molecule-root",
|
||||
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
||||
default=None,
|
||||
help="Custom molecule directory (single-role mode). Default: ansible/roles/gitea-runner/molecule.",
|
||||
)
|
||||
@click.option(
|
||||
"--roles-root",
|
||||
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
||||
default=None,
|
||||
help="Roles directory for multi-role discovery (scans */molecule/*/). "
|
||||
"Use this for projects with multiple Ansible roles. Default: disabled (single-role mode).",
|
||||
)
|
||||
@click.option(
|
||||
"--platforms-file",
|
||||
type=click.Path(exists=True, file_okay=True, path_type=Path),
|
||||
default=None,
|
||||
help="JSON file with custom platform list (each entry: name, image, command). "
|
||||
"Overrides the default platform matrix. Useful for projects with custom test images.",
|
||||
)
|
||||
def cli(
|
||||
runner_index: int | None,
|
||||
max_runners: int,
|
||||
@@ -149,17 +252,58 @@ def cli(
|
||||
list_platforms: bool,
|
||||
github_env: bool,
|
||||
skip_if_excess: bool,
|
||||
molecule_root: Path | None,
|
||||
roles_root: Path | None,
|
||||
platforms_file: Path | None,
|
||||
) -> None:
|
||||
scenarios = discover_scenarios()
|
||||
platforms = load_platforms(platforms_file)
|
||||
# Multi-role mode: discover (role, scenario) pairs across all roles
|
||||
if roles_root is not None:
|
||||
role_scenarios = discover_multi_role_scenarios(roles_root)
|
||||
if list_all:
|
||||
for role, scenario in role_scenarios:
|
||||
click.echo(f"{role}|{scenario}")
|
||||
return
|
||||
if list_platforms:
|
||||
for p in platforms:
|
||||
click.echo(f"{p['name']}|{p['image']}|{p['command']}")
|
||||
return
|
||||
pairs_mr = build_multi_role_pairs(role_scenarios, platforms)
|
||||
if runner_index is None:
|
||||
groups = distribute_multi_role(pairs_mr, max_runners)
|
||||
for i, group in enumerate(groups):
|
||||
labels = " ".join(p.encode() for p in group) if group else "(none)"
|
||||
click.echo(f"Runner {i}: {labels}")
|
||||
return
|
||||
if skip_if_excess and github_env and runner_index > max_runners:
|
||||
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
|
||||
_write_github_env("TEST_PAIRS", "")
|
||||
_write_github_env("SKIP", "true")
|
||||
return
|
||||
if runner_index < 1:
|
||||
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
|
||||
zero_based = runner_index - 1
|
||||
assigned = multi_role_pairs_for_runner(pairs_mr, zero_based, max_runners)
|
||||
encoded = " ".join(p.encode() for p in assigned)
|
||||
if github_env:
|
||||
_write_github_env("TEST_PAIRS", encoded)
|
||||
_write_github_env("SKIP", "false")
|
||||
click.echo(f"Assigned pairs: {encoded}")
|
||||
return
|
||||
click.echo(encoded)
|
||||
return
|
||||
|
||||
# Single-role mode (default or --molecule-root)
|
||||
scenarios = discover_scenarios(molecule_root)
|
||||
if list_all:
|
||||
for s in scenarios:
|
||||
click.echo(s)
|
||||
return
|
||||
if list_platforms:
|
||||
for p in PLATFORMS:
|
||||
for p in platforms:
|
||||
click.echo(f"{p['name']}|{p['image']}|{p['command']}")
|
||||
return
|
||||
pairs = build_pairs(scenarios)
|
||||
pairs = build_pairs(scenarios, platforms)
|
||||
if runner_index is None:
|
||||
groups = distribute(pairs, max_runners)
|
||||
for i, group in enumerate(groups):
|
||||
@@ -174,6 +318,10 @@ def cli(
|
||||
_write_github_env("SKIP", "true")
|
||||
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
|
||||
zero_based = runner_index - 1
|
||||
assigned = pairs_for_runner(pairs, zero_based, max_runners)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run molecule tests sequentially while polling Gitea for other runner failures.
|
||||
|
||||
Each pair is encoded as ``scenario|platform_name|platform_image|platform_command``.
|
||||
Each pair is encoded as one of:
|
||||
|
||||
- **Single-role (4-part):** ``scenario|platform_name|platform_image|platform_command``
|
||||
- **Multi-role (5-part):** ``role|scenario|platform_name|platform_image|platform_command``
|
||||
|
||||
Pairs are executed one at a time (molecule scenarios share temp directories and
|
||||
Docker networks, so parallel execution within a single runner is unsafe).
|
||||
|
||||
@@ -9,8 +13,17 @@ A background thread polls the Gitea API. If any other molecule matrix runner
|
||||
reports failure, the current molecule subprocess is killed and this runner
|
||||
exits early with code 1.
|
||||
|
||||
Usage:
|
||||
python3 -m devx.molecule.molecule_ci_guard <pair1> <pair2> ...
|
||||
JUnit XML is generated when ``--junit-output`` is provided, recording each
|
||||
pair as a testcase with pass/fail status and elapsed time.
|
||||
|
||||
Usage::
|
||||
|
||||
# Single-role (grm-style)
|
||||
python3 -m devx.molecule.molecule_ci_guard pair1 pair2 ...
|
||||
# Multi-role (infra-style)
|
||||
python3 -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2 ...
|
||||
# With JUnit output
|
||||
python3 -m devx.molecule.molecule_ci_guard --junit-output junit-results/runner-1.xml pair1 pair2 ...
|
||||
|
||||
Environment variables:
|
||||
GITEA_URL Base URL of the Gitea instance.
|
||||
@@ -30,6 +43,7 @@ import subprocess # nosec B404
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import xml.etree.ElementTree as ET # nosec B405
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
@@ -95,9 +109,25 @@ def build_molecule_cmd(scenario: str) -> list[str]:
|
||||
return cmd
|
||||
|
||||
|
||||
def parse_pair(pair: str) -> tuple[str, str, str, str, str]:
|
||||
"""Parse a pair string into (role, scenario, platform_name, platform_image, platform_command).
|
||||
|
||||
Supports both 4-part (single-role) and 5-part (multi-role) formats.
|
||||
For 4-part pairs, role is empty (caller uses default role dir).
|
||||
Spaces in the command field are encoded as ``__SPACE__`` to survive
|
||||
shell word-splitting when ``$TEST_PAIRS`` is expanded unquoted.
|
||||
"""
|
||||
parts = pair.split("|")
|
||||
if len(parts) == 4:
|
||||
return "", parts[0], parts[1], parts[2], parts[3].replace("__SPACE__", " ")
|
||||
if len(parts) == 5:
|
||||
return parts[0], parts[1], parts[2], parts[3], parts[4].replace("__SPACE__", " ")
|
||||
raise click.ClickException(f"Invalid pair format: {pair!r} (expected 4 or 5 pipe-delimited parts)")
|
||||
|
||||
|
||||
def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]:
|
||||
"""Build environment for a single molecule pair."""
|
||||
scenario, platform_name, platform_image, platform_command = pair.split("|")
|
||||
_role, _scenario, platform_name, platform_image, platform_command = parse_pair(pair)
|
||||
env = base_env.copy()
|
||||
env["MOLECULE_PLATFORM_NAME"] = platform_name
|
||||
env["MOLECULE_PLATFORM_IMAGE"] = platform_image
|
||||
@@ -106,12 +136,75 @@ def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]:
|
||||
elif "MOLECULE_PLATFORM_COMMAND" in env:
|
||||
del env["MOLECULE_PLATFORM_COMMAND"]
|
||||
env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
|
||||
# Use a fresh MOLECULE_HOME per pair to avoid stale config cache
|
||||
# from previous CI runs (causes "Instances missing" errors).
|
||||
if "MOLECULE_HOME" not in env:
|
||||
import tempfile
|
||||
|
||||
env["MOLECULE_HOME"] = tempfile.mkdtemp(prefix="molecule-ci-")
|
||||
return env
|
||||
|
||||
|
||||
def resolve_role_dir(role: str, roles_root: Path | None, repo_root: Path) -> Path:
|
||||
"""Resolve the working directory for a molecule pair.
|
||||
|
||||
For multi-role pairs (role non-empty), uses ``roles_root/role``.
|
||||
For single-role pairs, uses ``repo_root/ansible/roles/gitea-runner``.
|
||||
"""
|
||||
if role:
|
||||
if roles_root is None:
|
||||
roles_root = repo_root / "ansible" / "roles"
|
||||
return roles_root / role
|
||||
return repo_root / "ansible" / "roles" / "gitea-runner"
|
||||
|
||||
|
||||
def write_junit_report(
|
||||
output_path: str,
|
||||
testcases: list[dict],
|
||||
runner_index: int,
|
||||
) -> None:
|
||||
"""Write a JUnit XML report from collected test case results.
|
||||
|
||||
Each testcase dict has: role, scenario, time (float), passed (bool), error (str|None).
|
||||
"""
|
||||
suite = ET.Element(
|
||||
"testsuite",
|
||||
name=f"molecule-runner-{runner_index}",
|
||||
tests=str(len(testcases)),
|
||||
failures=str(sum(1 for tc in testcases if not tc["passed"])),
|
||||
)
|
||||
for tc in testcases:
|
||||
classname = tc["role"] if tc["role"] else "molecule"
|
||||
elem = ET.SubElement(
|
||||
suite,
|
||||
"testcase",
|
||||
classname=classname,
|
||||
name=tc["scenario"],
|
||||
time=f"{tc['time']:.1f}",
|
||||
)
|
||||
if not tc["passed"]:
|
||||
fail = ET.SubElement(elem, "failure")
|
||||
fail.text = tc.get("error") or "molecule test failed"
|
||||
tree = ET.ElementTree(suite)
|
||||
ET.indent(tree)
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
tree.write(output_path, encoding="UTF-8", xml_declaration=True)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("pairs", nargs=-1, required=True)
|
||||
def cli(pairs: tuple[str, ...]) -> None:
|
||||
@click.option(
|
||||
"--junit-output",
|
||||
default=None,
|
||||
help="Path to write JUnit XML report (e.g. junit-results/runner-1.xml).",
|
||||
)
|
||||
@click.option(
|
||||
"--roles-root",
|
||||
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
||||
default=None,
|
||||
help="Root directory for multi-role pairs (e.g. ansible/roles). Required when pairs use 5-part format.",
|
||||
)
|
||||
def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | None) -> None:
|
||||
"""Run molecule pairs sequentially, stop if another CI runner fails."""
|
||||
gitea_url = os.environ.get("GITEA_URL", "")
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
@@ -119,7 +212,7 @@ def cli(pairs: tuple[str, ...]) -> None:
|
||||
job_name = os.environ.get("JOB_NAME", "molecule-tests")
|
||||
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
|
||||
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx")
|
||||
owner, sep, repo = repository.partition("/")
|
||||
owner, _sep, repo = repository.partition("/")
|
||||
if not owner or not repo:
|
||||
owner, repo = "oblachno-oss", "devx"
|
||||
|
||||
@@ -127,7 +220,6 @@ def cli(pairs: tuple[str, ...]) -> None:
|
||||
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
|
||||
|
||||
repo_root = Path(__file__).resolve().parent.parent.parent.parent
|
||||
role_dir = repo_root / "ansible" / "roles" / "gitea-runner"
|
||||
|
||||
base_env = os.environ.copy()
|
||||
base_env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock")
|
||||
@@ -154,21 +246,24 @@ def cli(pairs: tuple[str, ...]) -> None:
|
||||
)
|
||||
poller.start()
|
||||
|
||||
testcases: list[dict] = []
|
||||
|
||||
try:
|
||||
for pair in pairs:
|
||||
if failed_event.is_set():
|
||||
sys.exit(1)
|
||||
|
||||
scenario = pair.split("|")[0]
|
||||
platform_name = pair.split("|")[1]
|
||||
role, scenario, platform_name, _img, _cmd = parse_pair(pair)
|
||||
click.echo(_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name))
|
||||
|
||||
cmd = build_molecule_cmd(scenario)
|
||||
env = build_env_for_pair(pair, base_env)
|
||||
cwd = resolve_role_dir(role, roles_root, repo_root)
|
||||
|
||||
start = time.time()
|
||||
process = subprocess.Popen( # nosec B603
|
||||
cmd,
|
||||
cwd=str(role_dir),
|
||||
cwd=str(cwd),
|
||||
env=env,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
@@ -184,6 +279,18 @@ def cli(pairs: tuple[str, ...]) -> None:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
process.wait()
|
||||
elapsed = time.time() - start
|
||||
testcases.append(
|
||||
{
|
||||
"role": role,
|
||||
"scenario": scenario,
|
||||
"time": elapsed,
|
||||
"passed": False,
|
||||
"error": "Cancelled — another runner failed",
|
||||
}
|
||||
)
|
||||
if junit_output:
|
||||
write_junit_report(junit_output, testcases, current_index)
|
||||
sys.exit(1)
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
@@ -193,13 +300,30 @@ def cli(pairs: tuple[str, ...]) -> None:
|
||||
sys.exit(1)
|
||||
|
||||
rc = process.returncode
|
||||
elapsed = time.time() - start
|
||||
passed = rc == 0
|
||||
|
||||
testcases.append(
|
||||
{
|
||||
"role": role,
|
||||
"scenario": scenario,
|
||||
"time": elapsed,
|
||||
"passed": passed,
|
||||
"error": f"Exit code: {rc}" if not passed else None,
|
||||
}
|
||||
)
|
||||
|
||||
if rc != 0:
|
||||
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
|
||||
if junit_output:
|
||||
write_junit_report(junit_output, testcases, current_index)
|
||||
sys.exit(rc)
|
||||
|
||||
click.echo(_("PASSED: {pair}", pair=pair))
|
||||
|
||||
click.echo(_("All molecule tests passed."))
|
||||
if junit_output:
|
||||
write_junit_report(junit_output, testcases, current_index)
|
||||
finally:
|
||||
stop_event.set()
|
||||
|
||||
|
||||
@@ -10,13 +10,39 @@ dev tools and CI scripts.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
#: Supported OS platform matrix.
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
#: Default supported OS platform matrix.
|
||||
#: Each entry maps a short name to (image, command).
|
||||
#: The command must be systemd since rootless Docker requires
|
||||
#: loginctl/systemctl --user.
|
||||
#: Uses the project's pre-built molecule-test-base image with
|
||||
#: ``sleep infinity`` (NOT systemd) to avoid cgroup v2 failures.
|
||||
PLATFORMS: list[dict[str, str]] = [
|
||||
{"name": "ubuntu-2204", "image": "geerlingguy/docker-ubuntu2204-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "ubuntu-2404", "image": "geerlingguy/docker-ubuntu2404-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "debian-12", "image": "geerlingguy/docker-debian12-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "archlinux", "image": "marcstraube/archlinux-ansible:latest", "command": "/usr/lib/systemd/systemd"},
|
||||
{
|
||||
"name": "ubuntu-2604",
|
||||
"image": "git.oblachno.oblachno.fyi/oblachno/molecule-test-base:latest",
|
||||
"command": "sleep infinity",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def load_platforms(platforms_file: str | Path | None = None) -> list[dict[str, str]]:
|
||||
"""Load platforms from a JSON file, falling back to PLATFORMS.
|
||||
|
||||
Args:
|
||||
platforms_file: Path to a JSON file with a list of platform dicts.
|
||||
Each dict must have ``name``, ``image``, and ``command`` keys.
|
||||
|
||||
Returns:
|
||||
List of platform dictionaries.
|
||||
"""
|
||||
if platforms_file is None:
|
||||
return PLATFORMS
|
||||
path = Path(platforms_file)
|
||||
if not path.is_file():
|
||||
return PLATFORMS
|
||||
with path.open() as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, list) or not data:
|
||||
return PLATFORMS
|
||||
return data
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ensure Docker is available for molecule tests in CI.
|
||||
|
||||
CI runners (e.g. ``gitea/runner-images:ubuntu-latest``) may have the host's
|
||||
Docker socket mounted. This module verifies Docker is accessible and
|
||||
sets ``DOCKER_HOST`` explicitly so molecule's Python docker library
|
||||
connects to the same socket as the Docker CLI.
|
||||
|
||||
If the host socket is not available, it tries the rootless socket, then
|
||||
starts a local ``dockerd`` with the vfs storage driver (requires
|
||||
privileged container).
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.molecule.start_docker [--timeout 30]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
DEFAULT_TIMEOUT = 30
|
||||
DOCKER_SOCK = "/var/run/docker.sock"
|
||||
# Rootless socket fallback (e.g. /run/user/994/docker.sock)
|
||||
ROOTLESS_SOCK = f"/run/user/{os.getuid()}/docker.sock"
|
||||
|
||||
|
||||
def is_docker_ready() -> bool:
|
||||
"""Check if Docker daemon is responding on the configured socket."""
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["docker", "info"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env={**os.environ, "DOCKER_HOST": f"unix://{DOCKER_SOCK}"},
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _diagnose_socket() -> None:
|
||||
"""Print diagnostic info about the Docker socket."""
|
||||
click.echo(f"DOCKER_HOST = {os.environ.get('DOCKER_HOST', '(not set)')}")
|
||||
click.echo(f"Socket path: {DOCKER_SOCK}")
|
||||
click.echo(f"Socket exists: {os.path.exists(DOCKER_SOCK)}")
|
||||
if os.path.exists(DOCKER_SOCK):
|
||||
stat = os.stat(DOCKER_SOCK)
|
||||
click.echo(f"Socket mode: {oct(stat.st_mode)}")
|
||||
click.echo(f"Socket uid: {stat.st_uid}, gid: {stat.st_gid}")
|
||||
# Check if it's a mount point
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["mount"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
docker_mounts = [line for line in result.stdout.splitlines() if "docker" in line.lower()]
|
||||
if docker_mounts:
|
||||
click.echo("Docker-related mounts:")
|
||||
for line in docker_mounts:
|
||||
click.echo(f" {line}")
|
||||
else:
|
||||
click.echo("No Docker-related mounts found")
|
||||
# Check docker context
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["docker", "context", "ls"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
click.echo(f"Docker contexts:\n{result.stdout}")
|
||||
# Try docker info without DOCKER_HOST
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["docker", "info"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
click.echo(f"docker info (no DOCKER_HOST): rc={result.returncode}")
|
||||
if result.returncode != 0:
|
||||
click.echo(f" stderr: {result.stderr[:500]}")
|
||||
else:
|
||||
# Print server version and storage driver
|
||||
for line in result.stdout.splitlines():
|
||||
if "Server Version" in line or "Storage Driver" in line or "Docker Root Dir" in line:
|
||||
click.echo(f" {line.strip()}")
|
||||
|
||||
|
||||
def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
|
||||
"""Ensure Docker is ready for molecule tests.
|
||||
|
||||
First tries the host socket. If that works, sets ``DOCKER_HOST`` and
|
||||
returns immediately. If not, tries the rootless socket. If neither
|
||||
works, starts a local ``dockerd`` with vfs storage driver (requires
|
||||
privileged container).
|
||||
|
||||
Returns ``True`` if Docker is ready, ``False`` if it failed to
|
||||
start within the timeout.
|
||||
"""
|
||||
# Point Docker CLI and Python library to the socket explicitly
|
||||
os.environ["DOCKER_HOST"] = f"unix://{DOCKER_SOCK}"
|
||||
|
||||
# Diagnose socket state
|
||||
click.echo("--- Docker socket diagnostics ---")
|
||||
_diagnose_socket()
|
||||
click.echo("--- End diagnostics ---")
|
||||
|
||||
# Check if host Docker is already available
|
||||
if is_docker_ready():
|
||||
click.echo(_("Docker daemon already running"))
|
||||
return True
|
||||
|
||||
# Try rootless socket (e.g. /run/user/994/docker.sock)
|
||||
click.echo(f"Trying rootless socket: {ROOTLESS_SOCK}")
|
||||
os.environ["DOCKER_HOST"] = f"unix://{ROOTLESS_SOCK}"
|
||||
if os.path.exists(ROOTLESS_SOCK) and is_docker_ready():
|
||||
click.echo(_("Docker daemon already running"))
|
||||
return True
|
||||
|
||||
click.echo(_("Host Docker not available, starting local dockerd..."))
|
||||
|
||||
# Start local dockerd (requires privileged container)
|
||||
log_file = tempfile.NamedTemporaryFile( # noqa: SIM115
|
||||
mode="w", suffix="dockerd.log", delete=False
|
||||
)
|
||||
click.echo(f"dockerd log: {log_file.name}")
|
||||
subprocess.Popen( # nosec B603 B607
|
||||
[
|
||||
"dockerd",
|
||||
"--storage-driver",
|
||||
"vfs",
|
||||
"-H",
|
||||
f"unix://{DOCKER_SOCK}",
|
||||
],
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
for _i in range(timeout):
|
||||
if is_docker_ready():
|
||||
click.echo(_("Docker daemon started"))
|
||||
return True
|
||||
time.sleep(1)
|
||||
|
||||
# Print dockerd log on failure
|
||||
click.echo(_("Docker daemon failed to start"))
|
||||
click.echo("--- dockerd log ---")
|
||||
try:
|
||||
with open(log_file.name) as f:
|
||||
log_content = f.read()
|
||||
click.echo(log_content[-3000:] if len(log_content) > 3000 else log_content)
|
||||
except OSError as e:
|
||||
click.echo(f"Could not read log: {e}")
|
||||
click.echo("--- End dockerd log ---")
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--timeout",
|
||||
default=DEFAULT_TIMEOUT,
|
||||
type=int,
|
||||
help="Seconds to wait for Docker daemon to start (default: 30).",
|
||||
)
|
||||
def main(timeout: int) -> None:
|
||||
"""Start Docker daemon for CI molecule tests."""
|
||||
if start_docker_daemon(timeout):
|
||||
# Export DOCKER_HOST to GITHUB_ENV for subsequent CI steps
|
||||
github_env = os.environ.get("GITHUB_ENV")
|
||||
if github_env and os.environ.get("DOCKER_HOST"):
|
||||
with open(github_env, "a") as f:
|
||||
f.write(f"DOCKER_HOST={os.environ['DOCKER_HOST']}\n")
|
||||
click.echo(f"Exported DOCKER_HOST={os.environ['DOCKER_HOST']} to GITHUB_ENV")
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OpenTofu output helpers for CI/CD deployment scripts.
|
||||
|
||||
Provides reusable functions for extracting values from ``tofu output``
|
||||
in a structured way. This eliminates duplicated ``subprocess.run``
|
||||
boilerplate across deployment and smoke-test scripts.
|
||||
|
||||
Typical usage::
|
||||
|
||||
from devx.opentofu import get_tofu_output, get_tofu_vm_ip
|
||||
|
||||
vms = get_tofu_output("customer_vms", cwd="tofu/environments/staging",
|
||||
env={"HCLOUD_TOKEN": token})
|
||||
ip = get_tofu_vm_ip("customer_vms", "oblachno", cwd="tofu/environments/staging",
|
||||
env={"HCLOUD_TOKEN": token})
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def get_tofu_output(
|
||||
output_name: str,
|
||||
cwd: str | Path | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> Any:
|
||||
"""Run ``tofu output -json <output_name>`` and return parsed JSON.
|
||||
|
||||
Args:
|
||||
output_name: The OpenTofu output name to query (e.g. ``customer_vms``).
|
||||
cwd: Directory to run the command in (the tofu env directory).
|
||||
env: Environment variables for the subprocess (e.g. ``{"HCLOUD_TOKEN": ...}``).
|
||||
If ``None``, inherits the current environment.
|
||||
|
||||
Returns:
|
||||
Parsed JSON value from the tofu output.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If ``tofu output`` exits with a non-zero code.
|
||||
json.JSONDecodeError: If stdout is not valid JSON.
|
||||
"""
|
||||
result = subprocess.run( # nosec B603, B607
|
||||
["tofu", "output", "-json", output_name],
|
||||
cwd=str(cwd) if cwd else None,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"tofu output failed: {result.stderr}")
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def get_tofu_vm_ip(
|
||||
output_name: str,
|
||||
vm_key: str,
|
||||
cwd: str | Path | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
ip_field: str = "ipv4",
|
||||
) -> str:
|
||||
"""Extract a VM IPv4 address from a tofu output map.
|
||||
|
||||
The output is expected to be a JSON object mapping VM names to objects
|
||||
containing an IP field (default ``ipv4``)::
|
||||
|
||||
{"staging": {"ipv4": "1.2.3.4", ...}, ...}
|
||||
|
||||
Args:
|
||||
output_name: The tofu output name (e.g. ``customer_vms``).
|
||||
vm_key: The key inside the output map (e.g. ``"staging"``).
|
||||
cwd: Directory to run the command in.
|
||||
env: Environment variables for the subprocess.
|
||||
ip_field: The field name for the IP address (default ``ipv4``).
|
||||
|
||||
Returns:
|
||||
The IP address string, or empty string if not found.
|
||||
"""
|
||||
data = get_tofu_output(output_name, cwd=cwd, env=env)
|
||||
if not isinstance(data, dict):
|
||||
return ""
|
||||
return str(data.get(vm_key, {}).get(ip_field, ""))
|
||||
|
||||
|
||||
def get_tofu_vm_field(
|
||||
output_name: str,
|
||||
vm_key: str,
|
||||
field: str,
|
||||
cwd: str | Path | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Extract an arbitrary field from a VM entry in tofu output.
|
||||
|
||||
Like :func:`get_tofu_vm_ip` but for any field (e.g. ``volume_linux_device``).
|
||||
|
||||
Args:
|
||||
output_name: The tofu output name.
|
||||
vm_key: The key inside the output map.
|
||||
field: The field name to extract.
|
||||
cwd: Directory to run the command in.
|
||||
env: Environment variables for the subprocess.
|
||||
|
||||
Returns:
|
||||
The field value as a string, or empty string if not found.
|
||||
"""
|
||||
data = get_tofu_output(output_name, cwd=cwd, env=env)
|
||||
if not isinstance(data, dict):
|
||||
return ""
|
||||
return str(data.get(vm_key, {}).get(field, ""))
|
||||
@@ -1,12 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run unit tests and enforce a maximum execution-time budget.
|
||||
"""Run unit tests and enforce execution-time budgets.
|
||||
|
||||
Checks two quality gates:
|
||||
1. **Total suite time** must not exceed ``--max-seconds``.
|
||||
2. **Per-test time** — no individual test may exceed ``--max-single-seconds``.
|
||||
|
||||
Usage:
|
||||
python3 -m devx.tools.check_test_speed [--max-seconds N]
|
||||
python3 -m devx.tools.check_test_speed [--max-seconds N] [--max-single-seconds S]
|
||||
|
||||
The module runs ``make test-unit`` with ``PYTEST_ADDOPTS=--durations=0`` so
|
||||
that pytest emits per-test timing lines alongside the summary. Both the
|
||||
total wall-clock time and individual test durations are parsed and validated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
@@ -14,18 +23,32 @@ import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
DEFAULT_MAX_SECONDS = 2.0
|
||||
DEFAULT_MAX_SECONDS = 10.0
|
||||
DEFAULT_MAX_SINGLE_SECONDS = 0.5
|
||||
TEST_COMMAND = ["make", "test-unit"]
|
||||
|
||||
# Matches pytest summary line: "234 passed in 0.70s"
|
||||
_TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
|
||||
|
||||
# Matches per-test duration lines from --durations=0:
|
||||
# 0.51s call tests/test_foo.py::test_bar
|
||||
_DURATION_LINE_RE = re.compile(r"^(\d+\.?\d*)s\s+(?:setup|call|teardown)\s+(.+)$")
|
||||
|
||||
|
||||
def run_tests() -> tuple[str, str]:
|
||||
"""Execute the unit-test suite and return (stdout, stderr)."""
|
||||
"""Execute the unit-test suite and return (stdout, stderr).
|
||||
|
||||
Sets ``PYTEST_ADDOPTS=--durations=0`` so pytest emits per-test timings.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
existing = env.get("PYTEST_ADDOPTS", "")
|
||||
env["PYTEST_ADDOPTS"] = f"--durations=0 {existing}".strip()
|
||||
result = subprocess.run( # nosec B603
|
||||
TEST_COMMAND,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
return result.stdout, result.stderr
|
||||
|
||||
@@ -43,8 +66,23 @@ def parse_duration(output: str) -> float:
|
||||
raise click.ClickException(_("Could not parse test execution time from output."))
|
||||
|
||||
|
||||
def parse_per_test_durations(output: str) -> list[tuple[str, float]]:
|
||||
"""Extract per-test timings from ``--durations=0`` output.
|
||||
|
||||
Returns a list of ``(test_name, seconds)`` tuples sorted by duration
|
||||
(slowest first).
|
||||
"""
|
||||
durations: list[tuple[str, float]] = []
|
||||
for line in output.splitlines():
|
||||
match = _DURATION_LINE_RE.match(line.strip())
|
||||
if match:
|
||||
durations.append((match.group(2).strip(), float(match.group(1))))
|
||||
durations.sort(key=lambda x: x[1], reverse=True)
|
||||
return durations
|
||||
|
||||
|
||||
def check_speed(duration: float, max_seconds: float) -> None:
|
||||
"""Validate duration is within budget; raise on violation."""
|
||||
"""Validate total duration is within budget; raise on violation."""
|
||||
if duration > max_seconds:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
@@ -57,19 +95,58 @@ def check_speed(duration: float, max_seconds: float) -> None:
|
||||
)
|
||||
|
||||
|
||||
def main(max_seconds: float) -> None:
|
||||
"""Run tests, parse timing, and enforce the budget."""
|
||||
def check_per_test_speed(
|
||||
durations: list[tuple[str, float]],
|
||||
max_single_seconds: float,
|
||||
) -> list[str]:
|
||||
"""Return a list of violation messages for tests exceeding the per-test limit.
|
||||
|
||||
An empty list means all tests are within budget.
|
||||
"""
|
||||
violations: list[str] = []
|
||||
for name, elapsed in durations:
|
||||
if elapsed > max_single_seconds:
|
||||
violations.append(
|
||||
_(
|
||||
"Test '{name}' took {elapsed:.2f}s (limit: {limit}s). "
|
||||
"Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
|
||||
name=name,
|
||||
elapsed=elapsed,
|
||||
limit=max_single_seconds,
|
||||
)
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def main(max_seconds: float, max_single_seconds: float) -> None:
|
||||
"""Run tests, parse timings, and enforce both budgets."""
|
||||
stdout, stderr = run_tests()
|
||||
combined = stdout + "\n" + stderr
|
||||
click.echo(combined, err=False)
|
||||
|
||||
duration = parse_duration(combined)
|
||||
check_speed(duration, max_seconds)
|
||||
|
||||
if max_single_seconds > 0:
|
||||
per_test = parse_per_test_durations(combined)
|
||||
violations = check_per_test_speed(per_test, max_single_seconds)
|
||||
if violations:
|
||||
msg = _(
|
||||
"Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||
count=len(violations),
|
||||
limit=max_single_seconds,
|
||||
)
|
||||
click.echo(f"\n{msg}", err=True)
|
||||
for v in violations:
|
||||
click.echo(f" - {v}", err=True)
|
||||
raise click.ClickException(msg)
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"Unit tests passed in {duration:.2f}s (under {max}s limit).",
|
||||
"Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
|
||||
duration=duration,
|
||||
max=max_seconds,
|
||||
single=max_single_seconds,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -80,10 +157,17 @@ def main(max_seconds: float) -> None:
|
||||
type=float,
|
||||
default=DEFAULT_MAX_SECONDS,
|
||||
show_default=True,
|
||||
help="Maximum allowed execution time in seconds.",
|
||||
help="Maximum allowed total execution time in seconds.",
|
||||
)
|
||||
def cli(max_seconds: float) -> None:
|
||||
main(max_seconds)
|
||||
@click.option(
|
||||
"--max-single-seconds",
|
||||
type=float,
|
||||
default=DEFAULT_MAX_SINGLE_SECONDS,
|
||||
show_default=True,
|
||||
help="Maximum allowed per-test time in seconds (0 to disable).",
|
||||
)
|
||||
def cli(max_seconds: float, max_single_seconds: float) -> None:
|
||||
main(max_seconds, max_single_seconds)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a cliff.toml configuration file for a project.
|
||||
|
||||
Produces a git-cliff configuration with the correct task ID prefix
|
||||
preprocessor, matching the format used by devx itself. Downstream
|
||||
repos can use this to avoid duplicating the entire cliff.toml by hand.
|
||||
|
||||
Usage::
|
||||
|
||||
python -m devx.tools.generate_cliff_config --prefix GRM
|
||||
python -m devx.tools.generate_cliff_config --prefix GRM --output cliff.toml
|
||||
python -m devx.tools.generate_cliff_config --prefix GRM --force
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.config import TASK_PREFIX
|
||||
from devx.i18n import _
|
||||
|
||||
# Template uses __PREFIX__ and __PREFIX_REGEX__ as placeholders to avoid
|
||||
# conflicts with Jinja2's {{ }} and {% %} syntax in the cliff.toml body.
|
||||
CLIFF_TEMPLATE = """\
|
||||
# git-cliff configuration for __PREFIX__
|
||||
# https://git-cliff.org/docs/configuration
|
||||
# Generated by: python -m devx.tools.generate_cliff_config --prefix __PREFIX__
|
||||
|
||||
[changelog]
|
||||
header = \"\"\"
|
||||
# Changelog\\n
|
||||
All notable changes to this project will be documented in this file.\\n
|
||||
\"\"\"
|
||||
body = \"\"\"
|
||||
{% if version %}\\
|
||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else %}\\
|
||||
## [unreleased]
|
||||
{% endif %}\\
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\\
|
||||
{% if commit.breaking %}[**breaking**] {% endif %}\\
|
||||
{{ commit.message | upper_first }}\\
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
\"\"\"
|
||||
trim = true
|
||||
render_always = true
|
||||
|
||||
[git]
|
||||
conventional_commits = true
|
||||
filter_unconventional = true
|
||||
require_conventional = false
|
||||
split_commits = false
|
||||
protect_breaking_commits = false
|
||||
filter_commits = false
|
||||
fail_on_unmatched_commit = false
|
||||
use_branch_tags = false
|
||||
topo_order = false
|
||||
topo_order_commits = true
|
||||
sort_commits = "oldest"
|
||||
recurse_submodules = false
|
||||
|
||||
commit_preprocessors = [
|
||||
# Strip __PREFIX__-N: task ID prefix from squash-merge commits so git-cliff sees conventional commits
|
||||
{ pattern = "^__PREFIX_REGEX__-\\\\d+:\\\\s+", replace = "" },
|
||||
]
|
||||
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "<!-- 0 -->Features" },
|
||||
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
|
||||
{ message = "^perf", group = "<!-- 4 -->Performance" },
|
||||
{ message = "^refactor", group = "<!-- 2 -->Refactor" },
|
||||
# Skip infrastructure-only commits — they don't affect users
|
||||
{ message = "^doc", skip = true },
|
||||
{ message = "^test", skip = true },
|
||||
{ message = "^style", skip = true },
|
||||
{ message = "^chore", skip = true },
|
||||
{ message = "^ci", skip = true },
|
||||
# Skip release commits — they are release artifacts, not features
|
||||
{ message = "^release:", skip = true },
|
||||
{ body = ".*security", group = "<!-- 8 -->Security" },
|
||||
{ message = "^revert", group = "<!-- 9 -->Revert" },
|
||||
# Skip anything that doesn't match above — safe default
|
||||
{ message = ".*", skip = true },
|
||||
]
|
||||
|
||||
[bump]
|
||||
features_always_bump_minor = true
|
||||
breaking_always_bump_major = false
|
||||
initial_tag = "0.1.0"
|
||||
# Refactor commits bump patch — structural changes to src/ or pyproject.toml
|
||||
# affect users even though no new feature was added.
|
||||
refactor_always_bump_patch = true
|
||||
"""
|
||||
|
||||
|
||||
def _generate(prefix: str) -> str:
|
||||
"""Generate cliff.toml content for the given prefix."""
|
||||
prefix_regex = prefix.replace("\\", "\\\\")
|
||||
return CLIFF_TEMPLATE.replace("__PREFIX__", prefix).replace("__PREFIX_REGEX__", prefix_regex)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--prefix",
|
||||
default=TASK_PREFIX,
|
||||
help="Task ID prefix for commit preprocessor (default: DEVX_TASK_PREFIX env var or 'DEVX').",
|
||||
)
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
default="cliff.toml",
|
||||
type=click.Path(),
|
||||
help="Output file path (default: cliff.toml).",
|
||||
)
|
||||
@click.option(
|
||||
"--force",
|
||||
is_flag=True,
|
||||
help="Overwrite existing file without prompting.",
|
||||
)
|
||||
def main(prefix: str, output: str, force: bool) -> None:
|
||||
"""Generate a cliff.toml configuration file."""
|
||||
output_path = Path(output)
|
||||
|
||||
if output_path.exists() and not force:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"{file} already exists. Use --force to overwrite.",
|
||||
file=str(output_path),
|
||||
)
|
||||
)
|
||||
|
||||
content = _generate(prefix)
|
||||
output_path.write_text(content)
|
||||
click.echo(
|
||||
_(
|
||||
"Generated {file} with prefix '{prefix}'.",
|
||||
file=str(output_path),
|
||||
prefix=prefix,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
+18
-3
@@ -26,9 +26,24 @@ def _run(cmd: list[str]) -> None:
|
||||
|
||||
|
||||
def _install_python_deps(bin_dir: str, extras: str = "dev") -> None:
|
||||
"""Install the project with the specified extras in editable mode."""
|
||||
"""Install the project with the specified extras in editable mode.
|
||||
|
||||
In CI (system Python with PIP_BREAK_SYSTEM_PACKAGES=1), a first attempt
|
||||
uses --break-system-packages. If that fails (e.g. debian-installed
|
||||
packages without RECORD files), retry with --ignore-installed to skip
|
||||
uninstalling system packages entirely.
|
||||
"""
|
||||
pip = str(Path(bin_dir) / "pip")
|
||||
_run([pip, "install", "-e", f".[{extras}]"])
|
||||
cmd = [pip, "install", "-e", f".[{extras}]"]
|
||||
if os.environ.get("PIP_BREAK_SYSTEM_PACKAGES") == "1":
|
||||
cmd.append("--break-system-packages")
|
||||
result = subprocess.run(cmd, check=False) # nosec B603
|
||||
if result.returncode != 0 and os.environ.get("PIP_BREAK_SYSTEM_PACKAGES") == "1":
|
||||
click.echo(" Retrying with --ignore-installed to bypass system packages...")
|
||||
cmd.append("--ignore-installed")
|
||||
_run(cmd)
|
||||
elif result.returncode != 0:
|
||||
raise subprocess.CalledProcessError(result.returncode, cmd)
|
||||
|
||||
|
||||
def _install_pre_commit_hooks(bin_dir: str) -> None:
|
||||
@@ -40,7 +55,7 @@ def _install_pre_commit_hooks(bin_dir: str) -> None:
|
||||
|
||||
def _install_ansible_collections(bin_dir: str) -> None:
|
||||
"""Install required Ansible Galaxy collections if requirements exist."""
|
||||
galaxy = str(Path(bin_dir) / "ansible-galaxy")
|
||||
galaxy = shutil.which("ansible-galaxy") or str(Path(bin_dir) / "ansible-galaxy")
|
||||
requirements = Path("ansible/requirements.yml")
|
||||
if not requirements.exists():
|
||||
click.echo(" ansible/requirements.yml not found — skipping collections.")
|
||||
|
||||
+833
-247
@@ -1,655 +1,1241 @@
|
||||
{
|
||||
"\nAll documentation coverage checks passed!": {
|
||||
"en": "\nAll documentation coverage checks passed!"
|
||||
"\n=== Summary ===": {
|
||||
"bg": "\n=== Summary ===",
|
||||
"de": "\n=== Summary ===",
|
||||
"en": "\n=== Summary ===",
|
||||
"ru": "\n=== Summary ===",
|
||||
"zh": "\n=== Summary ==="
|
||||
},
|
||||
"\nAnsible files changed ({count}):": {
|
||||
"en": "\nAnsible files changed ({count}):"
|
||||
"\nAll documentation coverage checks passed!": {
|
||||
"bg": "\nAll documentation coverage checks passed!",
|
||||
"de": "\nAll documentation coverage checks passed!",
|
||||
"en": "\nAll documentation coverage checks passed!",
|
||||
"ru": "\nAll documentation coverage checks passed!",
|
||||
"zh": "\nAll documentation coverage checks passed!"
|
||||
},
|
||||
"\nCHANGELOG version ordering:": {
|
||||
"bg": "\nCHANGELOG version ordering:",
|
||||
"de": "\nCHANGELOG version ordering:",
|
||||
"en": "\nCHANGELOG version ordering:",
|
||||
"ru": "\nCHANGELOG version ordering:",
|
||||
"zh": "\nCHANGELOG version ordering:"
|
||||
},
|
||||
"\nChecking CI script documentation in ci-cd-workflow.md...": {
|
||||
"en": "\nChecking CI script documentation in ci-cd-workflow.md..."
|
||||
"bg": "\nChecking CI script documentation in ci-cd-workflow.md...",
|
||||
"de": "\nChecking CI script documentation in ci-cd-workflow.md...",
|
||||
"en": "\nChecking CI script documentation in ci-cd-workflow.md...",
|
||||
"ru": "\nChecking CI script documentation in ci-cd-workflow.md...",
|
||||
"zh": "\nChecking CI script documentation in ci-cd-workflow.md..."
|
||||
},
|
||||
"\nChecking module documentation in architecture.md...": {
|
||||
"en": "\nChecking module documentation in architecture.md..."
|
||||
"bg": "\nChecking module documentation in architecture.md...",
|
||||
"de": "\nChecking module documentation in architecture.md...",
|
||||
"en": "\nChecking module documentation in architecture.md...",
|
||||
"ru": "\nChecking module documentation in architecture.md...",
|
||||
"zh": "\nChecking module documentation in architecture.md..."
|
||||
},
|
||||
"\nDoc coverage: {covered}/{total} ({pct}%)": {
|
||||
"en": "\nDoc coverage: {covered}/{total} ({pct}%)"
|
||||
"bg": "\nDoc coverage: {covered}/{total} ({pct}%)",
|
||||
"de": "\nDoc coverage: {covered}/{total} ({pct}%)",
|
||||
"en": "\nDoc coverage: {covered}/{total} ({pct}%)",
|
||||
"ru": "\nDoc coverage: {covered}/{total} ({pct}%)",
|
||||
"zh": "\nDoc coverage: {covered}/{total} ({pct}%)"
|
||||
},
|
||||
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}": {
|
||||
"en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
|
||||
"bg": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
||||
"de": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
||||
"en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
||||
"ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
||||
"zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
|
||||
},
|
||||
"\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": {
|
||||
"en": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce."
|
||||
"bg": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
|
||||
"de": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
|
||||
"en": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
|
||||
"ru": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
|
||||
"zh": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce."
|
||||
},
|
||||
"\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.": {
|
||||
"bg": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
|
||||
"de": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
|
||||
"en": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
|
||||
"ru": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
|
||||
"zh": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report."
|
||||
},
|
||||
"\nIntegrity check FAILED ({count} issues):": {
|
||||
"en": "\nIntegrity check FAILED ({count} issues):"
|
||||
"bg": "\nIntegrity check FAILED ({count} issues):",
|
||||
"de": "\nIntegrity check FAILED ({count} issues):",
|
||||
"en": "\nIntegrity check FAILED ({count} issues):",
|
||||
"ru": "\nIntegrity check FAILED ({count} issues):",
|
||||
"zh": "\nIntegrity check FAILED ({count} issues):"
|
||||
},
|
||||
"\nIntegrity check passed — all {count} pages verified.": {
|
||||
"en": "\nIntegrity check passed — all {count} pages verified."
|
||||
"bg": "\nIntegrity check passed — all {count} pages verified.",
|
||||
"de": "\nIntegrity check passed — all {count} pages verified.",
|
||||
"en": "\nIntegrity check passed — all {count} pages verified.",
|
||||
"ru": "\nIntegrity check passed — all {count} pages verified.",
|
||||
"zh": "\nIntegrity check passed — all {count} pages verified."
|
||||
},
|
||||
"\nLatest tag: {tag}": {
|
||||
"bg": "\nLatest tag: {tag}",
|
||||
"de": "\nLatest tag: {tag}",
|
||||
"en": "\nLatest tag: {tag}",
|
||||
"ru": "\nLatest tag: {tag}",
|
||||
"zh": "\nLatest tag: {tag}"
|
||||
},
|
||||
"\nMissing documentation:": {
|
||||
"en": "\nMissing documentation:"
|
||||
"bg": "\nMissing documentation:",
|
||||
"de": "\nMissing documentation:",
|
||||
"en": "\nMissing documentation:",
|
||||
"ru": "\nMissing documentation:",
|
||||
"zh": "\nMissing documentation:"
|
||||
},
|
||||
"\nResult: {status}": {
|
||||
"en": "\nResult: {status}"
|
||||
"bg": "\nResult: {status}",
|
||||
"de": "\nResult: {status}",
|
||||
"en": "\nResult: {status}",
|
||||
"ru": "\nResult: {status}",
|
||||
"zh": "\nResult: {status}"
|
||||
},
|
||||
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).": {
|
||||
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
|
||||
"bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
|
||||
"de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
|
||||
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
|
||||
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
|
||||
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
|
||||
},
|
||||
"\nRunning full wiki integrity check...": {
|
||||
"en": "\nRunning full wiki integrity check..."
|
||||
"bg": "\nRunning full wiki integrity check...",
|
||||
"de": "\nRunning full wiki integrity check...",
|
||||
"en": "\nRunning full wiki integrity check...",
|
||||
"ru": "\nRunning full wiki integrity check...",
|
||||
"zh": "\nRunning full wiki integrity check..."
|
||||
},
|
||||
"\nTag → Commit alignment:": {
|
||||
"bg": "\nTag → Commit alignment:",
|
||||
"de": "\nTag → Commit alignment:",
|
||||
"en": "\nTag → Commit alignment:",
|
||||
"ru": "\nTag → Commit alignment:",
|
||||
"zh": "\nTag → Commit alignment:"
|
||||
},
|
||||
"\nUntagged release commits:": {
|
||||
"bg": "\nUntagged release commits:",
|
||||
"de": "\nUntagged release commits:",
|
||||
"en": "\nUntagged release commits:",
|
||||
"ru": "\nUntagged release commits:",
|
||||
"zh": "\nUntagged release commits:"
|
||||
},
|
||||
"\nUser-facing changes ({count}):": {
|
||||
"en": "\nUser-facing changes ({count}):"
|
||||
},
|
||||
"\nUser-facing files changed ({count}):": {
|
||||
"en": "\nUser-facing files changed ({count}):"
|
||||
"bg": "\nUser-facing changes ({count}):",
|
||||
"de": "\nUser-facing changes ({count}):",
|
||||
"en": "\nUser-facing changes ({count}):",
|
||||
"ru": "\nUser-facing changes ({count}):",
|
||||
"zh": "\nUser-facing changes ({count}):"
|
||||
},
|
||||
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!": {
|
||||
"en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!"
|
||||
"bg": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
|
||||
"de": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
|
||||
"en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
|
||||
"ru": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
|
||||
"zh": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!"
|
||||
},
|
||||
"\nVerification passed — all wiki pages have correct content.": {
|
||||
"en": "\nVerification passed — all wiki pages have correct content."
|
||||
"bg": "\nVerification passed — all wiki pages have correct content.",
|
||||
"de": "\nVerification passed — all wiki pages have correct content.",
|
||||
"en": "\nVerification passed — all wiki pages have correct content.",
|
||||
"ru": "\nVerification passed — all wiki pages have correct content.",
|
||||
"zh": "\nVerification passed — all wiki pages have correct content."
|
||||
},
|
||||
"\nVerifying wiki pages have content...": {
|
||||
"en": "\nVerifying wiki pages have content..."
|
||||
"bg": "\nVerifying wiki pages have content...",
|
||||
"de": "\nVerifying wiki pages have content...",
|
||||
"en": "\nVerifying wiki pages have content...",
|
||||
"ru": "\nVerifying wiki pages have content...",
|
||||
"zh": "\nVerifying wiki pages have content..."
|
||||
},
|
||||
"\nWorkflow-only changes ({count}):": {
|
||||
"en": "\nWorkflow-only changes ({count}):"
|
||||
"bg": "\nWorkflow-only changes ({count}):",
|
||||
"de": "\nWorkflow-only changes ({count}):",
|
||||
"en": "\nWorkflow-only changes ({count}):",
|
||||
"ru": "\nWorkflow-only changes ({count}):",
|
||||
"zh": "\nWorkflow-only changes ({count}):"
|
||||
},
|
||||
"\n[dry-run] Changelog:\n{changelog}": {
|
||||
"en": "\n[dry-run] Changelog:\n{changelog}"
|
||||
"bg": "\n[dry-run] Changelog:\n{changelog}",
|
||||
"de": "\n[dry-run] Changelog:\n{changelog}",
|
||||
"en": "\n[dry-run] Changelog:\n{changelog}",
|
||||
"ru": "\n[dry-run] Changelog:\n{changelog}",
|
||||
"zh": "\n[dry-run] Changelog:\n{changelog}"
|
||||
},
|
||||
"\n{label} files changed ({count}):": {
|
||||
"bg": "\n{label} files changed ({count}):",
|
||||
"de": "\n{label} files changed ({count}):",
|
||||
"en": "\n{label} files changed ({count}):",
|
||||
"ru": "\n{label} files changed ({count}):",
|
||||
"zh": "\n{label} files changed ({count}):"
|
||||
},
|
||||
"\n{tag} files ({count}):": {
|
||||
"bg": "\n{tag} files ({count}):",
|
||||
"de": "\n{tag} files ({count}):",
|
||||
"en": "\n{tag} files ({count}):",
|
||||
"ru": "\n{tag} files ({count}):",
|
||||
"zh": "\n{tag} files ({count}):"
|
||||
},
|
||||
" - Auto-delete branch after merge: yes": {
|
||||
"en": " - Auto-delete branch after merge: yes",
|
||||
"bg": " - Автоматично изтриване на клон след сливане: да",
|
||||
"de": " - Branch nach Merge automatisch löschen: ja",
|
||||
"en": " - Auto-delete branch after merge: yes",
|
||||
"ru": " - Автоудаление ветки после слияния: да",
|
||||
"zh": " - 合并后自动删除分支: 是"
|
||||
},
|
||||
" - Block outdated branches: yes": {
|
||||
"en": " - Block outdated branches: yes",
|
||||
"bg": " - Блокиране на остарели клонове: да",
|
||||
"de": " - Veraltete Branches blockieren: ja",
|
||||
"en": " - Block outdated branches: yes",
|
||||
"ru": " - Блокировать устаревшие ветки: да",
|
||||
"zh": " - 阻止过时分支: 是"
|
||||
},
|
||||
" - Block rejected reviews: yes": {
|
||||
"en": " - Block rejected reviews: yes",
|
||||
"bg": " - Блокиране на отхвърлени рецензии: да",
|
||||
"de": " - Abgelehnte Reviews blockieren: ja",
|
||||
"en": " - Block rejected reviews: yes",
|
||||
"ru": " - Блокировать отклонённые ревью: да",
|
||||
"zh": " - 阻止被拒绝的审查: 是"
|
||||
},
|
||||
" - Direct pushes: BLOCKED (require PR, whitelisted users can push)": {
|
||||
"en": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)"
|
||||
"bg": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
|
||||
"de": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
|
||||
"en": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
|
||||
"ru": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
|
||||
"zh": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)"
|
||||
},
|
||||
" - Dismiss stale approvals: yes": {
|
||||
"en": " - Dismiss stale approvals: yes",
|
||||
"bg": " - Анулиране на остарели одобрения: да",
|
||||
"de": " - Veraltete Genehmigungen ablehnen: ja",
|
||||
"en": " - Dismiss stale approvals: yes",
|
||||
"ru": " - Отклонять устаревшие одобрения: да",
|
||||
"zh": " - 忽略过时审批: 是"
|
||||
},
|
||||
" - Required approvals: {count}": {
|
||||
"en": " - Required approvals: {count}",
|
||||
"bg": " - Необходими одобрения: {count}",
|
||||
"de": " - Erforderliche Genehmigungen: {count}",
|
||||
"en": " - Required approvals: {count}",
|
||||
"ru": " - Требуемые одобрения: {count}",
|
||||
"zh": " - 必需审批数: {count}"
|
||||
},
|
||||
" - Required status checks: {checks}": {
|
||||
"en": " - Required status checks: {checks}",
|
||||
"bg": " - Необходими проверки на състоянието: {checks}",
|
||||
"de": " - Erforderliche Status-Checks: {checks}",
|
||||
"en": " - Required status checks: {checks}",
|
||||
"ru": " - Требуемые проверки статуса: {checks}",
|
||||
"zh": " - 必需状态检查: {checks}"
|
||||
},
|
||||
" Created: {title}": {
|
||||
"en": " Created: {title}"
|
||||
"bg": " Created: {title}",
|
||||
"de": " Created: {title}",
|
||||
"en": " Created: {title}",
|
||||
"ru": " Created: {title}",
|
||||
"zh": " Created: {title}"
|
||||
},
|
||||
" FAIL: {title} — content mismatch or empty!": {
|
||||
"en": " FAIL: {title} — content mismatch or empty!"
|
||||
"bg": " FAIL: {title} — content mismatch or empty!",
|
||||
"de": " FAIL: {title} — content mismatch or empty!",
|
||||
"en": " FAIL: {title} — content mismatch or empty!",
|
||||
"ru": " FAIL: {title} — content mismatch or empty!",
|
||||
"zh": " FAIL: {title} — content mismatch or empty!"
|
||||
},
|
||||
" MISSING: devx {cmd}": {
|
||||
"en": " MISSING: devx {cmd}",
|
||||
"bg": " ЛИПСВА: devx {cmd}",
|
||||
"de": " FEHLT: devx {cmd}",
|
||||
"en": " MISSING: devx {cmd}",
|
||||
"ru": " ОТСУТСТВУЕТ: devx {cmd}",
|
||||
"zh": " 缺失: devx {cmd}"
|
||||
},
|
||||
" MISSING: grm {cmd}": {
|
||||
"en": " MISSING: grm {cmd}"
|
||||
},
|
||||
" MISSING: {module}": {
|
||||
"en": " MISSING: {module}"
|
||||
"bg": " MISSING: {module}",
|
||||
"de": " MISSING: {module}",
|
||||
"en": " MISSING: {module}",
|
||||
"ru": " MISSING: {module}",
|
||||
"zh": " MISSING: {module}"
|
||||
},
|
||||
" MISSING: {script}": {
|
||||
"en": " MISSING: {script}"
|
||||
"bg": " MISSING: {script}",
|
||||
"de": " MISSING: {script}",
|
||||
"en": " MISSING: {script}",
|
||||
"ru": " MISSING: {script}",
|
||||
"zh": " MISSING: {script}"
|
||||
},
|
||||
" OK: devx {cmd}": {
|
||||
"en": " OK: devx {cmd}",
|
||||
"bg": " ОК: devx {cmd}",
|
||||
"de": " OK: devx {cmd}",
|
||||
"en": " OK: devx {cmd}",
|
||||
"ru": " ОК: devx {cmd}",
|
||||
"zh": " 正常: devx {cmd}"
|
||||
},
|
||||
" OK: grm {cmd}": {
|
||||
"en": " OK: grm {cmd}"
|
||||
},
|
||||
" OK: {module}": {
|
||||
"en": " OK: {module}"
|
||||
"bg": " OK: {module}",
|
||||
"de": " OK: {module}",
|
||||
"en": " OK: {module}",
|
||||
"ru": " OK: {module}",
|
||||
"zh": " OK: {module}"
|
||||
},
|
||||
" OK: {script}": {
|
||||
"en": " OK: {script}"
|
||||
"bg": " OK: {script}",
|
||||
"de": " OK: {script}",
|
||||
"en": " OK: {script}",
|
||||
"ru": " OK: {script}",
|
||||
"zh": " OK: {script}"
|
||||
},
|
||||
" OK: {title} ({chars} chars)": {
|
||||
"en": " OK: {title} ({chars} chars)"
|
||||
"bg": " OK: {title} ({chars} chars)",
|
||||
"de": " OK: {title} ({chars} chars)",
|
||||
"en": " OK: {title} ({chars} chars)",
|
||||
"ru": " OK: {title} ({chars} chars)",
|
||||
"zh": " OK: {title} ({chars} chars)"
|
||||
},
|
||||
" Updated: {title}": {
|
||||
"en": " Updated: {title}"
|
||||
"bg": " Updated: {title}",
|
||||
"de": " Updated: {title}",
|
||||
"en": " Updated: {title}",
|
||||
"ru": " Updated: {title}",
|
||||
"zh": " Updated: {title}"
|
||||
},
|
||||
"--skip-build: skipping package build and PyPI publish.": {
|
||||
"bg": "--skip-build: skipping package build and PyPI publish.",
|
||||
"de": "--skip-build: skipping package build and PyPI publish.",
|
||||
"en": "--skip-build: skipping package build and PyPI publish.",
|
||||
"ru": "--skip-build: skipping package build and PyPI publish.",
|
||||
"zh": "--skip-build: skipping package build and PyPI publish."
|
||||
},
|
||||
"=== Release Alignment Verification ===\n": {
|
||||
"bg": "=== Release Alignment Verification ===\n",
|
||||
"de": "=== Release Alignment Verification ===\n",
|
||||
"en": "=== Release Alignment Verification ===\n",
|
||||
"ru": "=== Release Alignment Verification ===\n",
|
||||
"zh": "=== Release Alignment Verification ===\n"
|
||||
},
|
||||
"API poll warning: {exc}": {
|
||||
"en": "API poll warning: {exc}"
|
||||
"bg": "API poll warning: {exc}",
|
||||
"de": "API poll warning: {exc}",
|
||||
"en": "API poll warning: {exc}",
|
||||
"ru": "API poll warning: {exc}",
|
||||
"zh": "API poll warning: {exc}"
|
||||
},
|
||||
"All molecule tests passed.": {
|
||||
"en": "All molecule tests passed."
|
||||
"bg": "All molecule tests passed.",
|
||||
"de": "All molecule tests passed.",
|
||||
"en": "All molecule tests passed.",
|
||||
"ru": "All molecule tests passed.",
|
||||
"zh": "All molecule tests passed."
|
||||
},
|
||||
"Another molecule runner failed. Stopping this runner early.": {
|
||||
"en": "Another molecule runner failed. Stopping this runner early."
|
||||
"bg": "Another molecule runner failed. Stopping this runner early.",
|
||||
"de": "Another molecule runner failed. Stopping this runner early.",
|
||||
"en": "Another molecule runner failed. Stopping this runner early.",
|
||||
"ru": "Another molecule runner failed. Stopping this runner early.",
|
||||
"zh": "Another molecule runner failed. Stopping this runner early."
|
||||
},
|
||||
"Bumping version: {current} -> v{new_version}": {
|
||||
"en": "Bumping version: {current} -> v{new_version}"
|
||||
"bg": "Bumping version: {current} -> v{new_version}",
|
||||
"de": "Bumping version: {current} -> v{new_version}",
|
||||
"en": "Bumping version: {current} -> v{new_version}",
|
||||
"ru": "Bumping version: {current} -> v{new_version}",
|
||||
"zh": "Bumping version: {current} -> v{new_version}"
|
||||
},
|
||||
"Checking CLI command documentation...": {
|
||||
"en": "Checking CLI command documentation..."
|
||||
"bg": "Checking CLI command documentation...",
|
||||
"de": "Checking CLI command documentation...",
|
||||
"en": "Checking CLI command documentation...",
|
||||
"ru": "Checking CLI command documentation...",
|
||||
"zh": "Checking CLI command documentation..."
|
||||
},
|
||||
"Command failed ({cmd}): {stderr}": {
|
||||
"en": "Command failed ({cmd}): {stderr}"
|
||||
"bg": "Command failed ({cmd}): {stderr}",
|
||||
"de": "Command failed ({cmd}): {stderr}",
|
||||
"en": "Command failed ({cmd}): {stderr}",
|
||||
"ru": "Command failed ({cmd}): {stderr}",
|
||||
"zh": "Command failed ({cmd}): {stderr}"
|
||||
},
|
||||
"Comparing {base}..{head} ({count} files changed)": {
|
||||
"en": "Comparing {base}..{head} ({count} files changed)"
|
||||
"bg": "Comparing {base}..{head} ({count} files changed)",
|
||||
"de": "Comparing {base}..{head} ({count} files changed)",
|
||||
"en": "Comparing {base}..{head} ({count} files changed)",
|
||||
"ru": "Comparing {base}..{head} ({count} files changed)",
|
||||
"zh": "Comparing {base}..{head} ({count} files changed)"
|
||||
},
|
||||
"Configuring branch protection for {branch}...": {
|
||||
"en": "Configuring branch protection for {branch}...",
|
||||
"bg": "Конфигуриране на защита на клона {branch}...",
|
||||
"de": "Konfiguriere Branch-Schutz für {branch}...",
|
||||
"en": "Configuring branch protection for {branch}...",
|
||||
"ru": "Настройка защиты ветки {branch}...",
|
||||
"zh": "正在配置 {branch} 的分支保护..."
|
||||
},
|
||||
"Configuring repository settings...": {
|
||||
"en": "Configuring repository settings...",
|
||||
"bg": "Конфигуриране на настройките на хранилището...",
|
||||
"de": "Repository-Einstellungen konfigurieren...",
|
||||
"en": "Configuring repository settings...",
|
||||
"ru": "Настройка параметров репозитория...",
|
||||
"zh": "正在配置仓库设置..."
|
||||
},
|
||||
"Could not extract conventional commit message from PR commits.": {
|
||||
"en": "Could not extract conventional commit message from PR commits."
|
||||
"bg": "Could not extract conventional commit message from PR commits.",
|
||||
"de": "Could not extract conventional commit message from PR commits.",
|
||||
"en": "Could not extract conventional commit message from PR commits.",
|
||||
"ru": "Could not extract conventional commit message from PR commits.",
|
||||
"zh": "Could not extract conventional commit message from PR commits."
|
||||
},
|
||||
"Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": {
|
||||
"en": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task."
|
||||
"bg": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
|
||||
"de": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
|
||||
"en": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
|
||||
"ru": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
|
||||
"zh": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task."
|
||||
},
|
||||
"Could not find __version__ in {file}": {
|
||||
"en": "Could not find __version__ in {file}"
|
||||
"bg": "Could not find __version__ in {file}",
|
||||
"de": "Could not find __version__ in {file}",
|
||||
"en": "Could not find __version__ in {file}",
|
||||
"ru": "Could not find __version__ in {file}",
|
||||
"zh": "Could not find __version__ in {file}"
|
||||
},
|
||||
"Could not parse test execution time from output.": {
|
||||
"en": "Could not parse test execution time from output."
|
||||
"bg": "Could not parse test execution time from output.",
|
||||
"de": "Could not parse test execution time from output.",
|
||||
"en": "Could not parse test execution time from output.",
|
||||
"ru": "Could not parse test execution time from output.",
|
||||
"zh": "Could not parse test execution time from output."
|
||||
},
|
||||
"Created issue #{issue_id}: {title}": {
|
||||
"en": "Created issue #{issue_id}: {title}"
|
||||
"bg": "Created issue #{issue_id}: {title}",
|
||||
"de": "Created issue #{issue_id}: {title}",
|
||||
"en": "Created issue #{issue_id}: {title}",
|
||||
"ru": "Created issue #{issue_id}: {title}",
|
||||
"zh": "Created issue #{issue_id}: {title}"
|
||||
},
|
||||
"Created release commit.": {
|
||||
"en": "Created release commit."
|
||||
"bg": "Created release commit.",
|
||||
"de": "Created release commit.",
|
||||
"en": "Created release commit.",
|
||||
"ru": "Created release commit.",
|
||||
"zh": "Created release commit."
|
||||
},
|
||||
"Docker daemon already running": {
|
||||
"bg": "Докер демонът вече работи",
|
||||
"de": "Docker-Daemon läuft bereits",
|
||||
"en": "Docker daemon already running",
|
||||
"ru": "Демон Docker уже работает",
|
||||
"zh": "Docker 守护进程已在运行"
|
||||
},
|
||||
"Docker daemon failed to start": {
|
||||
"bg": "Docker daemon failed to start",
|
||||
"de": "Docker-Daemon konnte nicht gestartet werden",
|
||||
"en": "Docker daemon failed to start",
|
||||
"ru": "Не удалось запустить Docker-демон",
|
||||
"zh": "Docker 守护进程启动失败"
|
||||
},
|
||||
"Docker daemon started": {
|
||||
"bg": "Docker daemon started",
|
||||
"de": "Docker-Daemon gestartet",
|
||||
"en": "Docker daemon started",
|
||||
"ru": "Docker-демон запущен",
|
||||
"zh": "Docker 守护进程已启动"
|
||||
},
|
||||
"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.",
|
||||
"en": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
||||
"ru": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
||||
"zh": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently."
|
||||
},
|
||||
"ERROR: REPO_TOKEN is not set.": {
|
||||
"en": "ERROR: REPO_TOKEN is not set.",
|
||||
"bg": "ГРЕШКА: REPO_TOKEN не е зададен.",
|
||||
"de": "FEHLER: REPO_TOKEN ist nicht gesetzt.",
|
||||
"en": "ERROR: REPO_TOKEN is not set.",
|
||||
"ru": "ОШИБКА: REPO_TOKEN не задан.",
|
||||
"zh": "错误:未设置 REPO_TOKEN。"
|
||||
},
|
||||
"ERROR: VIKUNJA_TOKEN is not set.": {
|
||||
"en": "ERROR: VIKUNJA_TOKEN is not set.",
|
||||
"bg": "ГРЕШКА: VIKUNJA_TOKEN не е зададен.",
|
||||
"de": "FEHLER: VIKUNJA_TOKEN ist nicht gesetzt.",
|
||||
"ru": "ОШИБКА: VIKUNJA_TOKEN не задан.",
|
||||
"zh": "错误:未设置 VIKUNJA_TOKEN。"
|
||||
},
|
||||
"ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.": {
|
||||
"en": "ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.",
|
||||
"bg": "ГРЕШКА: Името на хранилището не е указано. Използвайте --repo или задайте DEVX_REPO_NAME.",
|
||||
"de": "FEHLER: Repository-Name nicht angegeben. Verwenden Sie --repo oder setzen Sie DEVX_REPO_NAME.",
|
||||
"en": "ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.",
|
||||
"ru": "ОШИБКА: Имя репозитория не указано. Используйте --repo или задайте DEVX_REPO_NAME.",
|
||||
"zh": "错误:未指定仓库名称。请使用 --repo 或设置 DEVX_REPO_NAME。"
|
||||
},
|
||||
"ERROR: Tag consistency check failed. Existing tags are misaligned:": {
|
||||
"bg": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
|
||||
"de": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
|
||||
"en": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
|
||||
"ru": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
|
||||
"zh": "ERROR: Tag consistency check failed. Existing tags are misaligned:"
|
||||
},
|
||||
"ERROR: VIKUNJA_TOKEN is not set.": {
|
||||
"bg": "ГРЕШКА: VIKUNJA_TOKEN не е зададен.",
|
||||
"de": "FEHLER: VIKUNJA_TOKEN ist nicht gesetzt.",
|
||||
"en": "ERROR: VIKUNJA_TOKEN is not set.",
|
||||
"ru": "ОШИБКА: VIKUNJA_TOKEN не задан.",
|
||||
"zh": "错误:未设置 VIKUNJA_TOKEN。"
|
||||
},
|
||||
"ERROR: mapping.json not found at {path}": {
|
||||
"en": "ERROR: mapping.json not found at {path}"
|
||||
"bg": "ERROR: mapping.json not found at {path}",
|
||||
"de": "ERROR: mapping.json not found at {path}",
|
||||
"en": "ERROR: mapping.json not found at {path}",
|
||||
"ru": "ERROR: mapping.json not found at {path}",
|
||||
"zh": "ERROR: mapping.json not found at {path}"
|
||||
},
|
||||
"FAILED: {pair} exited with code {code}": {
|
||||
"en": "FAILED: {pair} exited with code {code}"
|
||||
"bg": "FAILED: {pair} exited with code {code}",
|
||||
"de": "FAILED: {pair} exited with code {code}",
|
||||
"en": "FAILED: {pair} exited with code {code}",
|
||||
"ru": "FAILED: {pair} exited with code {code}",
|
||||
"zh": "FAILED: {pair} exited with code {code}"
|
||||
},
|
||||
"Failed to create issue via tea: {error}": {
|
||||
"en": "Failed to create issue via tea: {error}"
|
||||
"bg": "Failed to create issue via tea: {error}",
|
||||
"de": "Failed to create issue via tea: {error}",
|
||||
"en": "Failed to create issue via tea: {error}",
|
||||
"ru": "Failed to create issue via tea: {error}",
|
||||
"zh": "Failed to create issue via tea: {error}"
|
||||
},
|
||||
"Found {count} existing wiki pages.": {
|
||||
"en": "Found {count} existing wiki pages."
|
||||
"bg": "Found {count} existing wiki pages.",
|
||||
"de": "Found {count} existing wiki pages.",
|
||||
"en": "Found {count} existing wiki pages.",
|
||||
"ru": "Found {count} existing wiki pages.",
|
||||
"zh": "Found {count} existing wiki pages."
|
||||
},
|
||||
"GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
|
||||
"en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."
|
||||
"bg": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||
"de": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||
"en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||
"ru": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||
"zh": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."
|
||||
},
|
||||
"HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping.": {
|
||||
"en": "HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping."
|
||||
"Generated {file} with prefix '{prefix}'.": {
|
||||
"bg": "Generated {file} with prefix '{prefix}'.",
|
||||
"de": "Generated {file} with prefix '{prefix}'.",
|
||||
"en": "Generated {file} with prefix '{prefix}'.",
|
||||
"ru": "Generated {file} with prefix '{prefix}'.",
|
||||
"zh": "Generated {file} with prefix '{prefix}'."
|
||||
},
|
||||
"HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.": {
|
||||
"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.",
|
||||
"en": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
|
||||
"ru": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
|
||||
"zh": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag."
|
||||
},
|
||||
"HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.": {
|
||||
"bg": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.",
|
||||
"de": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.",
|
||||
"en": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.",
|
||||
"ru": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.",
|
||||
"zh": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment."
|
||||
},
|
||||
"HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.": {
|
||||
"bg": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
|
||||
"de": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
|
||||
"en": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
|
||||
"ru": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
|
||||
"zh": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping."
|
||||
},
|
||||
"HTTP error: {status} — {message}": {
|
||||
"en": "HTTP error: {status} — {message}",
|
||||
"bg": "HTTP грешка: {status} — {message}",
|
||||
"de": "HTTP-Fehler: {status} — {message}",
|
||||
"en": "HTTP error: {status} — {message}",
|
||||
"ru": "Ошибка HTTP: {status} — {message}",
|
||||
"zh": "HTTP 错误: {status} — {message}"
|
||||
},
|
||||
"HTTP {status} Forbidden — your token lacks admin rights.\nMake sure the token belongs to a repo owner or organisation admin.\nAlternatively, configure branch protection manually in Settings → Branches.": {
|
||||
"en": "HTTP {status} Forbidden — your token lacks admin rights.\nMake sure the token belongs to a repo owner or organisation admin.\nAlternatively, configure branch protection manually in Settings → Branches.",
|
||||
"bg": "HTTP {status} Забранено — вашият токен няма администраторски права.\nУверете се, че токенът принадлежи на собственик на хранилище или администратор на организация.\nАлтернативно, конфигурирайте защитата на клона ръчно в Настройки → Клонове.",
|
||||
"de": "HTTP {status} Verboten — Ihr Token hat keine Admin-Rechte.\nStellen Sie sicher, dass das Token einem Repository-Besitzer oder Organisations-Admin gehört.\nAlternativ können Sie den Branch-Schutz manuell unter Einstellungen → Branches konfigurieren.",
|
||||
"en": "HTTP {status} Forbidden — your token lacks admin rights.\nMake sure the token belongs to a repo owner or organisation admin.\nAlternatively, configure branch protection manually in Settings → Branches.",
|
||||
"ru": "HTTP {status} Запрещено — у вашего токена нет прав администратора.\nУбедитесь, что токен принадлежит владельцу репозитория или администратору организации.\nЛибо настройте защиту ветки вручную в разделе Настройки → Ветки.",
|
||||
"zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。"
|
||||
},
|
||||
"Head branch is behind master. Pulling and rebasing...": {
|
||||
"en": "Head branch is behind master. Pulling and rebasing..."
|
||||
"bg": "Head branch is behind master. Pulling and rebasing...",
|
||||
"de": "Head branch is behind master. Pulling and rebasing...",
|
||||
"en": "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..."
|
||||
},
|
||||
"Host Docker not available, starting local dockerd...": {
|
||||
"bg": "Хост Docker не е наличен, стартиране на локален dockerd...",
|
||||
"de": "Host-Docker nicht verfügbar, lokaler dockerd wird gestartet...",
|
||||
"en": "Host Docker not available, starting local dockerd...",
|
||||
"ru": "Хост Docker недоступен, запускается локальный dockerd...",
|
||||
"zh": "主机 Docker 不可用,正在启动本地 dockerd..."
|
||||
},
|
||||
"Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": {
|
||||
"en": "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}",
|
||||
"bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}",
|
||||
"de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}",
|
||||
"en": "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}",
|
||||
"ru": "Инфраструктурный коммит (без ID задачи DEVX-N), пропуск обновления 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}"
|
||||
"Integration tests cancelled — another runner failed.": {
|
||||
"bg": "Integration tests cancelled — another runner failed.",
|
||||
"de": "Integration tests cancelled — another runner failed.",
|
||||
"en": "Integration tests cancelled — another runner failed.",
|
||||
"ru": "Integration tests cancelled — another runner failed.",
|
||||
"zh": "Integration tests cancelled — another runner failed."
|
||||
},
|
||||
"Integration tests failed with exit code {code}": {
|
||||
"bg": "Integration tests failed with exit code {code}",
|
||||
"de": "Integration tests failed with exit code {code}",
|
||||
"en": "Integration tests failed with exit code {code}",
|
||||
"ru": "Integration tests failed with exit code {code}",
|
||||
"zh": "Integration tests failed with exit code {code}"
|
||||
},
|
||||
"Integration tests passed.": {
|
||||
"bg": "Integration tests passed.",
|
||||
"de": "Integration tests passed.",
|
||||
"en": "Integration tests passed.",
|
||||
"ru": "Integration tests passed.",
|
||||
"zh": "Integration tests passed."
|
||||
},
|
||||
"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}",
|
||||
"en": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
|
||||
"ru": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
|
||||
"zh": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}"
|
||||
},
|
||||
"Lint passed.": {
|
||||
"en": "Lint passed."
|
||||
"bg": "Lint passed.",
|
||||
"de": "Lint passed.",
|
||||
"en": "Lint passed.",
|
||||
"ru": "Lint passed.",
|
||||
"zh": "Lint passed."
|
||||
},
|
||||
"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.",
|
||||
"en": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
|
||||
"ru": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
|
||||
"zh": "Mapped file {file} is empty. Update the content or remove from mapping.json."
|
||||
},
|
||||
"Mapped file {file} not found. Update mapping.json or create the file.": {
|
||||
"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.",
|
||||
"en": "Mapped file {file} not found. Update mapping.json or create the file.",
|
||||
"ru": "Mapped file {file} not found. Update mapping.json or create the file.",
|
||||
"zh": "Mapped file {file} not found. Update mapping.json or create the file."
|
||||
},
|
||||
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.": {
|
||||
"en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually."
|
||||
"bg": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
|
||||
"de": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
|
||||
"en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
|
||||
"ru": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
|
||||
"zh": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually."
|
||||
},
|
||||
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": {
|
||||
"en": "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.",
|
||||
"bg": "Сливането неуспешно с HTTP {status}: {message}\nПроверете дали PR е готов и имате права за сливане.",
|
||||
"de": "Merge fehlgeschlagen mit HTTP {status}: {message}\nBitte prüfen Sie, ob der PR bereit ist und Sie Merge-Rechte haben.",
|
||||
"en": "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.",
|
||||
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
|
||||
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
|
||||
},
|
||||
"Merged {count} reports: {tests} tests, {failures} failures → {output}": {
|
||||
"bg": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
|
||||
"de": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
|
||||
"en": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
|
||||
"ru": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
|
||||
"zh": "Merged {count} reports: {tests} tests, {failures} failures → {output}"
|
||||
},
|
||||
"Module {mod} has no main() function": {
|
||||
"en": "Module {mod} has no main() function",
|
||||
"bg": "Модул {mod} няма функция main()",
|
||||
"de": "Modul {mod} hat keine main()-Funktion",
|
||||
"en": "Module {mod} has no main() function",
|
||||
"ru": "Модуль {mod} не имеет функции main()",
|
||||
"zh": "模块 {mod} 没有 main() 函数"
|
||||
},
|
||||
"Molecule directory not found: {path}": {
|
||||
"en": "Molecule directory not found: {path}",
|
||||
"bg": "Директорията на molecule не е намерена: {path}",
|
||||
"de": "Molecule-Verzeichnis nicht gefunden: {path}",
|
||||
"en": "Molecule directory not found: {path}",
|
||||
"ru": "Директория molecule не найдена: {path}",
|
||||
"zh": "未找到 molecule 目录: {path}"
|
||||
},
|
||||
"Nice! Gitea release {tag} created.": {
|
||||
"en": "Nice! Gitea release {tag} created.",
|
||||
"bg": "Отлично! Gitea release {tag} е създаден.",
|
||||
"de": "Prima! Gitea-Release {tag} erstellt.",
|
||||
"en": "Nice! Gitea release {tag} created.",
|
||||
"ru": "Отлично! Gitea release {tag} создан.",
|
||||
"zh": "不错!Gitea release {tag} 已创建。"
|
||||
},
|
||||
"Nice! PR #{pr_number} squash-merged with title: {merge_title}": {
|
||||
"en": "Nice! PR #{pr_number} squash-merged with title: {merge_title}",
|
||||
"bg": "Отлично! PR #{pr_number} е squash-merge-нат със заглавие: {merge_title}",
|
||||
"de": "Prima! PR #{pr_number} wurde mit Titel {merge_title} squash-gemergt.",
|
||||
"en": "Nice! PR #{pr_number} squash-merged with title: {merge_title}",
|
||||
"ru": "Отлично! PR #{pr_number} squash-merge с заголовком: {merge_title}",
|
||||
"zh": "不错!PR #{pr_number} 已 squash 合并,标题: {merge_title}"
|
||||
},
|
||||
"Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.": {
|
||||
"en": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered."
|
||||
"bg": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
|
||||
"de": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
|
||||
"en": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
|
||||
"ru": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
|
||||
"zh": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered."
|
||||
},
|
||||
"Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.": {
|
||||
"en": "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.",
|
||||
"bg": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) е обновена и маркирана като готова.",
|
||||
"de": "Prima! Vikunja-Aufgabe {task_id} (ID {vikunja_id}) aktualisiert und als erledigt markiert.",
|
||||
"en": "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.",
|
||||
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
|
||||
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
|
||||
},
|
||||
"No JUnit reports found matching {pattern} — skipping merge.": {
|
||||
"bg": "No JUnit reports found matching {pattern} — skipping merge.",
|
||||
"de": "No JUnit reports found matching {pattern} — skipping merge.",
|
||||
"en": "No JUnit reports found matching {pattern} — skipping merge.",
|
||||
"ru": "No JUnit reports found matching {pattern} — skipping merge.",
|
||||
"zh": "No JUnit reports found matching {pattern} — skipping merge."
|
||||
},
|
||||
"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}.",
|
||||
"en": "No changes between {base} and {head}.",
|
||||
"ru": "No changes between {base} and {head}.",
|
||||
"zh": "No changes between {base} and {head}."
|
||||
},
|
||||
"No staged changes — version and changelog already up to date.": {
|
||||
"en": "No staged changes — version and changelog already up to date."
|
||||
"bg": "No staged changes — version and changelog already up to date.",
|
||||
"de": "No staged changes — version and changelog already up to date.",
|
||||
"en": "No staged changes — version and changelog already up to date.",
|
||||
"ru": "No staged changes — version and changelog already up to date.",
|
||||
"zh": "No staged changes — version and changelog already up to date."
|
||||
},
|
||||
"No tags found — treating all changes as user-facing.": {
|
||||
"en": "No tags found — treating all changes as user-facing."
|
||||
"bg": "No tags found — treating all changes as user-facing.",
|
||||
"de": "No tags found — treating all changes as user-facing.",
|
||||
"en": "No tags found — treating all changes as user-facing.",
|
||||
"ru": "No tags found — treating all changes as user-facing.",
|
||||
"zh": "No tags found — treating all changes as user-facing."
|
||||
},
|
||||
"No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.": {
|
||||
"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.",
|
||||
"en": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
|
||||
"ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
|
||||
"zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID."
|
||||
},
|
||||
"No unreleased changes found. Nothing to release.": {
|
||||
"en": "No unreleased changes found. Nothing to release."
|
||||
"bg": "No unreleased changes found. Nothing to release.",
|
||||
"de": "No unreleased changes found. Nothing to release.",
|
||||
"en": "No unreleased changes found. Nothing to release.",
|
||||
"ru": "No unreleased changes found. Nothing to release.",
|
||||
"zh": "No unreleased changes found. Nothing to release."
|
||||
},
|
||||
"No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.": {
|
||||
"en": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release."
|
||||
"bg": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
|
||||
"de": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
|
||||
"en": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
|
||||
"ru": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
|
||||
"zh": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release."
|
||||
},
|
||||
"Note: Self-approval not allowed. Posting COMMENT instead.": {
|
||||
"en": "Note: Self-approval not allowed. Posting COMMENT instead."
|
||||
"bg": "Note: Self-approval not allowed. Posting COMMENT instead.",
|
||||
"de": "Note: Self-approval not allowed. Posting COMMENT instead.",
|
||||
"en": "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": {
|
||||
"en": "Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
|
||||
"bg": "Опа! Съобщението за commit трябва да следва конвенционален формат.\n Очаква се: <type>: <description>\n Получено: {subject}\n Разрешени типове: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
|
||||
"de": "Ups! Commit-Nachricht muss dem konventionellen Commit-Format folgen.\n Erwartet: <type>: <description>\n Erhalten: {subject}\n Erlaubte Typen: 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",
|
||||
"ru": "Ой! Сообщение коммита должно соответствовать формату conventional commit.\n Ожидается: <type>: <description>\n Получено: {subject}\n Допустимые типы: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
|
||||
"zh": "哎呀!提交消息必须遵循 conventional commit 格式。\n 预期格式: <type>: <description>\n 实际: {subject}\n 允许的类型: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE"
|
||||
},
|
||||
"Oops! Do not include task ID (DEVX-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": {
|
||||
"en": "Oops! Do not include task ID (DEVX-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
|
||||
"bg": "Опа! Не включвайте идентификатор на задача (DEVX-N) в commit-и от feature клонове.\n Идентификаторът ще бъде добавен автоматично при сливане чрез CI.",
|
||||
"de": "Ups! Keine Task-ID (DEVX-N) in Feature-Branch-Commits einfügen.\n Die Task-ID wird beim Merge automatisch über CI hinzugefügt.",
|
||||
"ru": "Ой! Не включайте ID задачи (DEVX-N) в коммиты feature-веток.\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! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": {
|
||||
"bg": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
|
||||
"de": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
|
||||
"en": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
|
||||
"ru": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
|
||||
"zh": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI."
|
||||
},
|
||||
"Oops! Gitea PyPI registry publish failed:\n{stderr}": {
|
||||
"en": "Oops! Gitea PyPI registry publish failed:\n{stderr}",
|
||||
"bg": "Опа! Публикуването в Gitea PyPI registry неуспешно:\n{stderr}",
|
||||
"de": "Ups! Veröffentlichung in der Gitea PyPI-Registry fehlgeschlagen:\n{stderr}",
|
||||
"en": "Oops! Gitea PyPI registry publish failed:\n{stderr}",
|
||||
"ru": "Ой! Публикация в Gitea PyPI registry не удалась:\n{stderr}",
|
||||
"zh": "哎呀!Gitea PyPI registry 发布失败:\n{stderr}"
|
||||
},
|
||||
"Oops! Master branch commit must follow conventional format after task ID.\n Expected: DEVX-N: <type>: <description>\n Got: {subject}": {
|
||||
"en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: DEVX-N: <type>: <description>\n Got: {subject}",
|
||||
"bg": "Опа! Commit-ът в клона master трябва да следва конвенционален формат след идентификатора.\n Очаква се: DEVX-N: <type>: <description>\n Получено: {subject}",
|
||||
"de": "Ups! Master-Branch-Commit muss nach der Task-ID dem konventionellen Format folgen.\n Erwartet: DEVX-N: <type>: <description>\n Erhalten: {subject}",
|
||||
"ru": "Ой! Коммит в ветку master после ID задачи должен соответствовать conventional формату.\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: {prefix}-N: <type>: <description>\n Got: {subject}": {
|
||||
"bg": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
|
||||
"de": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
|
||||
"en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
|
||||
"ru": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
|
||||
"zh": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {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}": {
|
||||
"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}",
|
||||
"de": "Ups! Master-Branch-Commits müssen mit einer Task-ID beginnen.\n Erwartet: DEVX-N: <conventional commit message>\n Erhalten: {subject}",
|
||||
"ru": "Ой! Коммиты в ветку 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! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}": {
|
||||
"bg": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
|
||||
"de": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
|
||||
"en": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
|
||||
"ru": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
|
||||
"zh": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}"
|
||||
},
|
||||
"Oops! No task ID found in .taskid file or branch name '{branch}'.": {
|
||||
"en": "Oops! No task ID found in .taskid file or branch name '{branch}'."
|
||||
"bg": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
|
||||
"de": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
|
||||
"en": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
|
||||
"ru": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
|
||||
"zh": "Oops! No task ID found in .taskid file or branch name '{branch}'."
|
||||
},
|
||||
"Oops! PR title must follow format 'DEVX-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
|
||||
"en": "Oops! PR title must follow format 'DEVX-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
|
||||
"bg": "Опа! Заглавието на PR трябва да следва формата 'DEVX-N: <заглавие на задача>'.\n Очаква се: {task_id}: <заглавие на задача>\n Получено: {pr_title}",
|
||||
"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 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}",
|
||||
"en": "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}"
|
||||
},
|
||||
"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}",
|
||||
"en": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
|
||||
"ru": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
|
||||
"zh": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}"
|
||||
},
|
||||
"Oops! Package build failed:\n{stderr}": {
|
||||
"en": "Oops! Package build failed:\n{stderr}",
|
||||
"bg": "Опа! Сборката на пакета неуспешна:\n{stderr}",
|
||||
"de": "Ups! Paket-Build fehlgeschlagen:\n{stderr}",
|
||||
"en": "Oops! Package build failed:\n{stderr}",
|
||||
"ru": "Ой! Сборка пакета не удалась:\n{stderr}",
|
||||
"zh": "哎呀!包构建失败:\n{stderr}"
|
||||
},
|
||||
"Oops! PyPI publish failed:\n{stderr}": {
|
||||
"en": "Oops! PyPI publish failed:\n{stderr}",
|
||||
"bg": "Опа! Публикуването в PyPI неуспешно:\n{stderr}",
|
||||
"de": "Ups! PyPI-Veröffentlichung fehlgeschlagen:\n{stderr}",
|
||||
"en": "Oops! PyPI publish failed:\n{stderr}",
|
||||
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
|
||||
"zh": "哎呀!PyPI 发布失败:\n{stderr}"
|
||||
},
|
||||
"PASSED: {pair}": {
|
||||
"en": "PASSED: {pair}"
|
||||
"bg": "PASSED: {pair}",
|
||||
"de": "PASSED: {pair}",
|
||||
"en": "PASSED: {pair}",
|
||||
"ru": "PASSED: {pair}",
|
||||
"zh": "PASSED: {pair}"
|
||||
},
|
||||
"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}",
|
||||
"en": "PR number must be an integer, got: {pr_number}",
|
||||
"ru": "PR number must be an integer, got: {pr_number}",
|
||||
"zh": "PR number must be an integer, got: {pr_number}"
|
||||
},
|
||||
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": {
|
||||
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}"
|
||||
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
||||
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
||||
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
||||
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
||||
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}"
|
||||
},
|
||||
"PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.": {
|
||||
"en": "PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.",
|
||||
"bg": "PYPI_TOKEN не е зададен и няма конфигуриран URL на registry — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.",
|
||||
"de": "PYPI_TOKEN nicht gesetzt und keine Registry-URL konfiguriert — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.",
|
||||
"en": "PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.",
|
||||
"ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в 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。"
|
||||
"Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.": {
|
||||
"bg": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||
"de": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||
"en": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||
"ru": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||
"zh": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit."
|
||||
},
|
||||
"Published to Gitea PyPI registry.": {
|
||||
"en": "Published to Gitea PyPI registry.",
|
||||
"bg": "Публикувано в Gitea PyPI registry.",
|
||||
"de": "In der Gitea PyPI-Registry veröffentlicht.",
|
||||
"en": "Published to Gitea PyPI registry.",
|
||||
"ru": "Опубликовано в Gitea PyPI registry.",
|
||||
"zh": "已发布到 Gitea PyPI registry。"
|
||||
},
|
||||
"Published to PyPI.": {
|
||||
"en": "Published to PyPI.",
|
||||
"bg": "Публикувано в PyPI.",
|
||||
"de": "In PyPI veröffentlicht.",
|
||||
"en": "Published to PyPI.",
|
||||
"ru": "Опубликовано в PyPI.",
|
||||
"zh": "已发布到 PyPI。"
|
||||
},
|
||||
"Pushed release commit to master.": {
|
||||
"en": "Pushed release commit to master."
|
||||
"bg": "Pushed release commit to master.",
|
||||
"de": "Pushed release commit to master.",
|
||||
"en": "Pushed release commit to master.",
|
||||
"ru": "Pushed release commit to master.",
|
||||
"zh": "Pushed release commit to master."
|
||||
},
|
||||
"Rebased and pushed. Retrying merge...": {
|
||||
"en": "Rebased and pushed. Retrying merge..."
|
||||
"bg": "Rebased and pushed. Retrying merge...",
|
||||
"de": "Rebased and pushed. Retrying merge...",
|
||||
"en": "Rebased and pushed. Retrying merge...",
|
||||
"ru": "Rebased and pushed. Retrying merge...",
|
||||
"zh": "Rebased and pushed. Retrying merge..."
|
||||
},
|
||||
"Release creation failed: {error}": {
|
||||
"en": "Release creation failed: {error}"
|
||||
"bg": "Release creation failed: {error}",
|
||||
"de": "Release creation failed: {error}",
|
||||
"en": "Release creation failed: {error}",
|
||||
"ru": "Release creation failed: {error}",
|
||||
"zh": "Release creation failed: {error}"
|
||||
},
|
||||
"Release must be run on master, currently on '{branch}'.": {
|
||||
"en": "Release must be run on master, currently on '{branch}'."
|
||||
"bg": "Release must be run on master, currently on '{branch}'.",
|
||||
"de": "Release must be run on master, currently on '{branch}'.",
|
||||
"en": "Release must be run on master, currently on '{branch}'.",
|
||||
"ru": "Release must be run on master, currently on '{branch}'.",
|
||||
"zh": "Release must be run on master, currently on '{branch}'."
|
||||
},
|
||||
"Repo must be in 'owner/name' format, got: {repo}": {
|
||||
"bg": "Repo must be in 'owner/name' format, got: {repo}",
|
||||
"de": "Repo must be in 'owner/name' format, got: {repo}",
|
||||
"en": "Repo must be in 'owner/name' format, got: {repo}",
|
||||
"ru": "Repo must be in 'owner/name' format, got: {repo}",
|
||||
"zh": "Repo must be in 'owner/name' format, got: {repo}"
|
||||
},
|
||||
"Repository configuration complete.": {
|
||||
"en": "Repository configuration complete.",
|
||||
"bg": "Конфигурирането на хранилището е завършено.",
|
||||
"de": "Repository-Konfiguration abgeschlossen.",
|
||||
"en": "Repository configuration complete.",
|
||||
"ru": "Конфигурация репозитория завершена.",
|
||||
"zh": "仓库配置完成。"
|
||||
},
|
||||
"Roles directory not found: {path}": {
|
||||
"bg": "Roles directory not found: {path}",
|
||||
"de": "Roles directory not found: {path}",
|
||||
"en": "Roles directory not found: {path}",
|
||||
"ru": "Roles directory not found: {path}",
|
||||
"zh": "Roles directory not found: {path}"
|
||||
},
|
||||
"Runner index {index} out of range (0..{max})": {
|
||||
"en": "Runner index {index} out of range (0..{max})",
|
||||
"bg": "Индексът на runner {index} е извън диапазона (0..{max})",
|
||||
"de": "Runner-Index {index} außerhalb des Bereichs (0..{max})",
|
||||
"en": "Runner index {index} out of range (0..{max})",
|
||||
"ru": "Индекс runner {index} вне диапазона (0..{max})",
|
||||
"zh": "Runner 索引 {index} 超出范围 (0..{max})"
|
||||
},
|
||||
"Running lint checks...": {
|
||||
"en": "Running lint checks..."
|
||||
"bg": "Running lint checks...",
|
||||
"de": "Running lint checks...",
|
||||
"en": "Running lint checks...",
|
||||
"ru": "Running lint checks...",
|
||||
"zh": "Running lint checks..."
|
||||
},
|
||||
"Running tests...": {
|
||||
"en": "Running tests..."
|
||||
"bg": "Running tests...",
|
||||
"de": "Running tests...",
|
||||
"en": "Running tests...",
|
||||
"ru": "Running tests...",
|
||||
"zh": "Running tests..."
|
||||
},
|
||||
"Running: {scenario} on {platform}": {
|
||||
"en": "Running: {scenario} on {platform}"
|
||||
"bg": "Running: {scenario} on {platform}",
|
||||
"de": "Running: {scenario} on {platform}",
|
||||
"en": "Running: {scenario} on {platform}",
|
||||
"ru": "Running: {scenario} on {platform}",
|
||||
"zh": "Running: {scenario} on {platform}"
|
||||
},
|
||||
"Skipping commit push — no staged changes.": {
|
||||
"en": "Skipping commit push — no staged changes."
|
||||
"bg": "Skipping commit push — no staged changes.",
|
||||
"de": "Skipping commit push — no staged changes.",
|
||||
"en": "Skipping commit push — no staged changes.",
|
||||
"ru": "Skipping commit push — no staged changes.",
|
||||
"zh": "Skipping commit push — no staged changes."
|
||||
},
|
||||
"Syncing {count} documentation pages to wiki...": {
|
||||
"en": "Syncing {count} documentation pages to wiki..."
|
||||
"bg": "Syncing {count} documentation pages to wiki...",
|
||||
"de": "Syncing {count} documentation pages to wiki...",
|
||||
"en": "Syncing {count} documentation pages to wiki...",
|
||||
"ru": "Syncing {count} documentation pages to wiki...",
|
||||
"zh": "Syncing {count} documentation pages to wiki..."
|
||||
},
|
||||
"Tag consistency check failed.": {
|
||||
"bg": "Tag consistency check failed.",
|
||||
"de": "Tag consistency check failed.",
|
||||
"en": "Tag consistency check failed.",
|
||||
"ru": "Tag consistency check failed.",
|
||||
"zh": "Tag consistency check failed."
|
||||
},
|
||||
"Tag v{version} already existed. Publish workflow should already have been triggered.": {
|
||||
"en": "Tag v{version} already existed. Publish workflow should already have been triggered."
|
||||
"bg": "Tag v{version} already existed. Publish workflow should already have been triggered.",
|
||||
"de": "Tag v{version} already existed. Publish workflow should already have been triggered.",
|
||||
"en": "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.": {
|
||||
"en": "Tag {tag} already exists, skipping creation."
|
||||
"Tag {tag} already exists and points to HEAD. Skipping creation.": {
|
||||
"bg": "Tag {tag} already exists and points to HEAD. Skipping creation.",
|
||||
"de": "Tag {tag} already exists and points to HEAD. Skipping creation.",
|
||||
"en": "Tag {tag} already exists and points to HEAD. Skipping creation.",
|
||||
"ru": "Tag {tag} already exists and points to HEAD. Skipping creation.",
|
||||
"zh": "Tag {tag} already exists and points to HEAD. Skipping creation."
|
||||
},
|
||||
"Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.": {
|
||||
"bg": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.",
|
||||
"de": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.",
|
||||
"en": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.",
|
||||
"ru": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.",
|
||||
"zh": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details."
|
||||
},
|
||||
"Task ID: {task_id}": {
|
||||
"en": "Task ID: {task_id}"
|
||||
"bg": "Task ID: {task_id}",
|
||||
"de": "Task ID: {task_id}",
|
||||
"en": "Task ID: {task_id}",
|
||||
"ru": "Task ID: {task_id}",
|
||||
"zh": "Task ID: {task_id}"
|
||||
},
|
||||
"Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.": {
|
||||
"bg": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
|
||||
"de": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
|
||||
"en": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
|
||||
"ru": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
|
||||
"zh": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls."
|
||||
},
|
||||
"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}",
|
||||
"en": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
|
||||
"ru": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
|
||||
"zh": "Tests failed — refusing to release. Fix test failures first.\n{stderr}"
|
||||
},
|
||||
"Tests passed.": {
|
||||
"en": "Tests passed."
|
||||
"bg": "Tests passed.",
|
||||
"de": "Tests passed.",
|
||||
"en": "Tests passed.",
|
||||
"ru": "Tests passed.",
|
||||
"zh": "Tests passed."
|
||||
},
|
||||
"Unit tests passed in {duration:.2f}s (under {max}s limit).": {
|
||||
"en": "Unit tests passed in {duration:.2f}s (under {max}s limit)."
|
||||
"Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).": {
|
||||
"bg": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
|
||||
"de": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
|
||||
"en": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
|
||||
"ru": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
|
||||
"zh": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit)."
|
||||
},
|
||||
"Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.": {
|
||||
"en": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures."
|
||||
"bg": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
|
||||
"de": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
|
||||
"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.",
|
||||
"ru": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
|
||||
"zh": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures."
|
||||
},
|
||||
"Unknown check category '{check}'. Available: all, user-facing{tags}": {
|
||||
"bg": "Unknown check category '{check}'. Available: all, user-facing{tags}",
|
||||
"de": "Unknown check category '{check}'. Available: all, user-facing{tags}",
|
||||
"en": "Unknown check category '{check}'. Available: all, user-facing{tags}",
|
||||
"ru": "Unknown check category '{check}'. Available: all, user-facing{tags}",
|
||||
"zh": "Unknown check category '{check}'. Available: all, user-facing{tags}"
|
||||
},
|
||||
"Updated version in {init}": {
|
||||
"en": "Updated version in {init}"
|
||||
"bg": "Updated version in {init}",
|
||||
"de": "Updated version in {init}",
|
||||
"en": "Updated version in {init}",
|
||||
"ru": "Updated version in {init}",
|
||||
"zh": "Updated version in {init}"
|
||||
},
|
||||
"Updated {changelog_file}": {
|
||||
"en": "Updated {changelog_file}"
|
||||
"bg": "Updated {changelog_file}",
|
||||
"de": "Updated {changelog_file}",
|
||||
"en": "Updated {changelog_file}",
|
||||
"ru": "Updated {changelog_file}",
|
||||
"zh": "Updated {changelog_file}"
|
||||
},
|
||||
"VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.": {
|
||||
"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.",
|
||||
"en": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
|
||||
"ru": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
|
||||
"zh": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles."
|
||||
},
|
||||
"Version file: {file}": {
|
||||
"bg": "Version file: {file}",
|
||||
"de": "Version file: {file}",
|
||||
"en": "Version file: {file}",
|
||||
"ru": "Version file: {file}",
|
||||
"zh": "Version file: {file}"
|
||||
},
|
||||
"Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.": {
|
||||
"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.",
|
||||
"en": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
|
||||
"ru": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
|
||||
"zh": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update."
|
||||
},
|
||||
"WARNING: --skip-tests passed — skipping test verification.": {
|
||||
"en": "WARNING: --skip-tests passed — skipping test verification."
|
||||
"bg": "WARNING: --skip-tests passed — skipping test verification.",
|
||||
"de": "WARNING: --skip-tests passed — skipping test verification.",
|
||||
"en": "WARNING: --skip-tests passed — skipping test verification.",
|
||||
"ru": "WARNING: --skip-tests passed — skipping test verification.",
|
||||
"zh": "WARNING: --skip-tests passed — skipping test verification."
|
||||
},
|
||||
"WARNING: File {file} is empty — skipping.": {
|
||||
"en": "WARNING: File {file} is empty — skipping."
|
||||
},
|
||||
"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."
|
||||
"Warning: could not fetch tags from origin.": {
|
||||
"bg": "Warning: could not fetch tags from origin.",
|
||||
"de": "Warning: could not fetch tags from origin.",
|
||||
"en": "Warning: could not fetch tags from origin.",
|
||||
"ru": "Warning: could not fetch tags from origin.",
|
||||
"zh": "Warning: could not fetch tags from origin."
|
||||
},
|
||||
"Wiki integrity check failed — {count} issue(s)": {
|
||||
"en": "Wiki integrity check failed — {count} issue(s)"
|
||||
"bg": "Wiki integrity check failed — {count} issue(s)",
|
||||
"de": "Wiki integrity check failed — {count} issue(s)",
|
||||
"en": "Wiki integrity check failed — {count} issue(s)",
|
||||
"ru": "Wiki integrity check failed — {count} issue(s)",
|
||||
"zh": "Wiki integrity check failed — {count} issue(s)"
|
||||
},
|
||||
"Wiki verification failed — {failures} page(s) empty or mismatched": {
|
||||
"en": "Wiki verification failed — {failures} page(s) empty or mismatched"
|
||||
"bg": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
||||
"de": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
||||
"en": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
||||
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
||||
"zh": "Wiki verification failed — {failures} page(s) empty or mismatched"
|
||||
},
|
||||
"[dry-run] Would commit: release: v{version}": {
|
||||
"en": "[dry-run] Would commit: release: v{version}"
|
||||
"bg": "[dry-run] Would commit: release: v{version}",
|
||||
"de": "[dry-run] Would commit: release: v{version}",
|
||||
"en": "[dry-run] Would commit: release: v{version}",
|
||||
"ru": "[dry-run] Would commit: release: v{version}",
|
||||
"zh": "[dry-run] Would commit: release: v{version}"
|
||||
},
|
||||
"[dry-run] Would create tag: v{version}": {
|
||||
"en": "[dry-run] Would create tag: v{version}"
|
||||
"bg": "[dry-run] Would create tag: v{version}",
|
||||
"de": "[dry-run] Would create tag: v{version}",
|
||||
"en": "[dry-run] Would create tag: v{version}",
|
||||
"ru": "[dry-run] Would create tag: v{version}",
|
||||
"zh": "[dry-run] Would create tag: v{version}"
|
||||
},
|
||||
"[dry-run] Would create tag: {tag}": {
|
||||
"en": "[dry-run] Would create tag: {tag}"
|
||||
"bg": "[dry-run] Would create tag: {tag}",
|
||||
"de": "[dry-run] Would create tag: {tag}",
|
||||
"en": "[dry-run] Would create tag: {tag}",
|
||||
"ru": "[dry-run] Would create tag: {tag}",
|
||||
"zh": "[dry-run] Would create tag: {tag}"
|
||||
},
|
||||
"[dry-run] Would push commit to master": {
|
||||
"en": "[dry-run] Would push commit to master"
|
||||
"bg": "[dry-run] Would push commit to master",
|
||||
"de": "[dry-run] Would push commit to master",
|
||||
"en": "[dry-run] Would push commit to master",
|
||||
"ru": "[dry-run] Would push commit to master",
|
||||
"zh": "[dry-run] Would push commit to master"
|
||||
},
|
||||
"[dry-run] Would sync page: {title} ({chars} chars)": {
|
||||
"en": "[dry-run] Would sync page: {title} ({chars} chars)"
|
||||
"bg": "[dry-run] Would sync page: {title} ({chars} chars)",
|
||||
"de": "[dry-run] Would sync page: {title} ({chars} chars)",
|
||||
"en": "[dry-run] Would sync page: {title} ({chars} chars)",
|
||||
"ru": "[dry-run] Would sync page: {title} ({chars} chars)",
|
||||
"zh": "[dry-run] Would sync page: {title} ({chars} chars)"
|
||||
},
|
||||
"[dry-run] Would update {changelog_file}": {
|
||||
"en": "[dry-run] Would update {changelog_file}"
|
||||
"bg": "[dry-run] Would update {changelog_file}",
|
||||
"de": "[dry-run] Would update {changelog_file}",
|
||||
"en": "[dry-run] Would update {changelog_file}",
|
||||
"ru": "[dry-run] Would update {changelog_file}",
|
||||
"zh": "[dry-run] Would update {changelog_file}"
|
||||
},
|
||||
"[dry-run] Would update {init}": {
|
||||
"en": "[dry-run] Would update {init}"
|
||||
"bg": "[dry-run] Would update {init}",
|
||||
"de": "[dry-run] Would update {init}",
|
||||
"en": "[dry-run] Would update {init}",
|
||||
"ru": "[dry-run] Would update {init}",
|
||||
"zh": "[dry-run] Would update {init}"
|
||||
},
|
||||
"active": {
|
||||
"en": "active",
|
||||
"bg": "активен",
|
||||
"de": "aktiv",
|
||||
"en": "active",
|
||||
"ru": "активен",
|
||||
"zh": "活跃"
|
||||
},
|
||||
"completed": {
|
||||
"en": "completed",
|
||||
"bg": "завършен",
|
||||
"de": "abgeschlossen",
|
||||
"en": "completed",
|
||||
"ru": "завершён",
|
||||
"zh": "已完成"
|
||||
},
|
||||
"failed": {
|
||||
"en": "failed",
|
||||
"bg": "неуспешен",
|
||||
"de": "fehlgeschlagen",
|
||||
"en": "failed",
|
||||
"ru": "неудачный",
|
||||
"zh": "失败"
|
||||
},
|
||||
"git command failed ({cmd}): {stderr}": {
|
||||
"en": "git command failed ({cmd}): {stderr}"
|
||||
"bg": "git command failed ({cmd}): {stderr}",
|
||||
"de": "git command failed ({cmd}): {stderr}",
|
||||
"en": "git command failed ({cmd}): {stderr}",
|
||||
"ru": "git command failed ({cmd}): {stderr}",
|
||||
"zh": "git command failed ({cmd}): {stderr}"
|
||||
},
|
||||
"git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.": {
|
||||
"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.",
|
||||
"en": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
|
||||
"ru": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
|
||||
"zh": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history."
|
||||
},
|
||||
"git-cliff returned empty version.": {
|
||||
"en": "git-cliff returned empty version."
|
||||
"bg": "git-cliff returned empty version.",
|
||||
"de": "git-cliff returned empty version.",
|
||||
"en": "git-cliff returned empty version.",
|
||||
"ru": "git-cliff returned empty version.",
|
||||
"zh": "git-cliff returned empty version."
|
||||
},
|
||||
"inactive": {
|
||||
"en": "inactive",
|
||||
"bg": "неактивен",
|
||||
"de": "inaktiv",
|
||||
"ru": "неактивен",
|
||||
"zh": "未激活"
|
||||
"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).",
|
||||
"en": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).",
|
||||
"ru": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).",
|
||||
"zh": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1)."
|
||||
},
|
||||
"in_progress": {
|
||||
"en": "in progress",
|
||||
"bg": "в процес",
|
||||
"de": "in Bearbeitung",
|
||||
"en": "in progress",
|
||||
"ru": "в процессе",
|
||||
"zh": "进行中"
|
||||
},
|
||||
"inactive": {
|
||||
"bg": "неактивен",
|
||||
"de": "inaktiv",
|
||||
"en": "inactive",
|
||||
"ru": "неактивен",
|
||||
"zh": "未激活"
|
||||
},
|
||||
"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}",
|
||||
"en": "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}": {
|
||||
"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}",
|
||||
"en": "mapping.json must be a dict of file-path -> page-title, got {type}",
|
||||
"ru": "mapping.json must be a dict of file-path -> page-title, got {type}",
|
||||
"zh": "mapping.json must be a dict of file-path -> page-title, got {type}"
|
||||
},
|
||||
"pending": {
|
||||
"en": "pending",
|
||||
"bg": "в очакване",
|
||||
"de": "ausstehend",
|
||||
"en": "pending",
|
||||
"ru": "ожидает",
|
||||
"zh": "待处理"
|
||||
},
|
||||
"unknown": {
|
||||
"en": "unknown",
|
||||
"bg": "неизвестен",
|
||||
"de": "unbekannt",
|
||||
"en": "unknown",
|
||||
"ru": "неизвестно",
|
||||
"zh": "未知"
|
||||
},
|
||||
"\n{label} files changed ({count}):": {
|
||||
"en": "\n{label} files changed ({count}):"
|
||||
},
|
||||
"\n{tag} files ({count}):": {
|
||||
"en": "\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}"
|
||||
},
|
||||
"Unknown check category '{check}'. Available: all, user-facing{tags}": {
|
||||
"en": "Unknown check category '{check}'. Available: all, user-facing{tags}"
|
||||
"{file} already exists. Use --force to overwrite.": {
|
||||
"bg": "{file} already exists. Use --force to overwrite.",
|
||||
"de": "{file} already exists. Use --force to overwrite.",
|
||||
"en": "{file} already exists. Use --force to overwrite.",
|
||||
"ru": "{file} already exists. Use --force to overwrite.",
|
||||
"zh": "{file} already exists. Use --force to overwrite."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,7 +566,8 @@ class TestVikunjaClient:
|
||||
json={"done": True},
|
||||
)
|
||||
|
||||
def test_http_error_raises_api_error(self) -> None:
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.side_effect = _mock_http_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
||||
|
||||
@@ -77,9 +77,10 @@ class TestValidatePrTitle:
|
||||
|
||||
class TestValidatePrTitleMatchesVikunja:
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_skips_when_no_token(self) -> None:
|
||||
# Should not raise — just warn
|
||||
validate_pr_title_matches_vikunja("DEVX-19: test", "DEVX-19")
|
||||
def test_raises_when_no_token(self) -> None:
|
||||
"""Should raise ClickException when VIKUNJA_TOKEN is not set."""
|
||||
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("devx.ci.auto_merge.VikunjaClient")
|
||||
@@ -192,9 +193,12 @@ class TestRunCmd:
|
||||
|
||||
|
||||
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")
|
||||
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)
|
||||
(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"],
|
||||
)
|
||||
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)
|
||||
def test_no_token_raises(self) -> None:
|
||||
@@ -240,9 +244,12 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
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")
|
||||
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)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
@@ -267,9 +274,12 @@ class TestMain:
|
||||
# Should have fetched, rebased, and pushed
|
||||
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")
|
||||
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)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
@@ -288,9 +298,12 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
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")
|
||||
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."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
@@ -308,8 +321,31 @@ class TestMain:
|
||||
assert "conventional commit" in result.output.lower()
|
||||
|
||||
@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")
|
||||
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."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for scripts/check_test_speed.py."""
|
||||
"""Unit tests for devx.tools.check_test_speed."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -8,10 +8,13 @@ from click.testing import CliRunner
|
||||
|
||||
from devx.tools.check_test_speed import (
|
||||
DEFAULT_MAX_SECONDS,
|
||||
DEFAULT_MAX_SINGLE_SECONDS,
|
||||
TEST_COMMAND,
|
||||
check_per_test_speed,
|
||||
check_speed,
|
||||
cli,
|
||||
parse_duration,
|
||||
parse_per_test_durations,
|
||||
run_tests,
|
||||
)
|
||||
|
||||
@@ -23,12 +26,23 @@ class TestRunTests:
|
||||
stdout, stderr = run_tests()
|
||||
assert stdout == "out"
|
||||
assert stderr == "err"
|
||||
mock_run.assert_called_once_with(
|
||||
TEST_COMMAND,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
mock_run.assert_called_once()
|
||||
call_kwargs = mock_run.call_args
|
||||
assert call_kwargs.args[0] == TEST_COMMAND
|
||||
assert call_kwargs.kwargs["capture_output"] is True
|
||||
assert call_kwargs.kwargs["text"] is True
|
||||
assert call_kwargs.kwargs["check"] is False
|
||||
env = call_kwargs.kwargs["env"]
|
||||
assert "--durations=0" in env["PYTEST_ADDOPTS"]
|
||||
|
||||
@patch("devx.tools.check_test_speed.subprocess.run")
|
||||
def test_run_tests_preserves_existing_pytest_addopts(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(stdout="out", stderr="err", returncode=0)
|
||||
with patch.dict("os.environ", {"PYTEST_ADDOPTS": "-x"}, clear=False):
|
||||
run_tests()
|
||||
env = mock_run.call_args.kwargs["env"]
|
||||
assert "--durations=0" in env["PYTEST_ADDOPTS"]
|
||||
assert "-x" in env["PYTEST_ADDOPTS"]
|
||||
|
||||
|
||||
class TestParseDuration:
|
||||
@@ -48,6 +62,38 @@ class TestParseDuration:
|
||||
assert "Could not parse" in str(exc.value)
|
||||
|
||||
|
||||
class TestParsePerTestDurations:
|
||||
def test_parses_call_lines(self) -> None:
|
||||
output = "0.01s call tests/test_foo.py::test_bar\n"
|
||||
durations = parse_per_test_durations(output)
|
||||
assert len(durations) == 1
|
||||
assert durations[0] == ("tests/test_foo.py::test_bar", 0.01)
|
||||
|
||||
def test_parses_setup_and_teardown(self) -> None:
|
||||
output = (
|
||||
"0.02s setup tests/test_foo.py::test_bar\n"
|
||||
"0.01s call tests/test_foo.py::test_bar\n"
|
||||
"0.00s teardown tests/test_foo.py::test_bar\n"
|
||||
)
|
||||
durations = parse_per_test_durations(output)
|
||||
assert len(durations) == 3
|
||||
names = [d[0] for d in durations]
|
||||
assert "tests/test_foo.py::test_bar" in names
|
||||
|
||||
def test_sorted_slowest_first(self) -> None:
|
||||
output = "0.01s call tests/test_a.py::test_slow\n0.50s call tests/test_b.py::test_fast\n"
|
||||
durations = parse_per_test_durations(output)
|
||||
assert durations[0][1] >= durations[1][1]
|
||||
assert durations[0][1] == 0.50
|
||||
|
||||
def test_empty_output(self) -> None:
|
||||
assert parse_per_test_durations("") == []
|
||||
|
||||
def test_ignores_non_duration_lines(self) -> None:
|
||||
output = "Some random line\n234 passed in 0.70s\n"
|
||||
assert parse_per_test_durations(output) == []
|
||||
|
||||
|
||||
class TestCheckSpeed:
|
||||
def test_under_budget_passes(self) -> None:
|
||||
check_speed(1.0, 2.0) # should not raise
|
||||
@@ -64,6 +110,31 @@ class TestCheckSpeed:
|
||||
assert "max allowed: 2.0s" in msg
|
||||
|
||||
|
||||
class TestCheckPerTestSpeed:
|
||||
def test_no_violations_when_all_fast(self) -> None:
|
||||
durations = [("test_a", 0.1), ("test_b", 0.2)]
|
||||
assert check_per_test_speed(durations, 0.5) == []
|
||||
|
||||
def test_violation_when_test_exceeds_limit(self) -> None:
|
||||
durations = [("test_slow", 0.6), ("test_fast", 0.1)]
|
||||
violations = check_per_test_speed(durations, 0.5)
|
||||
assert len(violations) == 1
|
||||
assert "test_slow" in violations[0]
|
||||
assert "0.60s" in violations[0]
|
||||
|
||||
def test_multiple_violations(self) -> None:
|
||||
durations = [("test_a", 0.7), ("test_b", 0.6), ("test_c", 0.1)]
|
||||
violations = check_per_test_speed(durations, 0.5)
|
||||
assert len(violations) == 2
|
||||
|
||||
def test_exact_limit_passes(self) -> None:
|
||||
durations = [("test_a", 0.5)]
|
||||
assert check_per_test_speed(durations, 0.5) == []
|
||||
|
||||
def test_empty_durations(self) -> None:
|
||||
assert check_per_test_speed([], 0.5) == []
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
import devx.tools.check_test_speed as cts
|
||||
|
||||
@@ -77,39 +148,71 @@ class TestMain:
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
@patch("devx.tools.check_test_speed.check_speed")
|
||||
@patch("devx.tools.check_test_speed.parse_per_test_durations")
|
||||
@patch("devx.tools.check_test_speed.check_per_test_speed")
|
||||
def test_successful_run(
|
||||
self,
|
||||
mock_check_per: MagicMock,
|
||||
mock_parse_per: MagicMock,
|
||||
mock_check: MagicMock,
|
||||
mock_parse: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
) -> None:
|
||||
mock_run.return_value = ("stdout\n", "stderr\n")
|
||||
mock_parse.return_value = 1.5
|
||||
mock_parse_per.return_value = []
|
||||
mock_check_per.return_value = []
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
assert "1.50s" in result.output
|
||||
assert "under 2.0s limit" in result.output
|
||||
assert "under 10.0s limit" in result.output
|
||||
mock_run.assert_called_once()
|
||||
mock_parse.assert_called_once_with("stdout\n\nstderr\n")
|
||||
mock_check.assert_called_once_with(1.5, DEFAULT_MAX_SECONDS)
|
||||
mock_parse_per.assert_called_once()
|
||||
mock_check_per.assert_called_once_with([], DEFAULT_MAX_SINGLE_SECONDS)
|
||||
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
def test_slow_tests_exit(
|
||||
def test_slow_total_exits(
|
||||
self,
|
||||
mock_parse: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
) -> None:
|
||||
mock_run.return_value = ("out\n", "err\n")
|
||||
mock_parse.return_value = 3.0
|
||||
mock_parse.return_value = 15.0
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 1
|
||||
assert "too slow" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
@patch("devx.tools.check_test_speed.check_speed")
|
||||
@patch("devx.tools.check_test_speed.parse_per_test_durations")
|
||||
@patch("devx.tools.check_test_speed.check_per_test_speed")
|
||||
def test_per_test_violation_exits(
|
||||
self,
|
||||
mock_check_per: MagicMock,
|
||||
mock_parse_per: MagicMock,
|
||||
mock_check: MagicMock,
|
||||
mock_parse: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
) -> None:
|
||||
mock_run.return_value = ("out\n", "err\n")
|
||||
mock_parse.return_value = 3.0
|
||||
mock_parse_per.return_value = [("test_slow", 0.8)]
|
||||
mock_check_per.return_value = ["Test 'test_slow' took 0.80s (limit: 0.5s)."]
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 1
|
||||
assert "Per-test speed check FAILED" in result.output
|
||||
assert "test_slow" in result.output
|
||||
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
def test_parse_failure_exits(
|
||||
self,
|
||||
@@ -125,16 +228,67 @@ class TestMain:
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
@patch("devx.tools.check_test_speed.check_speed")
|
||||
@patch("devx.tools.check_test_speed.parse_per_test_durations")
|
||||
@patch("devx.tools.check_test_speed.check_per_test_speed")
|
||||
def test_custom_max_seconds(
|
||||
self,
|
||||
mock_check_per: MagicMock,
|
||||
mock_parse_per: MagicMock,
|
||||
mock_check: MagicMock,
|
||||
mock_parse: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
) -> None:
|
||||
mock_run.return_value = ("out\n", "err\n")
|
||||
mock_parse.return_value = 0.5
|
||||
mock_parse_per.return_value = []
|
||||
mock_check_per.return_value = []
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--max-seconds", "1.5"])
|
||||
assert result.exit_code == 0
|
||||
mock_check.assert_called_once_with(0.5, 1.5)
|
||||
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
@patch("devx.tools.check_test_speed.check_speed")
|
||||
@patch("devx.tools.check_test_speed.parse_per_test_durations")
|
||||
@patch("devx.tools.check_test_speed.check_per_test_speed")
|
||||
def test_disable_per_test_check(
|
||||
self,
|
||||
mock_check_per: MagicMock,
|
||||
mock_parse_per: MagicMock,
|
||||
mock_check: MagicMock,
|
||||
mock_parse: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
) -> None:
|
||||
mock_run.return_value = ("out\n", "err\n")
|
||||
mock_parse.return_value = 1.0
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--max-single-seconds", "0"])
|
||||
assert result.exit_code == 0
|
||||
mock_parse_per.assert_not_called()
|
||||
mock_check_per.assert_not_called()
|
||||
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
@patch("devx.tools.check_test_speed.check_speed")
|
||||
@patch("devx.tools.check_test_speed.parse_per_test_durations")
|
||||
@patch("devx.tools.check_test_speed.check_per_test_speed")
|
||||
def test_custom_max_single_seconds(
|
||||
self,
|
||||
mock_check_per: MagicMock,
|
||||
mock_parse_per: MagicMock,
|
||||
mock_check: MagicMock,
|
||||
mock_parse: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
) -> None:
|
||||
mock_run.return_value = ("out\n", "err\n")
|
||||
mock_parse.return_value = 1.0
|
||||
mock_parse_per.return_value = []
|
||||
mock_check_per.return_value = []
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--max-single-seconds", "1.0"])
|
||||
assert result.exit_code == 0
|
||||
mock_check_per.assert_called_once_with([], 1.0)
|
||||
|
||||
@@ -67,7 +67,7 @@ class TestCheckTranslationSet:
|
||||
trans_file.write_text(json.dumps({"Used": {"en": "Used"}, "Dead": {"en": "Dead"}}))
|
||||
|
||||
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:
|
||||
src_dir = tmp_path / "src"
|
||||
@@ -77,7 +77,7 @@ class TestCheckTranslationSet:
|
||||
trans_file.write_text(json.dumps({"Hello": {"en": "Hello"}}))
|
||||
|
||||
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:
|
||||
src_dir = tmp_path / "src"
|
||||
@@ -108,23 +108,19 @@ class TestMain:
|
||||
result = runner.invoke(check_translations.main, [])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_strict_fails_on_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""--strict should fail if there are missing language warnings."""
|
||||
warn_result = check_translations.TranslationCheckResult(
|
||||
def test_errors_fail(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Errors should cause exit code 1."""
|
||||
error_result = check_translations.TranslationCheckResult(
|
||||
name="devx",
|
||||
src_dir=Path("/tmp"),
|
||||
trans_file=Path("/tmp/t.json"),
|
||||
used_keys={"a"},
|
||||
defined_keys={"a"},
|
||||
warnings=["Dead key: 'bar'"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
check_translations,
|
||||
"check_translation_set",
|
||||
lambda name, src, trans: warn_result,
|
||||
errors=["Dead key: 'bar'"],
|
||||
)
|
||||
monkeypatch.setattr(check_translations, "check_translation_set", lambda name, src, trans: error_result)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(check_translations.main, ["--strict"])
|
||||
result = runner.invoke(check_translations.main, [])
|
||||
assert result.exit_code == 1
|
||||
|
||||
def test_fails_on_errors(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -311,26 +307,25 @@ class TestCollectKeys:
|
||||
assert "in_progress" in keys
|
||||
|
||||
|
||||
class TestMainWarnings:
|
||||
def test_passes_with_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Should pass with exit code 0 and 'PASS with warnings' message."""
|
||||
warn_result = check_translations.TranslationCheckResult(
|
||||
class TestMainCleanPass:
|
||||
def test_passes_clean(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Should pass with exit code 0 and 'PASS:' message when no errors."""
|
||||
ok_result = check_translations.TranslationCheckResult(
|
||||
name="devx",
|
||||
src_dir=Path("/tmp"),
|
||||
trans_file=Path("/tmp/t.json"),
|
||||
used_keys={"a"},
|
||||
defined_keys={"a"},
|
||||
warnings=["Dead key: 'bar'"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
check_translations,
|
||||
"check_translation_set",
|
||||
lambda name, src, trans: warn_result,
|
||||
lambda name, src, trans: ok_result,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(check_translations.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "PASS with warnings" in result.output
|
||||
assert "PASS:" in result.output
|
||||
|
||||
|
||||
class TestI18nProjectTranslations:
|
||||
|
||||
@@ -178,6 +178,39 @@ class TestClassifierConfig:
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChangeClassifier tests
|
||||
|
||||
@@ -166,6 +166,13 @@ class TestToolsCommands:
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.tools.generate_badges", [])
|
||||
|
||||
@patch("devx.cli._run_module")
|
||||
def test_tools_generate_cliff_config(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["tools", "generate-cliff-config"])
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.tools.generate_cliff_config", [])
|
||||
|
||||
@patch("devx.cli._run_module")
|
||||
def test_tools_install_checkmake(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
@@ -218,6 +225,29 @@ class TestMoleculeCommands:
|
||||
mock_run.assert_called_once_with("devx.molecule.molecule_all", [])
|
||||
|
||||
|
||||
class TestNewCiCommands:
|
||||
@patch("devx.cli._run_module")
|
||||
def test_ci_distribute_files(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["ci", "distribute-files", "--", "--pattern", "*.py"])
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.ci.distribute_files", ["--pattern", "*.py"])
|
||||
|
||||
@patch("devx.cli._run_module")
|
||||
def test_ci_merge_junit(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["ci", "merge-junit", "--", "--output", "merged.xml"])
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.ci.merge_junit", ["--output", "merged.xml"])
|
||||
|
||||
@patch("devx.cli._run_module")
|
||||
def test_ci_integration_guard(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["ci", "integration-guard", "--", "-v"])
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.ci.integration_guard", ["-v"])
|
||||
|
||||
|
||||
class TestRunModule:
|
||||
@patch("importlib.import_module")
|
||||
def test_run_module_success(self, mock_import: MagicMock) -> None:
|
||||
|
||||
@@ -120,6 +120,35 @@ class TestQueryRunners:
|
||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
||||
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:
|
||||
@patch("devx.ci.discover_runners.query_runners", return_value=5)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Unit tests for devx.ci.distribute_files."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.distribute_files import (
|
||||
DEFAULT_MAX_RUNNERS,
|
||||
discover_files,
|
||||
distribute,
|
||||
files_for_runner,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
class TestDiscoverFiles:
|
||||
def test_discovers_sorted(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "test_b.py").write_text("")
|
||||
(tmp_path / "test_a.py").write_text("")
|
||||
result = discover_files(str(tmp_path / "test_*.py"))
|
||||
assert len(result) == 2
|
||||
assert result[0].endswith("test_a.py")
|
||||
assert result[1].endswith("test_b.py")
|
||||
|
||||
def test_no_matches(self, tmp_path: Path) -> None:
|
||||
assert discover_files(str(tmp_path / "nonexistent-*.py")) == []
|
||||
|
||||
|
||||
class TestDistribute:
|
||||
def test_even_split(self) -> None:
|
||||
files = [f"test_{i}.py" for i in range(6)]
|
||||
groups = distribute(files, 3)
|
||||
assert len(groups) == 3
|
||||
assert all(len(g) == 2 for g in groups)
|
||||
|
||||
def test_uneven_split(self) -> None:
|
||||
files = [f"test_{i}.py" for i in range(5)]
|
||||
groups = distribute(files, 3)
|
||||
assert len(groups[0]) == 2
|
||||
assert len(groups[1]) == 2
|
||||
assert len(groups[2]) == 1
|
||||
|
||||
def test_more_runners_than_files(self) -> None:
|
||||
files = ["test_a.py"]
|
||||
groups = distribute(files, 5)
|
||||
assert len(groups) == 5
|
||||
assert len(groups[0]) == 1
|
||||
assert all(len(g) == 0 for g in groups[1:])
|
||||
|
||||
def test_empty(self) -> None:
|
||||
assert distribute([], 3) == [[], [], []]
|
||||
|
||||
|
||||
class TestFilesForRunner:
|
||||
def test_returns_correct_subset(self) -> None:
|
||||
files = [f"test_{i}.py" for i in range(6)]
|
||||
assert len(files_for_runner(files, 0, 3)) == 2
|
||||
assert len(files_for_runner(files, 1, 3)) == 2
|
||||
assert len(files_for_runner(files, 2, 3)) == 2
|
||||
|
||||
def test_out_of_range_raises(self) -> None:
|
||||
with pytest.raises(Exception, match="out of range"):
|
||||
files_for_runner(["a.py"], 5, 3)
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_no_runner_index_prints_groups(self, tmp_path: Path) -> None:
|
||||
for i in range(3):
|
||||
(tmp_path / f"test_{i}.py").write_text("")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--pattern", str(tmp_path / "test_*.py"), "--max-runners", "3"])
|
||||
assert result.exit_code == 0
|
||||
assert "Runner 0:" in result.output
|
||||
assert "Runner 1:" in result.output
|
||||
assert "Runner 2:" in result.output
|
||||
|
||||
def test_runner_index_prints_assigned(self, tmp_path: Path) -> None:
|
||||
for i in range(3):
|
||||
(tmp_path / f"test_{i}.py").write_text("")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "1", "--max-runners", "3"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "test_0.py" in result.output
|
||||
|
||||
def test_github_env_writes_files(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "env.txt"
|
||||
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
|
||||
for i in range(2):
|
||||
(tmp_path / f"test_{i}.py").write_text("")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "1", "--max-runners", "2", "--github-env"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "ASSIGNED_FILES=" in content
|
||||
assert "SKIP=false" in content
|
||||
|
||||
def test_skip_if_excess(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "env.txt"
|
||||
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
|
||||
(tmp_path / "test.py").write_text("")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"--pattern",
|
||||
str(tmp_path / "test_*.py"),
|
||||
"--runner-index",
|
||||
"5",
|
||||
"--max-runners",
|
||||
"2",
|
||||
"--github-env",
|
||||
"--skip-if-excess",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "ASSIGNED_FILES=\n" in content
|
||||
assert "SKIP=true" in content
|
||||
|
||||
def test_runner_index_zero_raises(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "test.py").write_text("")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "0", "--max-runners", "3"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_no_env_var_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("GITHUB_ENV", raising=False)
|
||||
(tmp_path / "test.py").write_text("")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "1", "--max-runners", "3", "--github-env"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
def test_default_max_runners() -> None:
|
||||
assert DEFAULT_MAX_RUNNERS == 3
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
import devx.ci.distribute_files as mod
|
||||
|
||||
assert hasattr(mod, "main")
|
||||
@@ -8,13 +8,19 @@ import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.molecule.distribute_molecule import (
|
||||
DEFAULT_ROLES_ROOT,
|
||||
MOLECULE_ROOT,
|
||||
PLATFORMS,
|
||||
MultiRoleTestPair,
|
||||
TestPair,
|
||||
build_multi_role_pairs,
|
||||
build_pairs,
|
||||
cli,
|
||||
discover_multi_role_scenarios,
|
||||
discover_scenarios,
|
||||
distribute,
|
||||
distribute_multi_role,
|
||||
multi_role_pairs_for_runner,
|
||||
pairs_for_runner,
|
||||
)
|
||||
|
||||
@@ -157,10 +163,7 @@ class TestCli:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--list-platforms"])
|
||||
assert result.exit_code == 0
|
||||
assert "ubuntu-2204" in result.output
|
||||
assert "ubuntu-2404" in result.output
|
||||
assert "debian-12" in result.output
|
||||
assert "archlinux" in result.output
|
||||
assert "ubuntu-2604" in result.output
|
||||
|
||||
def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None:
|
||||
from click.testing import CliRunner
|
||||
@@ -192,7 +195,7 @@ class TestCli:
|
||||
assert result.exit_code == 0
|
||||
# Output should contain encoded pairs with platform info
|
||||
assert "alpha|" in result.output
|
||||
assert "ubuntu-2204" in result.output
|
||||
assert "ubuntu-2604" in result.output
|
||||
|
||||
|
||||
class TestGithubEnv:
|
||||
@@ -240,6 +243,16 @@ class TestGithubEnv:
|
||||
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:
|
||||
import devx.molecule.distribute_molecule as dm
|
||||
|
||||
@@ -249,3 +262,218 @@ def test_main_module_block() -> None:
|
||||
namespace = dict(dm.__dict__)
|
||||
exec(compile(source, dm.__file__, "exec"), namespace)
|
||||
assert callable(namespace["cli"])
|
||||
|
||||
|
||||
class TestDiscoverMultiRole:
|
||||
def test_discovers_role_scenario_pairs(self, tmp_path: Path) -> None:
|
||||
roles = tmp_path / "roles"
|
||||
for scenario in ["default", "binary"]:
|
||||
(roles / "gitea-runner" / "molecule" / scenario).mkdir(parents=True)
|
||||
(roles / "gitea-runner" / "molecule" / "common").mkdir(parents=True)
|
||||
(roles / "gitea-runner" / "molecule" / "_shared").mkdir(parents=True)
|
||||
(roles / "docker-base" / "molecule" / "default").mkdir(parents=True)
|
||||
(roles / "no-molecule").mkdir(parents=True)
|
||||
result = discover_multi_role_scenarios(roles)
|
||||
assert ("docker-base", "default") in result
|
||||
assert ("gitea-runner", "default") in result
|
||||
assert ("gitea-runner", "binary") in result
|
||||
assert ("gitea-runner", "common") not in result
|
||||
assert ("gitea-runner", "_shared") not in result
|
||||
assert len(result) == 3
|
||||
|
||||
def test_raises_when_dir_missing(self, tmp_path: Path) -> None:
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
discover_multi_role_scenarios(tmp_path / "nonexistent")
|
||||
assert "not found" in str(exc.value)
|
||||
|
||||
def test_default_roles_root_raises_when_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Calling with no args uses DEFAULT_ROLES_ROOT which doesn't exist in tests."""
|
||||
with pytest.raises(click.ClickException):
|
||||
discover_multi_role_scenarios()
|
||||
|
||||
def test_default_roles_root_constant(self) -> None:
|
||||
assert Path("ansible/roles") == DEFAULT_ROLES_ROOT
|
||||
|
||||
|
||||
class TestMultiRoleTestPair:
|
||||
def test_encode_roundtrip(self) -> None:
|
||||
pair = MultiRoleTestPair(
|
||||
"docker-base", "default", {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": ""}
|
||||
)
|
||||
encoded = pair.encode()
|
||||
assert encoded == "docker-base|default|ubuntu-2204|ubuntu:22.04|"
|
||||
decoded = MultiRoleTestPair.decode(encoded)
|
||||
assert decoded.role == "docker-base"
|
||||
assert decoded.scenario == "default"
|
||||
assert decoded.platform["name"] == "ubuntu-2204"
|
||||
|
||||
|
||||
class TestBuildMultiRolePairs:
|
||||
def test_cross_product(self) -> None:
|
||||
role_scenarios = [("role-a", "default"), ("role-b", "binary")]
|
||||
platforms = [{"name": "p1", "image": "i1", "command": ""}]
|
||||
pairs = build_multi_role_pairs(role_scenarios, platforms)
|
||||
assert len(pairs) == 2
|
||||
assert pairs[0].role == "role-a"
|
||||
assert pairs[1].role == "role-b"
|
||||
|
||||
def test_default_platforms(self) -> None:
|
||||
pairs = build_multi_role_pairs([("r", "s")])
|
||||
assert len(pairs) == len(PLATFORMS)
|
||||
|
||||
|
||||
class TestDistributeMultiRole:
|
||||
def test_even_split(self) -> None:
|
||||
pairs = [MultiRoleTestPair(f"r{i}", "s", {"name": "p", "image": "i", "command": ""}) for i in range(6)]
|
||||
groups = distribute_multi_role(pairs, 3)
|
||||
assert all(len(g) == 2 for g in groups)
|
||||
|
||||
def test_out_of_range_raises(self) -> None:
|
||||
pairs = [MultiRoleTestPair("r", "s", {"name": "p", "image": "i", "command": ""})]
|
||||
with pytest.raises(click.ClickException):
|
||||
multi_role_pairs_for_runner(pairs, 5, 3)
|
||||
|
||||
|
||||
class TestCliMultiRole:
|
||||
def test_roles_root_list(self, tmp_path: Path) -> None:
|
||||
roles = tmp_path / "roles"
|
||||
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
|
||||
(roles / "role-b" / "molecule" / "binary").mkdir(parents=True)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--roles-root", str(roles), "--list"])
|
||||
assert result.exit_code == 0
|
||||
assert "role-a|default" in result.output
|
||||
assert "role-b|binary" in result.output
|
||||
|
||||
def test_roles_root_runner_index(self, tmp_path: Path) -> None:
|
||||
roles = tmp_path / "roles"
|
||||
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--roles-root", str(roles), "--runner-index", "1", "--max-runners", "3"])
|
||||
assert result.exit_code == 0
|
||||
assert "role-a|default|" in result.output
|
||||
|
||||
def test_roles_root_github_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "env.txt"
|
||||
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
|
||||
roles = tmp_path / "roles"
|
||||
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--roles-root", str(roles), "--runner-index", "1", "--max-runners", "3", "--github-env"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "TEST_PAIRS=" in content
|
||||
assert "SKIP=false" in content
|
||||
|
||||
def test_roles_root_skip_if_excess(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "env.txt"
|
||||
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
|
||||
roles = tmp_path / "roles"
|
||||
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"--roles-root",
|
||||
str(roles),
|
||||
"--runner-index",
|
||||
"5",
|
||||
"--max-runners",
|
||||
"2",
|
||||
"--github-env",
|
||||
"--skip-if-excess",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "SKIP=true" in content
|
||||
|
||||
def test_molecule_root_option(self, tmp_path: Path) -> None:
|
||||
root = tmp_path / "custom-molecule"
|
||||
(root / "alpha").mkdir(parents=True)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--molecule-root", str(root), "--list"])
|
||||
assert result.exit_code == 0
|
||||
assert "alpha" in result.output
|
||||
|
||||
def test_roles_root_list_platforms(self, tmp_path: Path) -> None:
|
||||
"""--roles-root --list-platforms prints platforms."""
|
||||
roles = tmp_path / "roles"
|
||||
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--roles-root", str(roles), "--list-platforms"])
|
||||
assert result.exit_code == 0
|
||||
assert "ubuntu-2604" in result.output
|
||||
|
||||
def test_roles_root_no_runner_index_prints_groups(self, tmp_path: Path) -> None:
|
||||
"""--roles-root without --runner-index prints all groups."""
|
||||
roles = tmp_path / "roles"
|
||||
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
|
||||
(roles / "role-b" / "molecule" / "binary").mkdir(parents=True)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--roles-root", str(roles), "--max-runners", "2"])
|
||||
assert result.exit_code == 0
|
||||
assert "Runner 0:" in result.output
|
||||
assert "Runner 1:" in result.output
|
||||
|
||||
def test_platforms_file_overrides_default(self, tmp_path: Path) -> None:
|
||||
"""--platforms-file loads custom platforms from JSON."""
|
||||
import json
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.molecule.distribute_molecule import cli
|
||||
|
||||
roles = tmp_path / "roles"
|
||||
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
|
||||
platforms_file = tmp_path / "platforms.json"
|
||||
custom = [{"name": "custom-os", "image": "custom:latest", "command": "sleep infinity"}]
|
||||
platforms_file.write_text(json.dumps(custom))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli, ["--roles-root", str(roles), "--platforms-file", str(platforms_file), "--list-platforms"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "custom-os" in result.output
|
||||
assert "custom:latest" in result.output
|
||||
|
||||
def test_roles_root_skips_non_dir_role(self, tmp_path: Path) -> None:
|
||||
"""Non-directory entries in roles root are skipped."""
|
||||
roles = tmp_path / "roles"
|
||||
roles.mkdir(parents=True)
|
||||
(roles / "README.md").write_text("not a role")
|
||||
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
|
||||
result = discover_multi_role_scenarios(roles)
|
||||
assert ("role-a", "default") in result
|
||||
assert len(result) == 1
|
||||
|
||||
def test_roles_root_skips_non_dir_scenario(self, tmp_path: Path) -> None:
|
||||
"""Non-directory entries in molecule dir are skipped."""
|
||||
roles = tmp_path / "roles"
|
||||
(roles / "role-a" / "molecule").mkdir(parents=True)
|
||||
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
|
||||
(roles / "role-a" / "molecule" / "file.txt").write_text("not a scenario")
|
||||
result = discover_multi_role_scenarios(roles)
|
||||
assert ("role-a", "default") in result
|
||||
assert len(result) == 1
|
||||
|
||||
def test_roles_root_skips_role_without_molecule(self, tmp_path: Path) -> None:
|
||||
"""Roles without a molecule/ directory are skipped."""
|
||||
roles = tmp_path / "roles"
|
||||
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
|
||||
(roles / "no-molecule").mkdir(parents=True)
|
||||
result = discover_multi_role_scenarios(roles)
|
||||
assert ("role-a", "default") in result
|
||||
assert len(result) == 1
|
||||
|
||||
def test_roles_root_runner_index_zero_raises(self, tmp_path: Path) -> None:
|
||||
"""--roles-root --runner-index 0 should raise."""
|
||||
roles = tmp_path / "roles"
|
||||
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--roles-root", str(roles), "--runner-index", "0", "--max-runners", "3"])
|
||||
assert result.exit_code != 0
|
||||
assert "out of range" in result.output
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Tests for devx.tools.generate_cliff_config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.generate_cliff_config import main
|
||||
|
||||
|
||||
class TestGenerateCliffConfig:
|
||||
"""Tests for the generate_cliff_config tool."""
|
||||
|
||||
@pytest.fixture
|
||||
def runner(self) -> CliRunner:
|
||||
return CliRunner()
|
||||
|
||||
def test_generate_to_new_file(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
"""Generate cliff.toml to a new file."""
|
||||
output = tmp_path / "cliff.toml"
|
||||
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
|
||||
assert result.exit_code == 0
|
||||
assert output.exists()
|
||||
content = output.read_text()
|
||||
assert "git-cliff configuration for GRM" in content
|
||||
assert 'pattern = "^GRM-\\\\d+:\\\\s+"' in content
|
||||
|
||||
def test_generate_with_default_prefix(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
"""Generate with default prefix (DEVX_TASK_PREFIX or 'DEVX')."""
|
||||
output = tmp_path / "cliff.toml"
|
||||
with patch("devx.tools.generate_cliff_config.TASK_PREFIX", "DEVX"):
|
||||
result = runner.invoke(main, ["--output", str(output)])
|
||||
assert result.exit_code == 0
|
||||
content = output.read_text()
|
||||
assert "git-cliff configuration for DEVX" in content
|
||||
|
||||
def test_existing_file_without_force(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
"""Refuse to overwrite existing file without --force."""
|
||||
output = tmp_path / "cliff.toml"
|
||||
output.write_text("# existing")
|
||||
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
|
||||
assert result.exit_code != 0
|
||||
assert "already exists" in result.output
|
||||
assert output.read_text() == "# existing"
|
||||
|
||||
def test_existing_file_with_force(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
"""Overwrite existing file with --force."""
|
||||
output = tmp_path / "cliff.toml"
|
||||
output.write_text("# existing")
|
||||
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output), "--force"])
|
||||
assert result.exit_code == 0
|
||||
content = output.read_text()
|
||||
assert "git-cliff configuration for GRM" in content
|
||||
assert "# existing" not in content
|
||||
|
||||
def test_generated_config_is_valid_toml(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
"""Generated config must be valid TOML."""
|
||||
output = tmp_path / "cliff.toml"
|
||||
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
|
||||
assert result.exit_code == 0
|
||||
with open(output, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
assert "changelog" in data
|
||||
assert "git" in data
|
||||
assert "bump" in data
|
||||
assert data["bump"]["initial_tag"] == "0.1.0"
|
||||
assert data["bump"]["features_always_bump_minor"] is True
|
||||
|
||||
def test_generated_config_has_correct_preprocessor(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
"""Preprocessor pattern must match the given prefix."""
|
||||
output = tmp_path / "cliff.toml"
|
||||
result = runner.invoke(main, ["--prefix", "INFRA", "--output", str(output)])
|
||||
assert result.exit_code == 0
|
||||
with open(output, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
preprocessors = data["git"]["commit_preprocessors"]
|
||||
assert len(preprocessors) == 1
|
||||
pattern = preprocessors[0]["pattern"]
|
||||
assert "INFRA" in pattern
|
||||
|
||||
def test_generated_config_has_commit_parsers(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
"""Generated config must have all standard commit parsers."""
|
||||
output = tmp_path / "cliff.toml"
|
||||
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
|
||||
assert result.exit_code == 0
|
||||
with open(output, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
parsers = data["git"]["commit_parsers"]
|
||||
# Should have feat, fix, perf, refactor, doc, test, style, chore, ci, release, security, revert, catch-all
|
||||
messages = [p["message"] for p in parsers if "message" in p]
|
||||
assert "^feat" in messages
|
||||
assert "^fix" in messages
|
||||
assert "^perf" in messages
|
||||
assert "^refactor" in messages
|
||||
assert "^release:" in messages
|
||||
assert "^revert" in messages
|
||||
assert ".*" in messages # catch-all
|
||||
|
||||
def test_default_output_path(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
"""Default output path is cliff.toml in current directory."""
|
||||
output = tmp_path / "cliff.toml"
|
||||
# Change to tmp_path so default cliff.toml is created there
|
||||
import os
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
os.chdir(tmp_path)
|
||||
try:
|
||||
result = runner.invoke(main, ["--prefix", "GRM"])
|
||||
assert result.exit_code == 0
|
||||
assert output.exists()
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
def test_success_message(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
"""Success message includes file and prefix."""
|
||||
output = tmp_path / "cliff.toml"
|
||||
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
|
||||
assert result.exit_code == 0
|
||||
assert "Generated" in result.output
|
||||
assert "GRM" in result.output
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Unit tests for devx.ci.integration_guard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.integration_guard import cli
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_all_pass(self) -> None:
|
||||
with (
|
||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--", "tests/integration/test_foo.py"])
|
||||
assert result.exit_code == 0
|
||||
assert "Integration tests passed" in result.output
|
||||
|
||||
def test_failure_exits_nonzero(self) -> None:
|
||||
with (
|
||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 1
|
||||
proc.returncode = 1
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--", "tests/integration/test_foo.py"])
|
||||
assert result.exit_code == 1
|
||||
assert "failed" in result.output
|
||||
|
||||
def test_junit_output_passed_to_pytest(self) -> None:
|
||||
with (
|
||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--junit-output", "junit-results/runner-1.xml", "--", "test_foo.py"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
call_args = mock_popen.call_args[0][0]
|
||||
assert "--junitxml" in call_args
|
||||
assert "junit-results/runner-1.xml" in call_args
|
||||
|
||||
def test_pytest_args_passed_through(self) -> None:
|
||||
with (
|
||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--", "-x", "-v", "--tb=short", "test_a.py", "test_b.py"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
call_args = mock_popen.call_args[0][0]
|
||||
assert "-x" in call_args
|
||||
assert "-v" in call_args
|
||||
assert "test_a.py" in call_args
|
||||
assert "test_b.py" in call_args
|
||||
|
||||
def test_keyboard_interrupt_kills_process(self) -> None:
|
||||
with (
|
||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||
patch("time.sleep", side_effect=KeyboardInterrupt),
|
||||
patch("os.killpg") as mock_killpg,
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
):
|
||||
mock_getpgid.return_value = 123
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = None
|
||||
proc.wait.return_value = 0
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--", "test_foo.py"])
|
||||
assert result.exit_code == 1
|
||||
mock_killpg.assert_called()
|
||||
|
||||
def test_exits_when_other_runner_fails(self) -> None:
|
||||
real_sleep = time.sleep
|
||||
call_count = [0]
|
||||
|
||||
def get_jobs_side_effect(*args, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] < 2:
|
||||
return [{"name": "integration-tests (1)", "conclusion": "running"}]
|
||||
return [
|
||||
{"name": "integration-tests (0)", "conclusion": "running"},
|
||||
{"name": "integration-tests (1)", "conclusion": "failure"},
|
||||
]
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "integration-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
"GITEA_REPOSITORY": "oblachno-oss/infra",
|
||||
"PATH": os.environ.get("PATH", ""),
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("os.killpg") as mock_killpg,
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
||||
):
|
||||
mock_getpgid.return_value = 123
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = None
|
||||
proc.wait.return_value = 0
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--", "test_foo.py"])
|
||||
assert result.exit_code == 1
|
||||
mock_killpg.assert_called()
|
||||
assert "cancelled" in result.output.lower()
|
||||
|
||||
def test_process_lookup_error_suppressed(self) -> None:
|
||||
real_sleep = time.sleep
|
||||
call_count = [0]
|
||||
|
||||
def get_jobs_side_effect(*args, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] < 2:
|
||||
return [{"name": "integration-tests (1)", "conclusion": "running"}]
|
||||
return [
|
||||
{"name": "integration-tests (0)", "conclusion": "running"},
|
||||
{"name": "integration-tests (1)", "conclusion": "failure"},
|
||||
]
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "integration-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
"GITEA_REPOSITORY": "oblachno-oss/infra",
|
||||
"PATH": os.environ.get("PATH", ""),
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("os.killpg", side_effect=ProcessLookupError("no such process")),
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
||||
):
|
||||
mock_getpgid.return_value = 123
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = None
|
||||
proc.wait.return_value = 0
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--", "test_foo.py"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
def test_timeout_expired_kills_with_sigkill(self) -> None:
|
||||
real_sleep = time.sleep
|
||||
call_count = [0]
|
||||
|
||||
def get_jobs_side_effect(*args, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] < 2:
|
||||
return [{"name": "integration-tests (1)", "conclusion": "running"}]
|
||||
return [
|
||||
{"name": "integration-tests (0)", "conclusion": "running"},
|
||||
{"name": "integration-tests (1)", "conclusion": "failure"},
|
||||
]
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "integration-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
"GITEA_REPOSITORY": "oblachno-oss/infra",
|
||||
"PATH": os.environ.get("PATH", ""),
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("os.killpg") as mock_killpg,
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
||||
):
|
||||
mock_getpgid.return_value = 123
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = None
|
||||
proc.wait.side_effect = [subprocess.TimeoutExpired("cmd", 10)]
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--", "test_foo.py"])
|
||||
assert result.exit_code == 1
|
||||
# SIGKILL should have been called (second killpg call)
|
||||
assert mock_killpg.call_count >= 2
|
||||
|
||||
def test_no_env_vars_runs_without_polling(self) -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {"PATH": os.environ.get("PATH", "")}, clear=True),
|
||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--", "test_foo.py"])
|
||||
assert result.exit_code == 0
|
||||
assert "without cross-runner cancellation" in result.output
|
||||
|
||||
def test_partial_env_vars_runs_without_polling(self) -> None:
|
||||
"""Only GITEA_URL set (missing REPO_TOKEN and RUN_ID) — should skip polling."""
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{"GITEA_URL": "https://gitea.example", "PATH": os.environ.get("PATH", "")},
|
||||
clear=True,
|
||||
),
|
||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--", "test_foo.py"])
|
||||
assert result.exit_code == 0
|
||||
assert "without cross-runner cancellation" in result.output
|
||||
|
||||
def test_invalid_repository_falls_back_to_default(self) -> None:
|
||||
"""GITEA_REPOSITORY without '/' falls back to oblachno-oss/devx."""
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{"GITEA_REPOSITORY": "invalid", "PATH": os.environ.get("PATH", "")},
|
||||
clear=True,
|
||||
),
|
||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--", "test_foo.py"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
import devx.ci.integration_guard as ig
|
||||
|
||||
with open(ig.__file__) as f:
|
||||
source = f.read()
|
||||
source = source.replace('if __name__ == "__main__":\n cli()\n', "")
|
||||
namespace = dict(ig.__dict__)
|
||||
exec(compile(source, ig.__file__, "exec"), namespace)
|
||||
assert callable(namespace["cli"])
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Unit tests for devx.ci.merge_junit."""
|
||||
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.merge_junit import main, merge_files
|
||||
|
||||
|
||||
def _write_suite(path: Path, name: str, tests: int, failures: int) -> None:
|
||||
suite = ET.Element("testsuite", name=name, tests=str(tests), failures=str(failures))
|
||||
for i in range(tests):
|
||||
tc = ET.SubElement(suite, "testcase", classname="cls", name=f"test{i}", time="0.1")
|
||||
if i < failures:
|
||||
ET.SubElement(tc, "failure", message="fail")
|
||||
tree = ET.ElementTree(suite)
|
||||
tree.write(path, encoding="UTF-8", xml_declaration=True)
|
||||
|
||||
|
||||
class TestMergeFiles:
|
||||
def test_merges_multiple_suites(self, tmp_path: Path) -> None:
|
||||
_write_suite(tmp_path / "runner-1.xml", "r1", tests=3, failures=1)
|
||||
_write_suite(tmp_path / "runner-2.xml", "r2", tests=2, failures=0)
|
||||
merged, total_tests, total_failures = merge_files(str(tmp_path / "runner-*.xml"))
|
||||
assert total_tests == 5
|
||||
assert total_failures == 1
|
||||
assert merged.tag == "testsuites"
|
||||
assert len(merged) == 2
|
||||
|
||||
def test_no_files_returns_empty(self, tmp_path: Path) -> None:
|
||||
merged, total_tests, total_failures = merge_files(str(tmp_path / "nonexistent-*.xml"))
|
||||
assert total_tests == 0
|
||||
assert total_failures == 0
|
||||
assert merged.tag == "testsuites"
|
||||
assert len(merged) == 0
|
||||
|
||||
def test_handles_testsuites_wrapper_root(self, tmp_path: Path) -> None:
|
||||
wrapper = ET.Element("testsuites")
|
||||
suite = ET.SubElement(wrapper, "testsuite", name="r1", tests="4", failures="2")
|
||||
ET.SubElement(suite, "testcase", classname="c", name="t", time="0.1")
|
||||
tree = ET.ElementTree(wrapper)
|
||||
tree.write(tmp_path / "runner-1.xml", encoding="UTF-8", xml_declaration=True)
|
||||
merged, total_tests, total_failures = merge_files(str(tmp_path / "runner-*.xml"))
|
||||
assert total_tests == 4
|
||||
assert total_failures == 2
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_writes_merged_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_write_suite(tmp_path / "runner-1.xml", "r1", tests=2, failures=0)
|
||||
_write_suite(tmp_path / "runner-2.xml", "r2", tests=3, failures=0)
|
||||
out = tmp_path / "merged.xml"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--pattern", str(tmp_path / "runner-*.xml"), "--output", str(out)],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert out.exists()
|
||||
tree = ET.parse(out)
|
||||
root = tree.getroot()
|
||||
assert root.get("tests") == "5"
|
||||
assert root.get("failures") == "0"
|
||||
|
||||
def test_exits_nonzero_on_failures(self, tmp_path: Path) -> None:
|
||||
_write_suite(tmp_path / "runner-1.xml", "r1", tests=2, failures=1)
|
||||
out = tmp_path / "merged.xml"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--pattern", str(tmp_path / "runner-*.xml"), "--output", str(out)],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "failures" in result.output
|
||||
|
||||
def test_no_files_exits_zero(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--pattern", str(tmp_path / "nonexistent-*.xml"), "--output", str(tmp_path / "out.xml")],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "No JUnit" in result.output or "skipping" in result.output
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
import devx.ci.merge_junit as mod
|
||||
|
||||
assert hasattr(mod, "main")
|
||||
@@ -5,8 +5,11 @@ from __future__ import annotations
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
@@ -16,7 +19,10 @@ from devx.molecule.molecule_ci_guard import (
|
||||
build_molecule_cmd,
|
||||
cli,
|
||||
get_running_jobs,
|
||||
parse_pair,
|
||||
poll_for_other_failures,
|
||||
resolve_role_dir,
|
||||
write_junit_report,
|
||||
)
|
||||
|
||||
|
||||
@@ -168,6 +174,15 @@ class TestCli:
|
||||
assert result.exit_code == 0
|
||||
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:
|
||||
from click.testing import CliRunner
|
||||
|
||||
@@ -426,3 +441,240 @@ def test_main_module_block() -> None:
|
||||
namespace = dict(mg.__dict__)
|
||||
exec(compile(source, mg.__file__, "exec"), namespace)
|
||||
assert callable(namespace["cli"])
|
||||
|
||||
|
||||
class TestParsePair:
|
||||
def test_single_role_4_part(self) -> None:
|
||||
role, scenario, name, image, cmd = parse_pair("default|ubuntu-2204|ubuntu:22.04|")
|
||||
assert role == ""
|
||||
assert scenario == "default"
|
||||
assert name == "ubuntu-2204"
|
||||
assert image == "ubuntu:22.04"
|
||||
assert cmd == ""
|
||||
|
||||
def test_multi_role_5_part(self) -> None:
|
||||
role, scenario, name, image, cmd = parse_pair("gitea-runner|default|ubuntu-2204|ubuntu:22.04|")
|
||||
assert role == "gitea-runner"
|
||||
assert scenario == "default"
|
||||
assert name == "ubuntu-2204"
|
||||
assert image == "ubuntu:22.04"
|
||||
assert cmd == ""
|
||||
|
||||
def test_multi_role_with_command(self) -> None:
|
||||
role, scenario, name, image, cmd = parse_pair(
|
||||
"docker-base|lifecycle|archlinux|archlinux:latest|/usr/lib/systemd/systemd"
|
||||
)
|
||||
assert role == "docker-base"
|
||||
assert scenario == "lifecycle"
|
||||
assert cmd == "/usr/lib/systemd/systemd"
|
||||
|
||||
def test_invalid_pair_raises(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="Invalid pair format"):
|
||||
parse_pair("only|two|parts")
|
||||
|
||||
def test_too_many_parts_raises(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="Invalid pair format"):
|
||||
parse_pair("a|b|c|d|e|f")
|
||||
|
||||
|
||||
class TestResolveRoleDir:
|
||||
def test_multi_role_with_roles_root(self, tmp_path: Path) -> None:
|
||||
roles_root = tmp_path / "ansible" / "roles"
|
||||
roles_root.mkdir(parents=True)
|
||||
result = resolve_role_dir("gitea-runner", roles_root, tmp_path)
|
||||
assert result == roles_root / "gitea-runner"
|
||||
|
||||
def test_multi_role_default_roles_root(self, tmp_path: Path) -> None:
|
||||
result = resolve_role_dir("docker-base", None, tmp_path)
|
||||
assert result == tmp_path / "ansible" / "roles" / "docker-base"
|
||||
|
||||
def test_single_role_uses_default(self, tmp_path: Path) -> None:
|
||||
result = resolve_role_dir("", None, tmp_path)
|
||||
assert result == tmp_path / "ansible" / "roles" / "gitea-runner"
|
||||
|
||||
|
||||
class TestWriteJunitReport:
|
||||
def test_writes_report_with_passing_tests(self, tmp_path: Path) -> None:
|
||||
output = str(tmp_path / "junit-results" / "runner-1.xml")
|
||||
testcases = [
|
||||
{"role": "gitea-runner", "scenario": "default", "time": 5.2, "passed": True, "error": None},
|
||||
{"role": "docker-base", "scenario": "lifecycle", "time": 3.1, "passed": True, "error": None},
|
||||
]
|
||||
write_junit_report(output, testcases, 1)
|
||||
tree = ET.parse(output)
|
||||
root = tree.getroot()
|
||||
assert root.get("tests") == "2"
|
||||
assert root.get("failures") == "0"
|
||||
assert len(root) == 2
|
||||
|
||||
def test_writes_report_with_failures(self, tmp_path: Path) -> None:
|
||||
output = str(tmp_path / "runner-2.xml")
|
||||
testcases = [
|
||||
{"role": "", "scenario": "default", "time": 1.0, "passed": False, "error": "Exit code: 1"},
|
||||
]
|
||||
write_junit_report(output, testcases, 2)
|
||||
tree = ET.parse(output)
|
||||
root = tree.getroot()
|
||||
assert root.get("tests") == "1"
|
||||
assert root.get("failures") == "1"
|
||||
failure = root[0][0]
|
||||
assert failure.tag == "failure"
|
||||
assert failure.text == "Exit code: 1"
|
||||
|
||||
def test_creates_parent_directory(self, tmp_path: Path) -> None:
|
||||
output = str(tmp_path / "deep" / "nested" / "dir" / "runner.xml")
|
||||
write_junit_report(output, [], 0)
|
||||
assert Path(output).exists()
|
||||
|
||||
|
||||
class TestCliMultiRole:
|
||||
def test_multi_role_pair_passes(self, tmp_path: Path) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
roles_root = tmp_path / "ansible" / "roles"
|
||||
(roles_root / "gitea-runner").mkdir(parents=True)
|
||||
|
||||
with (
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--roles-root", str(roles_root), "gitea-runner|default|ubuntu-2204|ubuntu:22.04|"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "All molecule tests passed" in result.output
|
||||
|
||||
def test_junit_output_written(self, tmp_path: Path) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
roles_root = tmp_path / "ansible" / "roles"
|
||||
(roles_root / "gitea-runner").mkdir(parents=True)
|
||||
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
|
||||
|
||||
with (
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"--roles-root",
|
||||
str(roles_root),
|
||||
"--junit-output",
|
||||
junit_path,
|
||||
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert Path(junit_path).exists()
|
||||
|
||||
def test_junit_output_on_failure(self, tmp_path: Path) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
roles_root = tmp_path / "ansible" / "roles"
|
||||
(roles_root / "gitea-runner").mkdir(parents=True)
|
||||
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
|
||||
|
||||
with (
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 1
|
||||
proc.returncode = 1
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"--roles-root",
|
||||
str(roles_root),
|
||||
"--junit-output",
|
||||
junit_path,
|
||||
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert Path(junit_path).exists()
|
||||
tree = ET.parse(junit_path)
|
||||
assert tree.getroot().get("failures") == "1"
|
||||
|
||||
def test_junit_output_on_cancellation(self, tmp_path: Path) -> None:
|
||||
"""JUnit report is written when a runner is cancelled by another runner's failure."""
|
||||
from click.testing import CliRunner
|
||||
|
||||
real_sleep = time.sleep
|
||||
roles_root = tmp_path / "ansible" / "roles"
|
||||
(roles_root / "gitea-runner").mkdir(parents=True)
|
||||
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
|
||||
call_count = [0]
|
||||
|
||||
def get_jobs_side_effect(*args, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] < 2:
|
||||
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
|
||||
return [
|
||||
{"name": "molecule-tests (0)", "conclusion": "running"},
|
||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
||||
]
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "molecule-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
"GITEA_REPOSITORY": "oblachno-oss/infra",
|
||||
"PATH": os.environ.get("PATH", ""),
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("os.killpg"),
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
||||
):
|
||||
mock_getpgid.return_value = 123
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = None
|
||||
proc.wait.return_value = 0
|
||||
mock_popen.return_value = proc
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"--roles-root",
|
||||
str(roles_root),
|
||||
"--junit-output",
|
||||
junit_path,
|
||||
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert Path(junit_path).exists()
|
||||
tree = ET.parse(junit_path)
|
||||
root = tree.getroot()
|
||||
assert root.get("failures") == "1"
|
||||
# The failure message should mention cancellation
|
||||
failure = root[0][0]
|
||||
assert "Cancelled" in (failure.text or "")
|
||||
|
||||
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.notify_failure import main
|
||||
from devx.ci.notify_failure import _configure_tea_login, main
|
||||
from devx.gitea_cli import TeaCLIError
|
||||
|
||||
|
||||
@@ -114,3 +114,105 @@ class TestNotifyFailure:
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "REPO_TOKEN" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.shutil.which", return_value=None)
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_auto_login_no_tea_skips(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
|
||||
"""--auto-login with tea not installed skips login and still creates issue."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = []
|
||||
mock_tea.create_issue.return_value = {"index": 60, "title": "test"}
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "issue #60" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
||||
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_auto_login_no_token_skips_login(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
|
||||
"""--auto-login with no REPO_TOKEN skips login but raises before creating issue."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "REPO_TOKEN" in result.output
|
||||
|
||||
|
||||
class TestConfigureTeaLogin:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
||||
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
def test_no_token_skips(self, mock_which: MagicMock) -> None:
|
||||
"""_configure_tea_login with no token prints skip message and returns."""
|
||||
_configure_tea_login()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.shutil.which", return_value=None)
|
||||
def test_no_tea_skips(self, mock_which: MagicMock) -> None:
|
||||
"""_configure_tea_login with no tea binary prints skip message and returns."""
|
||||
_configure_tea_login()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("devx.ci.notify_failure.subprocess.run")
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_auto_login_configures_tea(
|
||||
self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock
|
||||
) -> None:
|
||||
"""--auto-login calls tea login add and default."""
|
||||
mock_run = MagicMock()
|
||||
mock_run.returncode = 0
|
||||
mock_run.stdout = ""
|
||||
mock_subprocess.return_value = mock_run
|
||||
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = []
|
||||
mock_tea.create_issue.return_value = {"index": 61, "title": "test"}
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "issue #61" in result.output
|
||||
# tea login add was called
|
||||
assert mock_subprocess.call_count >= 2
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("devx.ci.notify_failure.subprocess.run")
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_auto_login_skips_if_already_configured(
|
||||
self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock
|
||||
) -> None:
|
||||
"""--auto-login skips tea login add if login already exists."""
|
||||
mock_list = MagicMock()
|
||||
mock_list.returncode = 0
|
||||
mock_list.stdout = "devx https://git.example.com"
|
||||
mock_subprocess.return_value = mock_list
|
||||
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = []
|
||||
mock_tea.create_issue.return_value = {"index": 62, "title": "test"}
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "already configured" in result.output
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Unit tests for devx.opentofu."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from subprocess import CompletedProcess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from devx.opentofu import get_tofu_output, get_tofu_vm_field, get_tofu_vm_ip
|
||||
|
||||
|
||||
class TestGetTofuOutput:
|
||||
@patch("devx.opentofu.subprocess.run")
|
||||
def test_returns_parsed_json(self, mock_run: MagicMock) -> None:
|
||||
payload = {"staging": {"ipv4": "1.2.3.4"}}
|
||||
mock_run.return_value = CompletedProcess(
|
||||
args=["tofu", "output", "-json", "customer_vms"],
|
||||
returncode=0,
|
||||
stdout=json.dumps(payload),
|
||||
stderr="",
|
||||
)
|
||||
result = get_tofu_output("customer_vms", cwd="/tmp/tofu/staging")
|
||||
assert result == payload
|
||||
mock_run.assert_called_once()
|
||||
call_kwargs = mock_run.call_args
|
||||
assert call_kwargs.args[0] == ["tofu", "output", "-json", "customer_vms"]
|
||||
assert call_kwargs.kwargs["cwd"] == "/tmp/tofu/staging"
|
||||
assert call_kwargs.kwargs["env"] is None
|
||||
|
||||
@patch("devx.opentofu.subprocess.run")
|
||||
def test_with_env(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = CompletedProcess(
|
||||
args=["tofu", "output", "-json", "obs"],
|
||||
returncode=0,
|
||||
stdout='{"staging": {"ipv4": "5.6.7.8"}}',
|
||||
stderr="",
|
||||
)
|
||||
env = {"HCLOUD_TOKEN": "secret"}
|
||||
result = get_tofu_output("obs", cwd=Path("/tmp"), env=env)
|
||||
assert result == {"staging": {"ipv4": "5.6.7.8"}}
|
||||
assert mock_run.call_args.kwargs["env"] == env
|
||||
|
||||
@patch("devx.opentofu.subprocess.run")
|
||||
def test_no_cwd(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = CompletedProcess(
|
||||
args=["tofu", "output", "-json", "x"],
|
||||
returncode=0,
|
||||
stdout='{"a": 1}',
|
||||
stderr="",
|
||||
)
|
||||
result = get_tofu_output("x")
|
||||
assert result == {"a": 1}
|
||||
assert mock_run.call_args.kwargs["cwd"] is None
|
||||
|
||||
@patch("devx.opentofu.subprocess.run")
|
||||
def test_pathlib_cwd(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = CompletedProcess(
|
||||
args=["tofu", "output", "-json", "x"],
|
||||
returncode=0,
|
||||
stdout="{}",
|
||||
stderr="",
|
||||
)
|
||||
get_tofu_output("x", cwd=Path("/some/path"))
|
||||
assert mock_run.call_args.kwargs["cwd"] == "/some/path"
|
||||
|
||||
@patch("devx.opentofu.subprocess.run")
|
||||
def test_failure_raises_runtime_error(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = CompletedProcess(
|
||||
args=["tofu", "output", "-json", "x"],
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr="Error: module not found",
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="tofu output failed"):
|
||||
get_tofu_output("x", cwd="/tmp")
|
||||
|
||||
@patch("devx.opentofu.subprocess.run")
|
||||
def test_invalid_json_raises(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = CompletedProcess(
|
||||
args=["tofu", "output", "-json", "x"],
|
||||
returncode=0,
|
||||
stdout="not json",
|
||||
stderr="",
|
||||
)
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
get_tofu_output("x")
|
||||
|
||||
|
||||
class TestGetTofuVmIp:
|
||||
@patch("devx.opentofu.subprocess.run")
|
||||
def test_returns_ipv4(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = CompletedProcess(
|
||||
args=["tofu", "output", "-json", "customer_vms"],
|
||||
returncode=0,
|
||||
stdout=json.dumps({"oblachno": {"ipv4": "10.0.0.1"}}),
|
||||
stderr="",
|
||||
)
|
||||
ip = get_tofu_vm_ip("customer_vms", "oblachno", cwd="/tmp")
|
||||
assert ip == "10.0.0.1"
|
||||
|
||||
@patch("devx.opentofu.subprocess.run")
|
||||
def test_missing_vm_returns_empty(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = CompletedProcess(
|
||||
args=["tofu", "output", "-json", "customer_vms"],
|
||||
returncode=0,
|
||||
stdout=json.dumps({"other": {"ipv4": "10.0.0.2"}}),
|
||||
stderr="",
|
||||
)
|
||||
ip = get_tofu_vm_ip("customer_vms", "missing", cwd="/tmp")
|
||||
assert ip == ""
|
||||
|
||||
@patch("devx.opentofu.subprocess.run")
|
||||
def test_missing_ip_field_returns_empty(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = CompletedProcess(
|
||||
args=["tofu", "output", "-json", "customer_vms"],
|
||||
returncode=0,
|
||||
stdout=json.dumps({"vm1": {"name": "test"}}),
|
||||
stderr="",
|
||||
)
|
||||
ip = get_tofu_vm_ip("customer_vms", "vm1", cwd="/tmp")
|
||||
assert ip == ""
|
||||
|
||||
@patch("devx.opentofu.subprocess.run")
|
||||
def test_custom_ip_field(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = CompletedProcess(
|
||||
args=["tofu", "output", "-json", "vms"],
|
||||
returncode=0,
|
||||
stdout=json.dumps({"vm1": {"address": "192.168.1.1"}}),
|
||||
stderr="",
|
||||
)
|
||||
ip = get_tofu_vm_ip("vms", "vm1", cwd="/tmp", ip_field="address")
|
||||
assert ip == "192.168.1.1"
|
||||
|
||||
@patch("devx.opentofu.subprocess.run")
|
||||
def test_non_dict_output_returns_empty(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = CompletedProcess(
|
||||
args=["tofu", "output", "-json", "vms"],
|
||||
returncode=0,
|
||||
stdout='["not", "a", "dict"]',
|
||||
stderr="",
|
||||
)
|
||||
ip = get_tofu_vm_ip("vms", "vm1", cwd="/tmp")
|
||||
assert ip == ""
|
||||
|
||||
|
||||
class TestGetTofuVmField:
|
||||
@patch("devx.opentofu.subprocess.run")
|
||||
def test_returns_field_value(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = CompletedProcess(
|
||||
args=["tofu", "output", "-json", "obs"],
|
||||
returncode=0,
|
||||
stdout=json.dumps({"staging": {"volume_linux_device": "/dev/sda1"}}),
|
||||
stderr="",
|
||||
)
|
||||
val = get_tofu_vm_field("obs", "staging", "volume_linux_device", cwd="/tmp")
|
||||
assert val == "/dev/sda1"
|
||||
|
||||
@patch("devx.opentofu.subprocess.run")
|
||||
def test_missing_field_returns_empty(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = CompletedProcess(
|
||||
args=["tofu", "output", "-json", "obs"],
|
||||
returncode=0,
|
||||
stdout=json.dumps({"staging": {"ipv4": "1.2.3.4"}}),
|
||||
stderr="",
|
||||
)
|
||||
val = get_tofu_vm_field("obs", "staging", "volume_linux_device", cwd="/tmp")
|
||||
assert val == ""
|
||||
|
||||
@patch("devx.opentofu.subprocess.run")
|
||||
def test_missing_vm_returns_empty(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = CompletedProcess(
|
||||
args=["tofu", "output", "-json", "obs"],
|
||||
returncode=0,
|
||||
stdout=json.dumps({"prod": {"x": "y"}}),
|
||||
stderr="",
|
||||
)
|
||||
val = get_tofu_vm_field("obs", "staging", "x", cwd="/tmp")
|
||||
assert val == ""
|
||||
|
||||
@patch("devx.opentofu.subprocess.run")
|
||||
def test_non_dict_output_returns_empty(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = CompletedProcess(
|
||||
args=["tofu", "output", "-json", "obs"],
|
||||
returncode=0,
|
||||
stdout='"a string"',
|
||||
stderr="",
|
||||
)
|
||||
val = get_tofu_vm_field("obs", "staging", "x", cwd="/tmp")
|
||||
assert val == ""
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Unit tests for scripts/ci/platforms.py."""
|
||||
"""Unit tests for devx.molecule.platforms."""
|
||||
|
||||
from devx.molecule.platforms import PLATFORMS
|
||||
import json
|
||||
|
||||
from devx.molecule.platforms import PLATFORMS, load_platforms
|
||||
|
||||
|
||||
class TestPlatforms:
|
||||
def test_platforms_not_empty(self) -> None:
|
||||
assert len(PLATFORMS) >= 4
|
||||
assert len(PLATFORMS) >= 1
|
||||
|
||||
def test_each_platform_has_required_keys(self) -> None:
|
||||
for p in PLATFORMS:
|
||||
@@ -17,9 +19,40 @@ class TestPlatforms:
|
||||
names = [p["name"] for p in PLATFORMS]
|
||||
assert len(names) == len(set(names))
|
||||
|
||||
def test_platforms_use_sleep_infinity(self) -> None:
|
||||
"""All default platforms must use sleep infinity, not systemd."""
|
||||
for p in PLATFORMS:
|
||||
assert p["command"] == "sleep infinity", f"Platform {p['name']} uses {p['command']}"
|
||||
|
||||
def test_known_platforms_present(self) -> None:
|
||||
names = {p["name"] for p in PLATFORMS}
|
||||
assert "ubuntu-2204" in names
|
||||
assert "ubuntu-2404" in names
|
||||
assert "debian-12" in names
|
||||
assert "archlinux" in names
|
||||
assert "ubuntu-2604" in names
|
||||
|
||||
|
||||
class TestLoadPlatforms:
|
||||
def test_load_platforms_default(self, tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
"""load_platforms with no file returns PLATFORMS."""
|
||||
result = load_platforms(None)
|
||||
assert result == PLATFORMS
|
||||
|
||||
def test_load_platforms_from_file(self, tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
"""load_platforms reads custom platforms from JSON file."""
|
||||
custom = [
|
||||
{"name": "custom-os", "image": "custom:latest", "command": "sleep infinity"},
|
||||
]
|
||||
f = tmp_path / "platforms.json"
|
||||
f.write_text(json.dumps(custom))
|
||||
result = load_platforms(f)
|
||||
assert result == custom
|
||||
|
||||
def test_load_platforms_missing_file_falls_back(self, tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
"""load_platforms falls back to PLATFORMS when file doesn't exist."""
|
||||
result = load_platforms(tmp_path / "nonexistent.json")
|
||||
assert result == PLATFORMS
|
||||
|
||||
def test_load_platforms_empty_list_falls_back(self, tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
"""load_platforms falls back to PLATFORMS when file has empty list."""
|
||||
f = tmp_path / "platforms.json"
|
||||
f.write_text("[]")
|
||||
result = load_platforms(f)
|
||||
assert result == PLATFORMS
|
||||
|
||||
@@ -132,13 +132,12 @@ class TestMain:
|
||||
assert "VIKUNJA_TOKEN" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_no_task_id_non_release_warns(self) -> None:
|
||||
"""Non-release commits without DEVX-N prefix should warn, not fail."""
|
||||
def test_no_task_id_non_release_fails(self) -> None:
|
||||
"""Non-release commits without DEVX-N prefix should fail."""
|
||||
runner = CliRunner()
|
||||
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 "Skipping" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_release_commit_without_task_id_skips(self) -> None:
|
||||
@@ -179,8 +178,8 @@ class TestMain:
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("devx.ci.post_merge.VikunjaClient")
|
||||
def test_post_comment_failure_warns(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Vikunja API errors should warn, not fail — the merge already succeeded."""
|
||||
def test_post_comment_failure_fails(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Vikunja API errors should fail — the task was not updated."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "DEVX-20"},
|
||||
@@ -189,14 +188,13 @@ class TestMain:
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-20: fix: bug"])
|
||||
assert result.exit_code == 0
|
||||
assert "Warning" in result.output
|
||||
assert "not updated" in result.output.lower()
|
||||
assert result.exit_code != 0
|
||||
assert "Vikunja API error" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("devx.ci.post_merge.VikunjaClient")
|
||||
def test_mark_done_failure_warns(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Vikunja API errors should warn, not fail — the merge already succeeded."""
|
||||
def test_mark_done_failure_fails(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Vikunja API errors should fail — the task was not updated."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "DEVX-20"},
|
||||
@@ -206,9 +204,8 @@ class TestMain:
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-20: fix: bug"])
|
||||
assert result.exit_code == 0
|
||||
assert "Warning" in result.output
|
||||
assert "not updated" in result.output.lower()
|
||||
assert result.exit_code != 0
|
||||
assert "Vikunja API error" in result.output
|
||||
|
||||
|
||||
class TestGetGitCommitMessage:
|
||||
|
||||
@@ -300,3 +300,20 @@ class TestMain:
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 1
|
||||
assert "Release creation failed" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("devx.ci.publish.TeaCLI")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
def test_skip_build_skips_build_and_publish(
|
||||
self, mock_build: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||
) -> None:
|
||||
"""--skip-build skips build_package and PyPI publish, only creates Gitea release."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
|
||||
assert result.exit_code == 0
|
||||
assert "skip" in result.output.lower()
|
||||
mock_build.assert_not_called()
|
||||
mock_tea.create_release.assert_called_once()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -223,3 +224,66 @@ class TestMain:
|
||||
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
|
||||
assert result.exit_code == 0
|
||||
mock_update.assert_not_called()
|
||||
|
||||
def test_retries_success_on_second_attempt(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""With --retries 3, first attempt fails but second succeeds."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
badges_dir = tmp_path / ".badges"
|
||||
badges_dir.mkdir()
|
||||
(badges_dir / "badge1.svg").touch()
|
||||
|
||||
import subprocess
|
||||
|
||||
call_count = [0]
|
||||
|
||||
def side_effect(*args: Any, **kwargs: Any) -> Any:
|
||||
call_count[0] += 1
|
||||
# First call (git fetch) fails, rest succeed
|
||||
if call_count[0] == 1:
|
||||
raise subprocess.CalledProcessError(1, "git fetch")
|
||||
return MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("subprocess.run", side_effect=side_effect),
|
||||
patch("devx.ci.push_badges.update_readme_with_badge_sha"),
|
||||
patch("time.sleep"),
|
||||
):
|
||||
result = runner.invoke(
|
||||
push_badges.main,
|
||||
["--output-dir", str(badges_dir), "--no-readme-update", "--retries", "3"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_retries_exhausted(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""With --retries 2, all attempts fail and exit code is non-zero."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
import subprocess
|
||||
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")),
|
||||
patch("time.sleep"),
|
||||
):
|
||||
result = runner.invoke(
|
||||
push_badges.main,
|
||||
["--no-readme-update", "--retries", "2"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "failed after 2" in result.output
|
||||
|
||||
def test_default_retries_is_one(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Without --retries, only one attempt is made (no retry on failure)."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
import subprocess
|
||||
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")),
|
||||
patch("time.sleep") as mock_sleep,
|
||||
):
|
||||
result = runner.invoke(push_badges.main, ["--no-readme-update"])
|
||||
assert result.exit_code != 0
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
+739
-17
@@ -9,9 +9,16 @@ from click.testing import CliRunner
|
||||
from devx.ci.release import (
|
||||
commit_release_changes,
|
||||
create_and_push_tag,
|
||||
fetch_tags,
|
||||
get_all_tags,
|
||||
get_bumped_version,
|
||||
get_changelog,
|
||||
get_changelog_versions,
|
||||
get_commit_version,
|
||||
get_head_commit,
|
||||
get_init_version,
|
||||
get_latest_tag,
|
||||
get_tag_commit,
|
||||
has_unreleased_changes,
|
||||
main,
|
||||
run_cmd,
|
||||
@@ -19,6 +26,8 @@ from devx.ci.release import (
|
||||
tag_exists,
|
||||
update_changelog,
|
||||
update_init_version,
|
||||
verify_alignment,
|
||||
verify_tag_consistency,
|
||||
)
|
||||
|
||||
|
||||
@@ -44,14 +53,14 @@ class TestRunCmd:
|
||||
|
||||
|
||||
class TestGetLatestTag:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_returns_tag(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.1.0\n")
|
||||
@patch("devx.ci._shared.subprocess.run")
|
||||
def test_returns_tag(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="v0.1.0\n")
|
||||
assert get_latest_tag() == "v0.1.0"
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_no_tags_returns_empty(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="")
|
||||
@patch("devx.ci._shared.subprocess.run")
|
||||
def test_no_tags_returns_empty(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="")
|
||||
assert get_latest_tag() == ""
|
||||
|
||||
|
||||
@@ -84,6 +93,13 @@ class TestGetBumpedVersion:
|
||||
with pytest.raises(click.ClickException):
|
||||
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:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@@ -149,6 +165,534 @@ class TestUpdateInitVersion:
|
||||
update_init_version("0.2.0")
|
||||
|
||||
|
||||
class TestGetTagCommit:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_returns_commit(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="abc123\n", stderr="")
|
||||
assert get_tag_commit("v0.1.0") == "abc123"
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_returns_empty_on_failure(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="err")
|
||||
assert get_tag_commit("v0.1.0") == ""
|
||||
|
||||
|
||||
class TestGetHeadCommit:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_returns_head(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="def456\n", stderr="")
|
||||
assert get_head_commit() == "def456"
|
||||
|
||||
|
||||
class TestFetchTags:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_success(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
fetch_tags()
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_failure_warns(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="err")
|
||||
# Should not raise
|
||||
fetch_tags()
|
||||
|
||||
|
||||
class TestGetAllTags:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_returns_tags(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.3.0\nv0.2.0\nv0.1.0\n", stderr="")
|
||||
tags = get_all_tags()
|
||||
assert tags == ["v0.3.0", "v0.2.0", "v0.1.0"]
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_empty(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="\n", stderr="")
|
||||
assert get_all_tags() == []
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_failure_returns_empty(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="err")
|
||||
assert get_all_tags() == []
|
||||
|
||||
|
||||
class TestGetCommitVersion:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_release_commit(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="release: v0.4.4 [skip ci]\n", stderr="")
|
||||
assert get_commit_version("abc123") == "0.4.4"
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_non_release_commit(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="DEVX-9 feat: add thing\n", stderr="")
|
||||
assert get_commit_version("abc123") is None
|
||||
|
||||
|
||||
class TestVerifyTagConsistency:
|
||||
@patch("devx.ci.release.get_commit_version")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
def test_all_consistent(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None:
|
||||
mock_tags.return_value = ["v0.2.0", "v0.1.0"]
|
||||
mock_cv.side_effect = ["0.2.0", "0.1.0"]
|
||||
errors = verify_tag_consistency()
|
||||
assert errors == []
|
||||
|
||||
@patch("devx.ci.release.get_commit_version")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
def test_tag_on_non_release_commit(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None:
|
||||
# v0.1.0 is first (exempt), v0.2.0 is non-release (should error)
|
||||
mock_tags.return_value = ["v0.2.0", "v0.1.0"]
|
||||
mock_cv.side_effect = [None, "0.1.0"] # v0.2.0 non-release, v0.1.0 ok
|
||||
errors = verify_tag_consistency()
|
||||
assert len(errors) == 1
|
||||
assert "non-release commit" in errors[0]
|
||||
|
||||
@patch("devx.ci.release.get_commit_version")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
def test_first_tag_exempt_from_release_check(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None:
|
||||
"""The first (oldest) tag is allowed to point to a non-release commit."""
|
||||
mock_tags.return_value = ["v0.1.0"]
|
||||
mock_cv.return_value = None # non-release commit
|
||||
errors = verify_tag_consistency()
|
||||
assert errors == [] # no error — first tag is exempt
|
||||
|
||||
@patch("devx.ci.release.get_commit_version")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
def test_tag_version_mismatch(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None:
|
||||
mock_tags.return_value = ["v0.2.0"]
|
||||
mock_cv.return_value = "0.1.0"
|
||||
errors = verify_tag_consistency()
|
||||
assert len(errors) == 1
|
||||
assert "0.1.0" in errors[0]
|
||||
assert "0.2.0" in errors[0]
|
||||
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
def test_no_tags(self, mock_tags: MagicMock) -> None:
|
||||
mock_tags.return_value = []
|
||||
assert verify_tag_consistency() == []
|
||||
|
||||
|
||||
class TestGetInitVersion:
|
||||
def test_returns_version(self, tmp_path, monkeypatch) -> None:
|
||||
init_file = tmp_path / "__init__.py"
|
||||
init_file.write_text('__version__ = "0.4.4"\n')
|
||||
monkeypatch.setattr("devx.ci.release.INIT_FILE", str(init_file))
|
||||
assert get_init_version() == "0.4.4"
|
||||
|
||||
def test_file_not_found(self, monkeypatch) -> None:
|
||||
monkeypatch.setattr("devx.ci.release.INIT_FILE", "/nonexistent/path/__init__.py")
|
||||
assert get_init_version() is None
|
||||
|
||||
def test_no_version_string(self, tmp_path, monkeypatch) -> None:
|
||||
init_file = tmp_path / "__init__.py"
|
||||
init_file.write_text('"""module"""\n')
|
||||
monkeypatch.setattr("devx.ci.release.INIT_FILE", str(init_file))
|
||||
assert get_init_version() is None
|
||||
|
||||
|
||||
class TestGetChangelogVersions:
|
||||
def test_returns_versions(self, tmp_path, monkeypatch) -> None:
|
||||
changelog = tmp_path / "CHANGELOG.md"
|
||||
changelog.write_text(
|
||||
"# Changelog\n\n## [0.4.4] - 2026-06-21\n\n### Features\n- new\n\n"
|
||||
"## [0.4.3] - 2026-06-20\n\n### Fixes\n- fix\n\n## [0.4.2] - 2026-06-19\n"
|
||||
)
|
||||
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", str(changelog))
|
||||
versions = get_changelog_versions()
|
||||
assert versions == ["0.4.4", "0.4.3", "0.4.2"]
|
||||
|
||||
def test_file_not_found(self, monkeypatch) -> None:
|
||||
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", "/nonexistent/CHANGELOG.md")
|
||||
assert get_changelog_versions() == []
|
||||
|
||||
|
||||
class TestVerifyAlignment:
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_all_aligned(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify alignment passes when everything is consistent."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4", "v0.4.3"]
|
||||
mock_vtc.return_value = [] # no tag errors
|
||||
mock_iv.return_value = "0.4.4"
|
||||
mock_cv.return_value = ["0.4.4", "0.4.3"]
|
||||
# run_cmd is called for untagged release commits check
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
assert verify_alignment() == 0
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_misaligned_tags(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify alignment fails when tags are misaligned."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = [" v0.1.0 → bad"]
|
||||
mock_iv.return_value = "0.4.4"
|
||||
mock_cv.return_value = ["0.4.4"]
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
assert verify_alignment() == 1
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_version_mismatch(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify alignment fails when __version__ != latest tag."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.4.3" # mismatch
|
||||
mock_cv.return_value = ["0.4.4"]
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
assert verify_alignment() == 1
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_changelog_duplicates(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify alignment fails when CHANGELOG has duplicate versions."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.4.4"
|
||||
mock_cv.return_value = ["0.4.4", "0.4.4"] # duplicate
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
assert verify_alignment() == 1
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_changelog_out_of_order(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify alignment fails when CHANGELOG versions are not descending."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.4.4"
|
||||
mock_cv.return_value = ["0.4.3", "0.4.4"] # out of order
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
assert verify_alignment() == 1
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_changelog_latest_mismatch(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify alignment fails when CHANGELOG latest != latest tag."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.4.4"
|
||||
mock_cv.return_value = ["0.4.3"] # doesn't match tag
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
assert verify_alignment() == 1
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_changelog_unreleased_section(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify passes when CHANGELOG has one unreleased section ahead of tag."""
|
||||
mock_lt.return_value = "v0.6.3"
|
||||
mock_tags.return_value = ["v0.6.3", "v0.6.2"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.6.3"
|
||||
mock_cv.return_value = ["0.6.4", "0.6.3"] # 0.6.4 is unreleased
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
assert verify_alignment() == 0
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_changelog_tag_at_wrong_position(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify fails when latest tag is deep in CHANGELOG (not at position 0 or 1)."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.4.4"
|
||||
mock_cv.return_value = ["0.5.0", "0.4.5", "0.4.4"] # tag at position 2
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
assert verify_alignment() == 1
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_duplicate_release_commits_info(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify reports duplicate release commits as info, not error."""
|
||||
mock_lt.return_value = "v0.6.1"
|
||||
mock_tags.return_value = ["v0.6.1"] # tag for 0.6.1 exists
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.6.1"
|
||||
mock_cv.return_value = ["0.6.1"]
|
||||
# git log finds 2 release commits for v0.6.1, neither has tag pointing at it
|
||||
# (the tag points to a third commit)
|
||||
commits = "abc123 release: v0.6.1 [skip ci]\ndef456 release: v0.6.1 [skip ci]\n"
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout=commits, stderr=""),
|
||||
MagicMock(returncode=0, stdout="", stderr=""), # no tag at abc123
|
||||
MagicMock(returncode=0, stdout="", stderr=""), # no tag at def456
|
||||
]
|
||||
# Should return 0 — duplicates are informational, not errors
|
||||
assert verify_alignment() == 0
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_many_duplicate_release_commits(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify handles >5 duplicate release commits (truncation message)."""
|
||||
mock_lt.return_value = "v0.6.1"
|
||||
mock_tags.return_value = ["v0.6.1"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.6.1"
|
||||
mock_cv.return_value = ["0.6.1"]
|
||||
# Generate 7 duplicate release commits for v0.6.1
|
||||
commits = "\n".join(f"abc{i:03d} release: v0.6.1 [skip ci]" for i in range(7))
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout=commits + "\n", stderr=""),
|
||||
] + [MagicMock(returncode=0, stdout="", stderr="") for _ in range(7)]
|
||||
assert verify_alignment() == 0
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_untagged_release_commits(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify alignment fails when there are untagged release commits."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.4.4"
|
||||
mock_cv.return_value = ["0.4.4"]
|
||||
# git log finds release commits, then tag --points-at finds nothing
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout="abc123 release: v0.3.0 [skip ci]\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="", stderr=""), # no tags at abc123
|
||||
]
|
||||
assert verify_alignment() == 1
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_no_init_version(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify alignment fails when __version__ is not found."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = None # not found
|
||||
mock_cv.return_value = ["0.4.4"]
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
assert verify_alignment() == 1
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_all_release_commits_tagged(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify passes when all release commits have tags."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.4.4"
|
||||
mock_cv.return_value = ["0.4.4"]
|
||||
# git log finds release commit, tag --points-at finds the tag
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout="abc123 release: v0.4.4 [skip ci]\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="v0.4.4\n", stderr=""), # tag found
|
||||
]
|
||||
assert verify_alignment() == 0
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_no_release_commits_found(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify handles case with no release commits at all."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.4.4"
|
||||
mock_cv.return_value = ["0.4.4"]
|
||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="")
|
||||
assert verify_alignment() == 0
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@patch("devx.ci.release.get_changelog_versions")
|
||||
@patch("devx.ci.release.get_init_version")
|
||||
@patch("devx.ci.release.verify_tag_consistency")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
@patch("devx.ci.release.get_latest_tag")
|
||||
def test_many_untagged_release_commits(
|
||||
self,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
mock_run_cmd: MagicMock,
|
||||
) -> None:
|
||||
"""Verify handles >10 untagged release commits (truncation message)."""
|
||||
mock_lt.return_value = "v0.4.4"
|
||||
mock_tags.return_value = ["v0.4.4"]
|
||||
mock_vtc.return_value = []
|
||||
mock_iv.return_value = "0.4.4"
|
||||
mock_cv.return_value = ["0.4.4"]
|
||||
# Generate 15 untagged release commits
|
||||
commits = "\n".join(f"abc{i:03d} release: v0.1.{i} [skip ci]" for i in range(15))
|
||||
# First call returns all commits, subsequent calls return empty (no tags)
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout=commits + "\n", stderr=""),
|
||||
] + [MagicMock(returncode=0, stdout="", stderr="") for _ in range(15)]
|
||||
assert verify_alignment() == 1
|
||||
|
||||
|
||||
class TestUpdateChangelog:
|
||||
def test_creates_new_file(self, tmp_path, monkeypatch) -> None:
|
||||
changelog_file = tmp_path / "CHANGELOG.md"
|
||||
@@ -243,9 +787,17 @@ class TestCreateAndPushTag:
|
||||
assert call.args[0][0:2] != ["git", "push"]
|
||||
assert call.args[0][0:2] != ["git", "tag"]
|
||||
|
||||
@patch("devx.ci.release.get_head_commit", return_value="abc123")
|
||||
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
|
||||
@patch("devx.ci.release.tag_exists", return_value=True)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_tag_exists_skips_creation(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
||||
def test_tag_exists_skips_creation(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_tag_exists: MagicMock,
|
||||
mock_tag_commit: MagicMock,
|
||||
mock_head_commit: MagicMock,
|
||||
) -> None:
|
||||
result = create_and_push_tag("0.1.0", "changelog", dry_run=False)
|
||||
assert result is False
|
||||
# Should not create tag, but should ensure it's pushed
|
||||
@@ -253,13 +805,38 @@ class TestCreateAndPushTag:
|
||||
assert ["git", "tag", "-a"] not in [c[:3] for c in calls]
|
||||
assert ["git", "push", "origin", "v0.1.0"] in calls
|
||||
|
||||
@patch("devx.ci.release.get_head_commit", return_value="def456")
|
||||
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
|
||||
@patch("devx.ci.release.tag_exists", return_value=True)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_tag_exists_dry_run_no_push(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
||||
def test_tag_exists_mismatch_raises(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_tag_exists: MagicMock,
|
||||
mock_tag_commit: MagicMock,
|
||||
mock_head_commit: MagicMock,
|
||||
) -> None:
|
||||
"""Tag exists but points to different commit than HEAD → error."""
|
||||
with pytest.raises(click.ClickException, match="misalignment"):
|
||||
create_and_push_tag("0.1.0", "changelog", dry_run=False)
|
||||
|
||||
@patch("devx.ci.release.get_head_commit", return_value="abc123")
|
||||
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
|
||||
@patch("devx.ci.release.tag_exists", return_value=True)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_tag_exists_dry_run_no_push(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_tag_exists: MagicMock,
|
||||
mock_tag_commit: MagicMock,
|
||||
mock_head_commit: MagicMock,
|
||||
) -> None:
|
||||
result = create_and_push_tag("0.1.0", "changelog", dry_run=True)
|
||||
assert result is False
|
||||
# No git commands at all in dry-run when tag exists
|
||||
mock_run_cmd.assert_not_called()
|
||||
# No push in dry-run when tag exists, but alignment check still runs
|
||||
for call in mock_run_cmd.call_args_list:
|
||||
assert call.args[0][0:2] != ["git", "push"]
|
||||
assert call.args[0][0:2] != ["git", "tag"]
|
||||
|
||||
|
||||
class TestRunTests:
|
||||
@@ -295,6 +872,13 @@ class TestRunTests:
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""Tests for the main release command.
|
||||
|
||||
All tests mock fetch_tags and verify_tag_consistency since these
|
||||
are pre-flight checks that call git commands. Tests that need to
|
||||
verify specific git call sequences mock run_cmd with side_effect.
|
||||
"""
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_not_on_master_exits(self, mock_run_cmd: MagicMock) -> None:
|
||||
@@ -305,9 +889,13 @@ class TestMain:
|
||||
assert "master" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.5.0")
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=False)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_dry_run_on_non_master_warns(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None:
|
||||
def test_dry_run_on_non_master_warns(
|
||||
self, mock_run_cmd: MagicMock, mock_uf: MagicMock, mock_vtc: MagicMock, mock_glt: MagicMock
|
||||
) -> None:
|
||||
"""Dry-run mode should not fail on non-master branches."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="")
|
||||
runner = CliRunner()
|
||||
@@ -316,14 +904,26 @@ class TestMain:
|
||||
assert "Dry-run mode" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.get_head_commit", return_value="abc123")
|
||||
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_release_lock_skips_when_head_is_release_commit(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None:
|
||||
"""If HEAD is already a release commit, should skip to prevent duplicate releases."""
|
||||
# First call: git rev-parse (master), second: git log -1 (release commit)
|
||||
def test_release_lock_skips_when_head_is_release_commit_and_tag_exists(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_uf: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_tc: MagicMock,
|
||||
mock_hc: MagicMock,
|
||||
) -> None:
|
||||
"""If HEAD is a release commit and the tag exists, skip."""
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout="master\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="v0.5.0\n", stderr=""), # tag -l finds tag
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
@@ -332,6 +932,62 @@ class TestMain:
|
||||
assert "Skipping" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.get_head_commit", return_value="def456")
|
||||
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_release_lock_tag_points_elsewhere(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_uf: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
mock_tc: MagicMock,
|
||||
mock_hc: MagicMock,
|
||||
) -> None:
|
||||
"""If HEAD is a release commit but tag points elsewhere, error."""
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout="master\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="v0.5.0\n", stderr=""), # tag -l finds tag
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "misalignment" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.get_changelog", return_value="## changelog")
|
||||
@patch("devx.ci.release.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,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
"""If HEAD is a release commit but the tag is missing, create the tag."""
|
||||
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("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.has_unreleased_changes", return_value=False)
|
||||
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@@ -342,6 +998,8 @@ class TestMain:
|
||||
mock_bumped: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
@@ -350,6 +1008,7 @@ class TestMain:
|
||||
assert "No unreleased changes" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.create_and_push_tag")
|
||||
@patch("devx.ci.release.commit_release_changes")
|
||||
@@ -360,7 +1019,7 @@ class TestMain:
|
||||
@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.run_cmd")
|
||||
def test_dry_run_empty_changelog(
|
||||
def test_dry_run_empty_changelog_fails(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_has: MagicMock,
|
||||
@@ -372,14 +1031,17 @@ class TestMain:
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
"""Empty changelog should fail, not warn."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--dry-run"])
|
||||
assert result.exit_code == 0
|
||||
assert "empty changelog" in result.output
|
||||
assert result.exit_code != 0
|
||||
assert "empty changelog" in result.output.lower()
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.create_and_push_tag")
|
||||
@patch("devx.ci.release.commit_release_changes")
|
||||
@@ -402,6 +1064,7 @@ class TestMain:
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
@@ -414,6 +1077,8 @@ class TestMain:
|
||||
mock_tag.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="v0.3.0")
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=False)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@@ -422,6 +1087,8 @@ class TestMain:
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_user_facing: MagicMock,
|
||||
mock_latest: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
"""Release is skipped when only workflow/infra files changed."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
@@ -432,6 +1099,8 @@ class TestMain:
|
||||
assert "Skipping release" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_tests")
|
||||
@patch("devx.ci.release.create_and_push_tag", return_value=True)
|
||||
@@ -456,6 +1125,8 @@ class TestMain:
|
||||
mock_tag: MagicMock,
|
||||
mock_run_tests: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
@@ -469,6 +1140,8 @@ class TestMain:
|
||||
mock_tag.assert_called_once_with("0.2.0", "changelog", False)
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.run_tests")
|
||||
@patch("devx.ci.release.create_and_push_tag", return_value=False)
|
||||
@@ -493,6 +1166,8 @@ class TestMain:
|
||||
mock_tag: MagicMock,
|
||||
mock_run_tests: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
"""When tag already exists, still update files but report existing tag."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
@@ -503,6 +1178,8 @@ class TestMain:
|
||||
mock_tag.assert_called_once_with("0.1.0", "changelog", False)
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.create_and_push_tag", return_value=True)
|
||||
@patch("devx.ci.release.commit_release_changes", return_value=True)
|
||||
@@ -525,6 +1202,8 @@ class TestMain:
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
"""--skip-tests bypasses test verification."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
@@ -537,6 +1216,8 @@ class TestMain:
|
||||
assert make_calls == []
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.create_and_push_tag")
|
||||
@patch("devx.ci.release.commit_release_changes")
|
||||
@@ -559,6 +1240,8 @@ class TestMain:
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
"""If tests fail, release aborts — no commit, no tag."""
|
||||
# Calls: git rev-parse (master), git log -1 (release lock check),
|
||||
@@ -577,6 +1260,8 @@ class TestMain:
|
||||
mock_tag.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("devx.ci.release.create_and_push_tag")
|
||||
@patch("devx.ci.release.commit_release_changes")
|
||||
@@ -599,6 +1284,8 @@ class TestMain:
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
mock_ft: MagicMock,
|
||||
mock_vtc: MagicMock,
|
||||
) -> None:
|
||||
"""If lint fails, release aborts — no commit, no tag."""
|
||||
# Calls: git rev-parse (master), git log -1 (release lock check),
|
||||
@@ -614,3 +1301,38 @@ class TestMain:
|
||||
assert "Lint failed" in result.output
|
||||
mock_commit.assert_not_called()
|
||||
mock_tag.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.get_changelog_versions", return_value=[])
|
||||
@patch("devx.ci.release.get_init_version", return_value="0.1.0")
|
||||
@patch("devx.ci.release.get_all_tags", return_value=[])
|
||||
@patch("devx.ci.release.get_latest_tag", return_value="")
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_verify_mode_no_tags(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
mock_lt: MagicMock,
|
||||
mock_tags: MagicMock,
|
||||
mock_iv: MagicMock,
|
||||
mock_cv: MagicMock,
|
||||
) -> None:
|
||||
"""--verify checks alignment and exits without releasing."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--verify"])
|
||||
assert result.exit_code == 0
|
||||
assert "Release Alignment Verification" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("devx.ci.release.verify_tag_consistency", return_value=[" v0.1.0 → bad"])
|
||||
@patch("devx.ci.release.fetch_tags")
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_preflight_tag_consistency_fails(
|
||||
self, mock_run_cmd: MagicMock, mock_ft: MagicMock, mock_vtc: MagicMock
|
||||
) -> None:
|
||||
"""Pre-flight tag consistency check aborts if tags are misaligned."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "Tag consistency check failed" in result.output
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Unit tests for devx.tools.setup."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -32,20 +33,48 @@ class TestRun:
|
||||
|
||||
|
||||
class TestInstallPythonDeps:
|
||||
@patch("devx.tools.setup._run")
|
||||
@patch("devx.tools.setup.subprocess.run")
|
||||
def test_install_dev(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
_install_python_deps(".venv/bin", "dev")
|
||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[dev]"])
|
||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[dev]"], check=False)
|
||||
|
||||
@patch("devx.tools.setup._run")
|
||||
@patch("devx.tools.setup.subprocess.run")
|
||||
def test_install_ci(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
_install_python_deps(".venv/bin", "ci")
|
||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci]"])
|
||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci]"], check=False)
|
||||
|
||||
@patch("devx.tools.setup.subprocess.run")
|
||||
def test_install_custom_extras(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
_install_python_deps(".venv/bin", "ci,lint")
|
||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci,lint]"], check=False)
|
||||
|
||||
@patch("devx.tools.setup.subprocess.run")
|
||||
def test_install_with_break_system_packages(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
with patch.dict(os.environ, {"PIP_BREAK_SYSTEM_PACKAGES": "1"}):
|
||||
_install_python_deps(".venv/bin", "ci")
|
||||
mock_run.assert_called_once_with(
|
||||
[".venv/bin/pip", "install", "-e", ".[ci]", "--break-system-packages"], check=False
|
||||
)
|
||||
|
||||
@patch("devx.tools.setup._run")
|
||||
def test_install_custom_extras(self, mock_run: MagicMock) -> None:
|
||||
_install_python_deps(".venv/bin", "ci,lint")
|
||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci,lint]"])
|
||||
@patch("devx.tools.setup.subprocess.run")
|
||||
def test_install_retry_with_ignore_installed(self, mock_subprocess: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_subprocess.return_value = MagicMock(returncode=1)
|
||||
with patch.dict(os.environ, {"PIP_BREAK_SYSTEM_PACKAGES": "1"}):
|
||||
_install_python_deps(".venv/bin", "ci")
|
||||
mock_run.assert_called_once_with(
|
||||
[".venv/bin/pip", "install", "-e", ".[ci]", "--break-system-packages", "--ignore-installed"]
|
||||
)
|
||||
|
||||
@patch("devx.tools.setup.subprocess.run")
|
||||
def test_install_failure_without_break_system(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1)
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
_install_python_deps(".venv/bin", "ci")
|
||||
|
||||
|
||||
class TestInstallPreCommitHooks:
|
||||
@@ -60,8 +89,9 @@ class TestInstallPreCommitHooks:
|
||||
|
||||
|
||||
class TestInstallAnsibleCollections:
|
||||
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/ansible-galaxy")
|
||||
@patch("devx.tools.setup._run")
|
||||
def test_installs_from_requirements(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
def test_installs_from_requirements(self, mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path) -> None:
|
||||
req = tmp_path / "ansible" / "requirements.yml"
|
||||
req.parent.mkdir(parents=True)
|
||||
req.write_text("collections: []")
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Unit tests for devx.molecule.start_docker."""
|
||||
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.molecule.start_docker import (
|
||||
DOCKER_SOCK,
|
||||
_diagnose_socket,
|
||||
is_docker_ready,
|
||||
main,
|
||||
start_docker_daemon,
|
||||
)
|
||||
|
||||
|
||||
class TestIsDockerReady:
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
def test_ready(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
assert is_docker_ready() is True
|
||||
call_kwargs = mock_run.call_args
|
||||
assert call_kwargs.args[0] == ["docker", "info"]
|
||||
assert call_kwargs.kwargs["env"]["DOCKER_HOST"] == f"unix://{DOCKER_SOCK}"
|
||||
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
def test_not_ready(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1)
|
||||
assert is_docker_ready() is False
|
||||
|
||||
|
||||
class TestDiagnoseSocket:
|
||||
@patch("devx.molecule.start_docker.os.stat")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
def test_socket_exists(self, mock_run: MagicMock, mock_exists: MagicMock, mock_stat: MagicMock) -> None:
|
||||
mock_stat.return_value = MagicMock(st_mode=0o660, st_uid=0, st_gid=0)
|
||||
mock_run.side_effect = [
|
||||
MagicMock(stdout="/dev/sda1 /var/lib/docker ext4\n", returncode=0, text=""),
|
||||
MagicMock(stdout="default\n", returncode=0, text=""),
|
||||
MagicMock(
|
||||
stdout="Server Version: 29.5.2\nStorage Driver: overlay2\nDocker Root Dir: /var/lib/docker\n",
|
||||
returncode=0,
|
||||
text="",
|
||||
),
|
||||
]
|
||||
_diagnose_socket()
|
||||
mock_exists.assert_called_with(DOCKER_SOCK)
|
||||
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
def test_socket_missing(self, mock_run: MagicMock, mock_exists: MagicMock) -> None:
|
||||
mock_run.side_effect = [
|
||||
MagicMock(stdout="proc on /proc type proc\n", returncode=0, text=""),
|
||||
MagicMock(stdout="default\n", returncode=0, text=""),
|
||||
MagicMock(stdout="", stderr="Cannot connect", returncode=1, text=""),
|
||||
]
|
||||
_diagnose_socket()
|
||||
mock_exists.assert_called_with(DOCKER_SOCK)
|
||||
|
||||
|
||||
class TestStartDockerDaemon:
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
|
||||
def test_host_socket_available(self, mock_ready: MagicMock, mock_diag: MagicMock) -> None:
|
||||
"""Should return immediately if host Docker is available."""
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
mock_ready.assert_called_once()
|
||||
mock_diag.assert_called_once()
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready")
|
||||
def test_rootless_socket_available(
|
||||
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
|
||||
) -> None:
|
||||
"""Should use rootless socket if host socket fails."""
|
||||
# First check (host) fails, second check (rootless) succeeds
|
||||
mock_ready.side_effect = [False, True]
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
@patch("devx.molecule.start_docker.subprocess.Popen")
|
||||
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
||||
def test_starts_local_daemon(
|
||||
self,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
) -> None:
|
||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||
# Host fails, rootless doesn't exist, local daemon starts
|
||||
mock_ready.side_effect = [False, False, False, False, True]
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
mock_popen.assert_called_once()
|
||||
popen_args = mock_popen.call_args.args[0]
|
||||
assert "dockerd" in popen_args
|
||||
assert "--storage-driver" in popen_args
|
||||
assert "vfs" in popen_args
|
||||
assert "-H" in popen_args
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
@patch("devx.molecule.start_docker.subprocess.Popen")
|
||||
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
||||
def test_fails_after_timeout(
|
||||
self,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
) -> None:
|
||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||
with patch("builtins.open", mock_open(read_data="dockerd error log")):
|
||||
assert start_docker_daemon(timeout=3) is False
|
||||
mock_popen.assert_called_once()
|
||||
assert mock_sleep.call_count == 3
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
@patch("devx.molecule.start_docker.subprocess.Popen")
|
||||
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
||||
def test_fails_log_read_error(
|
||||
self,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
) -> None:
|
||||
"""Should handle log read errors gracefully."""
|
||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||
with patch("builtins.open", side_effect=OSError("permission denied")):
|
||||
assert start_docker_daemon(timeout=2) is False
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready")
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
@patch("devx.molecule.start_docker.subprocess.Popen")
|
||||
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
||||
def test_local_daemon_ready_on_first_check(
|
||||
self,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
) -> None:
|
||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||
# Host fails, rootless doesn't exist, local ready on first loop check
|
||||
mock_ready.side_effect = [False, False, True]
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
assert mock_popen.call_count == 1
|
||||
assert mock_sleep.call_count == 1
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker.os.environ")
|
||||
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
|
||||
def test_sets_docker_host(
|
||||
self,
|
||||
mock_ready: MagicMock,
|
||||
mock_environ: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
) -> None:
|
||||
"""DOCKER_HOST must be set so molecule connects to correct socket."""
|
||||
start_docker_daemon(timeout=5)
|
||||
mock_environ.__setitem__.assert_called_with("DOCKER_HOST", f"unix://{DOCKER_SOCK}")
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
|
||||
def test_success(self, mock_start: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=False)
|
||||
def test_failure(self, mock_start: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 1
|
||||
|
||||
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
|
||||
def test_custom_timeout_flag(self, mock_start: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--timeout", "60"])
|
||||
assert result.exit_code == 0
|
||||
mock_start.assert_called_once_with(60)
|
||||
|
||||
@patch("devx.molecule.start_docker.os.environ.get")
|
||||
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
|
||||
def test_exports_github_env(self, mock_start: MagicMock, mock_get: MagicMock) -> None:
|
||||
"""Should write DOCKER_HOST to GITHUB_ENV when available."""
|
||||
mock_get.side_effect = lambda key, default="": (
|
||||
"/tmp/github_env" if key == "GITHUB_ENV" else f"unix://{DOCKER_SOCK}" if key == "DOCKER_HOST" else default
|
||||
)
|
||||
with patch("builtins.open", mock_open()) as mock_file:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
mock_file.assert_called_with("/tmp/github_env", "a")
|
||||
|
||||
@patch("devx.molecule.start_docker.os.environ.get", return_value="")
|
||||
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
|
||||
def test_no_github_env(self, mock_start: MagicMock, mock_get: MagicMock) -> None:
|
||||
"""Should not crash when GITHUB_ENV is not set."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
@@ -63,6 +64,22 @@ class TestLoadMapping:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
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:
|
||||
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("devx.ci.sync_wiki.GiteaClient")
|
||||
def test_file_not_found_warning(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that missing doc files are skipped with a warning."""
|
||||
def test_file_not_found_fails(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that missing doc files cause an error, not a warning."""
|
||||
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
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={}):
|
||||
runner = CliRunner()
|
||||
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 "Skipped: 1" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.sync_wiki.GiteaClient")
|
||||
def test_empty_doc_file_skipped(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that empty doc files are skipped with a warning."""
|
||||
def test_empty_doc_file_fails(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that empty doc files cause an error, not a warning."""
|
||||
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
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={}):
|
||||
runner = CliRunner()
|
||||
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 "Skipped: 1" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.sync_wiki.GiteaClient")
|
||||
|
||||
@@ -152,6 +152,89 @@ class TestMain:
|
||||
assert "task ID" in result.output
|
||||
|
||||
|
||||
class TestCustomPrefix:
|
||||
"""Tests for custom task ID prefix (e.g., GRM-N instead of DEVX-N).
|
||||
|
||||
The prefix is configured via the DEVX_TASK_PREFIX environment variable.
|
||||
This is critical for consumer projects like GRM that use their own
|
||||
Vikunja project with a different identifier prefix.
|
||||
"""
|
||||
|
||||
def _write_msg(self, content: str) -> str:
|
||||
fd, path = tempfile.mkstemp()
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(content)
|
||||
return path
|
||||
|
||||
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "GRM"})
|
||||
def test_master_accepts_grm_prefix(self) -> None:
|
||||
"""Master branch accepts GRM-N: prefix when DEVX_TASK_PREFIX=GRM."""
|
||||
import importlib
|
||||
|
||||
import devx.ci.validate_commit_msg as vcm
|
||||
import devx.config
|
||||
|
||||
importlib.reload(devx.config)
|
||||
importlib.reload(vcm)
|
||||
try:
|
||||
msg_path = self._write_msg("GRM-66: fix: add scripts/** to infrastructure")
|
||||
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(vcm.main, [msg_path])
|
||||
assert result.exit_code == 0
|
||||
os.unlink(msg_path)
|
||||
finally:
|
||||
os.environ.pop("DEVX_TASK_PREFIX", None)
|
||||
importlib.reload(devx.config)
|
||||
importlib.reload(vcm)
|
||||
|
||||
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "GRM"})
|
||||
def test_master_rejects_devx_prefix_when_grm_configured(self) -> None:
|
||||
"""Master branch rejects DEVX-N: prefix when DEVX_TASK_PREFIX=GRM."""
|
||||
import importlib
|
||||
|
||||
import devx.ci.validate_commit_msg as vcm
|
||||
import devx.config
|
||||
|
||||
importlib.reload(devx.config)
|
||||
importlib.reload(vcm)
|
||||
try:
|
||||
msg_path = self._write_msg("DEVX-8: fix: wrong prefix")
|
||||
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(vcm.main, [msg_path])
|
||||
assert result.exit_code == 1
|
||||
assert "GRM-N" in result.output
|
||||
os.unlink(msg_path)
|
||||
finally:
|
||||
os.environ.pop("DEVX_TASK_PREFIX", None)
|
||||
importlib.reload(devx.config)
|
||||
importlib.reload(vcm)
|
||||
|
||||
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "GRM"})
|
||||
def test_feature_branch_rejects_grm_prefix(self) -> None:
|
||||
"""Feature branch rejects GRM-N: prefix when DEVX_TASK_PREFIX=GRM."""
|
||||
import importlib
|
||||
|
||||
import devx.ci.validate_commit_msg as vcm
|
||||
import devx.config
|
||||
|
||||
importlib.reload(devx.config)
|
||||
importlib.reload(vcm)
|
||||
try:
|
||||
msg_path = self._write_msg("GRM-66: fix: should not have prefix on branch")
|
||||
with patch("devx.ci.validate_commit_msg.get_branch", return_value="GRM-66-fix"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(vcm.main, [msg_path])
|
||||
assert result.exit_code == 1
|
||||
assert "task ID" in result.output
|
||||
os.unlink(msg_path)
|
||||
finally:
|
||||
os.environ.pop("DEVX_TASK_PREFIX", None)
|
||||
importlib.reload(devx.config)
|
||||
importlib.reload(vcm)
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
import tempfile
|
||||
|
||||
|
||||
Reference in New Issue
Block a user