Compare commits

...
43 Commits
Author SHA1 Message Date
devx-ci-bot 80ca622838 release: v0.9.10 [skip ci] 2026-06-24 18:42:23 +02:00
emil 3f2d19d7ac DEVX-34: fix: retrospective fixes for CI/CD friction
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 25s
Post-merge / release (push) Successful in 50s
Post-merge / vikunja (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 41s
Post-merge / badges (push) Successful in 40s
2026-06-24 16:41:22 +00:00
devx-ci-bot c839d49fe3 release: v0.9.9 [skip ci] 2026-06-24 13:17:30 +02:00
emil 93b5d2f926 DEVX-33: fix: use explicit refspecs for git push to avoid tag/branch ambiguity
Post-merge / detect-type (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / release (push) Successful in 39s
Post-merge / vikunja (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 39s
Post-merge / badges (push) Successful in 41s
2026-06-24 11:16:41 +00:00
emil 131c04c9d0 DEVX-32: fix: filter non-version tags in release verification
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Successful in 12s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Failing after 37s
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 40s
2026-06-24 11:09:47 +00:00
emil 8e9681cf7d DEVX-31: fix: prefer branch name for task ID extraction + strip heads/ prefix in release
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 14s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Failing after 29s
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 37s
2026-06-24 10:54:58 +00:00
emil 6631525a1d DEVX-30: fix: use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets
Post-merge / detect-type (push) Successful in 6s
Post-merge / validate-commit-msg (push) Successful in 6s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / release (push) Failing after 35s
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 39s
2026-06-24 10:35:32 +00:00
devx-ci-bot 6985030a3c release: v0.9.8 [skip ci] 2026-06-24 11:15:20 +02:00
emil 4d073f3beb DEVX-29: fix: use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets
Post-merge / detect-type (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 19s
Post-merge / release (push) Successful in 42s
Post-merge / vikunja (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 42s
Post-merge / badges (push) Successful in 41s
2026-06-24 09:14:27 +00:00
devx-ci-bot 037d7b0d16 release: v0.9.7 [skip ci] 2026-06-24 02:18:19 +00:00
emil 9cb706e387 DEVX-28: fix: add rootless socket fallback and GITHUB_ENV export
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Successful in 45s
Post-merge / vikunja (push) Successful in 7s
Post-merge / sync-wiki (push) Successful in 41s
Post-merge / badges (push) Successful in 42s
2026-06-24 02:17:20 +00:00
devx-ci-bot 5bd6158f2a release: v0.9.6 [skip ci] 2026-06-24 04:00:50 +02:00
emil 05aa2ffe76 DEVX-27: fix: add Docker socket diagnostics to start_docker
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / release (push) Successful in 41s
Post-merge / vikunja (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 43s
Post-merge / badges (push) Successful in 52s
2026-06-24 01:59:55 +00:00
emil b9c3b55680 DEVX-27: fix: add Docker socket diagnostics to start_docker
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 6s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Successful in 33s
Post-merge / vikunja (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 40s
Post-merge / badges (push) Successful in 39s
2026-06-24 01:50:46 +00:00
devx-ci-bot d398c8e971 release: v0.9.5 [skip ci] 2026-06-24 01:37:15 +00:00
emil 39526d8e6a DEVX-26: fix: use host Docker socket with DOCKER_HOST fallback to local dockerd
Post-merge / detect-type (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / release (push) Successful in 46s
Post-merge / vikunja (push) Successful in 10s
Post-merge / badges (push) Successful in 41s
Post-merge / sync-wiki (push) Successful in 43s
2026-06-24 01:36:21 +00:00
emil 37730e2187 DEVX-25: fix: use host Docker socket with DOCKER_HOST fallback to local dockerd
Post-merge / detect-type (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / release (push) Successful in 41s
Post-merge / vikunja (push) Successful in 12s
Post-merge / sync-wiki (push) Successful in 48s
Post-merge / badges (push) Successful in 52s
2026-06-24 01:24:00 +00:00
devx-ci-bot e2f66ca70a release: v0.9.4 [skip ci] 2026-06-24 01:11:08 +00:00
emil 0b88c211f1 DEVX-24: fix: use separate Docker socket for DinD in CI
Post-merge / detect-type (push) Successful in 14s
Post-merge / validate-commit-msg (push) Successful in 15s
Post-merge / configure-repo (push) Successful in 43s
Post-merge / release (push) Successful in 51s
Post-merge / vikunja (push) Successful in 18s
Post-merge / sync-wiki (push) Successful in 45s
Post-merge / badges (push) Successful in 44s
2026-06-24 01:08:10 +00:00
devx-ci-bot ea4ee0d303 release: v0.9.3 [skip ci] 2026-06-24 02:46:26 +02:00
emil 16fba17b03 DEVX-23: fix: use tempfile for dockerd log to fix CI permission error
Post-merge / detect-type (push) Successful in 15s
Post-merge / validate-commit-msg (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / release (push) Successful in 40s
Post-merge / vikunja (push) Successful in 22s
Post-merge / sync-wiki (push) Successful in 42s
Post-merge / badges (push) Successful in 54s
2026-06-24 00:45:28 +00:00
devx-ci-bot 011cf3e093 release: v0.9.2 [skip ci] 2026-06-24 02:34:47 +02:00
emil daf99c5fed DEVX-22: fix: use vfs storage driver for Docker-in-Docker in CI
Post-merge / detect-type (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 12s
Post-merge / release (push) Successful in 42s
Post-merge / vikunja (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 38s
Post-merge / badges (push) Successful in 40s
2026-06-24 00:33:48 +00:00
emil 7154e3ad7c DEVX-21: chore: trigger auto-merge after Vikunja title fix
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Successful in 6s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Successful in 30s
Post-merge / vikunja (push) Successful in 14s
Post-merge / sync-wiki (push) Successful in 35s
Post-merge / badges (push) Successful in 42s
2026-06-24 00:28:18 +00:00
devx-ci-bot 6053fb9fba release: v0.9.1 [skip ci] 2026-06-24 01:50:03 +02:00
emil e76741bfad DEVX-21: fix: always start dockerd in CI runner for molecule tests
Post-merge / detect-type (push) Successful in 6s
Post-merge / validate-commit-msg (push) Successful in 14s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / release (push) Successful in 40s
Post-merge / vikunja (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 51s
Post-merge / badges (push) Successful in 59s
2026-06-23 23:49:13 +00:00
devx-ci-bot f206a9cd8d release: v0.9.0 [skip ci] 2026-06-24 01:29:24 +02:00
emil b4b7428f9c DEVX-20: feat: extract Docker daemon start to tested Python module
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / release (push) Successful in 51s
Post-merge / vikunja (push) Successful in 18s
Post-merge / badges (push) Successful in 45s
Post-merge / sync-wiki (push) Successful in 47s
2026-06-23 23:28:20 +00:00
devx-ci-bot 0d9e76a838 release: v0.8.5 [skip ci] 2026-06-24 01:02:21 +02:00
emil 034cbde2f7 DEVX-19: fix: retry pip install with --ignore-installed only on failure
Post-merge / detect-type (push) Successful in 6s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / release (push) Successful in 39s
Post-merge / vikunja (push) Successful in 13s
Post-merge / sync-wiki (push) Successful in 36s
Post-merge / badges (push) Successful in 41s
2026-06-23 23:01:32 +00:00
devx-ci-bot e0abe6f176 release: v0.8.4 [skip ci] 2026-06-23 22:28:29 +00:00
emil 15f6837dc2 DEVX-18: fix: add --ignore-installed to pip in CI to bypass debian packages
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Successful in 45s
Post-merge / vikunja (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 39s
Post-merge / badges (push) Successful in 42s
2026-06-23 22:27:35 +00:00
devx-ci-bot b4dda91e24 release: v0.8.3 [skip ci] 2026-06-23 21:58:33 +00:00
emil 3e21e774f7 DEVX-17: fix: pass --break-system-packages to pip in CI environments
Post-merge / detect-type (push) Successful in 9s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / release (push) Successful in 45s
Post-merge / sync-wiki (push) Successful in 38s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / vikunja (push) Successful in 14s
Post-merge / badges (push) Successful in 41s
2026-06-23 21:57:37 +00:00
emil 7c11215e57 DEVX-16: fix: lower check_test_speed threshold to 4 seconds
Post-merge / detect-type (push) Successful in 23s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Successful in 51s
Post-merge / validate-commit-msg (push) Successful in 12s
Post-merge / badges (push) Successful in 57s
Post-merge / vikunja (push) Successful in 19s
Post-merge / sync-wiki (push) Successful in 1m29s
2026-06-23 20:35:53 +00:00
devx-ci-bot 5384269c83 release: v0.8.2 [skip ci] 2026-06-23 21:44:03 +02:00
emil b3d0dd8ca7 DEVX-15: fix: encode spaces in pair commands to survive shell word-splitting
Post-merge / detect-type (push) Successful in 15s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 31s
Post-merge / sync-wiki (push) Successful in 47s
Post-merge / badges (push) Successful in 1m3s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / release (push) Successful in 47s
2026-06-23 19:42:56 +00:00
devx-ci-bot a7dcaee5c6 release: v0.8.1 [skip ci] 2026-06-23 20:45:02 +02:00
emil 02f8d3757b DEVX-14: fix: set fresh MOLECULE_HOME per pair to avoid stale config cache
Post-merge / detect-type (push) Successful in 14s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 20s
Post-merge / sync-wiki (push) Successful in 58s
Post-merge / badges (push) Successful in 1m6s
Post-merge / release (push) Successful in 48s
Post-merge / vikunja (push) Successful in 21s
2026-06-23 18:43:58 +00:00
devx-ci-bot 4311fb7648 release: v0.8.0 [skip ci] 2026-06-23 20:11:18 +02:00
emil 2ead959fcf DEVX-14: feat: fix molecule platforms to use sleep infinity, add --platforms-file
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 20s
Post-merge / release (push) Successful in 47s
Post-merge / vikunja (push) Successful in 20s
Post-merge / sync-wiki (push) Successful in 50s
Post-merge / badges (push) Successful in 1m0s
2026-06-23 18:10:19 +00:00
devx-ci-bot 9a60009d29 release: v0.7.0 [skip ci] 2026-06-23 18:26:54 +02:00
emil c20dfd185a DEVX-13: feat: add per-test timing quality gate to check_test_speed
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 26s
Post-merge / release (push) Successful in 43s
Post-merge / vikunja (push) Successful in 16s
Post-merge / sync-wiki (push) Successful in 48s
Post-merge / badges (push) Successful in 58s
2026-06-23 16:25:50 +00:00
30 changed files with 2505 additions and 1293 deletions
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
python3 -m devx.tools.check_test_speed --max-seconds 60 python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
- name: Documentation coverage check - name: Documentation coverage check
env: env:
PYTHONPATH: src PYTHONPATH: src
@@ -120,8 +120,8 @@ jobs:
auto-merge: auto-merge:
# Auto-merge runs after all CI checks pass. It reads the task ID # Auto-merge runs after all CI checks pass. It reads the task ID
# from .taskid file, validates the PR title, and squash-merges. # from the branch name (falling back to .taskid file), validates
# No manual label or review needed — CI is the quality gate. # the PR title, and squash-merges.
needs: [quality, detect-changes, pr-review] needs: [quality, detect-changes, pr-review]
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
runs-on: docker runs-on: docker
+1 -1
View File
@@ -1 +1 @@
DEVX-12 DEVX-30
+35
View File
@@ -285,6 +285,37 @@ setuptools via `dynamic = ["version"]` in `pyproject.toml`.
| PR title | `DEVX-N: <vikunja task title>` | `DEVX-12: Add release automation` | | PR title | `DEVX-N: <vikunja task title>` | `DEVX-12: Add release automation` |
| Merge commit | `DEVX-N <conventional commit>` | `DEVX-12 feat: add release script` | | Merge commit | `DEVX-N <conventional commit>` | `DEVX-12 feat: add release script` |
### Task ID Resolution
`auto_merge` resolves the task ID from the branch name first (e.g.
`DEVX-12-fix-foo``DEVX-12`), falling back to the `.taskid` file
for branches without a task ID prefix. If both exist and disagree,
a warning is printed and the branch task ID is preferred.
**When creating a new branch from an existing branch**, the `.taskid`
file may be stale (it contains the old branch's task ID). Either:
1. Update `.taskid` to match the new branch's task ID, or
2. Delete `.taskid` — the branch name is the primary source of truth
### Workflow `auto-merge` Job and `always()`
When `auto-merge` depends on a job that can be skipped (e.g.
`molecule-tests`), the `if:` condition MUST include `always() &&`
at the start. Without it, Gitea Actions skips `auto-merge` when any
dependency is skipped, even if the condition explicitly allows
`result == 'skipped'`.
```yaml
auto-merge:
needs: [quality, detect-changes, pr-review, molecule-tests]
if: >-
always() &&
github.event_name == 'pull_request' &&
needs.quality.result == 'success' &&
needs.pr-review.result == 'success' &&
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
```
## Config System ## Config System
devx uses environment variables with `.env` file fallback for configuration. devx uses environment variables with `.env` file fallback for configuration.
@@ -295,6 +326,10 @@ devx uses environment variables with `.env` file fallback for configuration.
|----------|---------|-------------| |----------|---------|-------------|
| `DEVX_GITEA_API_URL` | `https://git.oblachno.oblachno.fyi/api/v1` | Gitea API base URL | | `DEVX_GITEA_API_URL` | `https://git.oblachno.oblachno.fyi/api/v1` | Gitea API base URL |
| `DEVX_VIKUNJA_API_URL` | `https://work.oblachno.oblachno.fyi/api/v1` | Vikunja API base URL | | `DEVX_VIKUNJA_API_URL` | `https://work.oblachno.oblachno.fyi/api/v1` | Vikunja API base URL |
| `DEVX_REPO_OWNER` | **(none — must be set)** | Repository owner for API calls |
| `DEVX_REPO_NAME` | **(none — must be set)** | Repository name (or `owner/repo`) |
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
| `DEVX_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID |
| `DEVX_LANG` | `en` | Language for i18n (en, bg) | | `DEVX_LANG` | `en` | Language for i18n (en, bg) |
| `REPO_TOKEN` | (from .env) | Gitea API token | | `REPO_TOKEN` | (from .env) | Gitea API token |
| `VIKUNJA_TOKEN` | (from .env) | Vikunja API token | | `VIKUNJA_TOKEN` | (from .env) | Vikunja API token |
+114
View File
@@ -2,6 +2,120 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.9.10] - 2026-06-24
### Bug Fixes
- Retrospective fixes for CI/CD friction
## [0.9.9] - 2026-06-24
### Bug Fixes
- Use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets
- Prefer branch name for task ID extraction + strip heads/ prefix in release
- Filter non-version tags in release verification
- Use explicit refspecs for git push to avoid tag/branch ambiguity
## [0.9.8] - 2026-06-24
### Bug Fixes
- Use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets
## [0.9.7] - 2026-06-24
### Bug Fixes
- Add rootless socket fallback and GITHUB_ENV export
## [0.9.6] - 2026-06-24
### 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 ## [0.6.0] - 2026-06-23
### Features ### Features
+7 -1
View File
@@ -76,7 +76,13 @@ Validate commit messages for conventional commit format.
### `devx tools check-test-speed` ### `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` ### `devx tools configure-repo`
+4 -3
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# pre-commit hook: fail if unit tests take longer than 10 seconds. # pre-commit hook: fail if unit tests are too slow.
# Aligned with CI timeout (ci.yml uses --max-seconds 10). # Checks both total suite time (10s) and per-test time (0.5s).
# Aligned with CI (ci.yml uses same thresholds).
set -e set -e
export PYTHONPATH=src 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 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects.""" """devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.6.0" __version__ = "0.9.10"
+34 -9
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Auto-merge PR when all CI checks pass. """Auto-merge PR when all CI checks pass.
Runs as the final job in ci.yml. Reads the task ID from ``.taskid`` file Runs as the final job in ci.yml. Reads the task ID from the branch name
(falling back to branch name extraction for backwards compatibility), (falling back to ``.taskid`` file for branches without a task ID prefix),
validates the PR title, and squash-merges with a conventional commit validates the PR title, and squash-merges with a conventional commit
message prefixed by the task ID. message prefixed by the task ID.
@@ -63,20 +63,45 @@ def run_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess[
def read_taskid(branch: str) -> str: def read_taskid(branch: str) -> str:
"""Read task ID from .taskid file, falling back to branch name extraction. """Read task ID from branch name, falling back to .taskid file.
The .taskid file is a simple text file containing just the task ID The branch name is the primary source of truth for the task ID
(e.g., ``DEVX-60``). If the file doesn't exist, extract from the (e.g., ``DEVX-31-fix-foo`` → ``DEVX-31``). The ``.taskid`` file
branch name as a backwards-compatibility fallback. is a legacy fallback for branches without a task ID prefix.
If both sources exist and disagree, a warning is printed and the
branch task ID is preferred (it is the current source of truth).
""" """
branch_task_id = extract_task_id(branch)
if branch_task_id:
# Check for stale .taskid file that disagrees with branch name
path = Path(TASKID_FILE)
if path.exists():
file_task_id = path.read_text(encoding="utf-8").strip()
if file_task_id and file_task_id != branch_task_id:
click.echo(
_(
"WARNING: .taskid file ({file_id}) disagrees with branch name ({branch_id}). "
"Using branch task ID. Update or delete .taskid to silence this warning.",
file_id=file_task_id,
branch_id=branch_task_id,
)
)
return branch_task_id
# Fallback: read from .taskid file
path = Path(TASKID_FILE) path = Path(TASKID_FILE)
if path.exists(): if path.exists():
task_id = path.read_text(encoding="utf-8").strip() task_id = path.read_text(encoding="utf-8").strip()
if task_id: if task_id:
click.echo(
_(
"Task ID from .taskid file: {task_id} (not found in branch name '{branch}')",
task_id=task_id,
branch=branch,
)
)
return task_id return task_id
# Fallback: extract from branch name return ""
match = TASK_ID_RE.search(branch)
return match.group(0) if match else ""
def extract_task_id(branch: str) -> str: def extract_task_id(branch: str) -> str:
+13 -6
View File
@@ -136,10 +136,14 @@ def verify_tag_consistency() -> list[str]:
""" """
errors: list[str] = [] errors: list[str] = []
tags = get_all_tags() tags = get_all_tags()
# Sort oldest first to identify the first tag # Filter to version tags (vX.Y.Z) and sort oldest first
sorted_tags = sorted(tags, key=lambda t: [int(x) for x in t.lstrip("v").split(".")]) version_tags = [t for t in tags if re.match(r"^v\d+\.\d+\.\d+$", t)]
sorted_tags = sorted(version_tags, key=lambda t: [int(x) for x in t.lstrip("v").split(".")])
first_tag = sorted_tags[0] if sorted_tags else None first_tag = sorted_tags[0] if sorted_tags else None
for tag in tags: for tag in tags:
# Skip non-version tags (e.g., branch names like "master")
if not re.match(r"^v\d+\.\d+\.\d+$", tag):
continue
tag_version = tag.lstrip("v") tag_version = tag.lstrip("v")
commit_version = get_commit_version(tag) commit_version = get_commit_version(tag)
if commit_version is None: if commit_version is None:
@@ -340,14 +344,14 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
click.echo(_("Tag {tag} already exists and points to HEAD. Skipping creation.", tag=tag)) click.echo(_("Tag {tag} already exists and points to HEAD. Skipping creation.", tag=tag))
if not dry_run: if not dry_run:
# Ensure the existing tag is pushed # Ensure the existing tag is pushed
run_cmd(["git", "push", "origin", tag], check=False) run_cmd(["git", "push", "origin", f"refs/tags/{tag}"], check=False)
return False return False
tag_msg = f"Release v{new_version}\n\n{changelog}" tag_msg = f"Release v{new_version}\n\n{changelog}"
if dry_run: if dry_run:
click.echo(_("[dry-run] Would create tag: {tag}", tag=tag)) click.echo(_("[dry-run] Would create tag: {tag}", tag=tag))
return True return True
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg]) run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
run_cmd(["git", "push", "origin", tag]) run_cmd(["git", "push", "origin", f"refs/tags/{tag}"])
return True return True
@@ -478,7 +482,7 @@ def verify_alignment() -> int:
) )
if result.returncode == 0 and result.stdout.strip(): if result.returncode == 0 and result.stdout.strip():
all_release_commits = result.stdout.strip().split("\n") all_release_commits = result.stdout.strip().split("\n")
all_tags_set = {t.lstrip("v") for t in get_all_tags()} all_tags_set = {t.lstrip("v") for t in get_all_tags() if re.match(r"^v\d+\.\d+\.\d+$", t)}
truly_untagged: list[str] = [] truly_untagged: list[str] = []
duplicates: list[str] = [] duplicates: list[str] = []
for line in all_release_commits: for line in all_release_commits:
@@ -547,6 +551,8 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
# Ensure we're on master (skip this check in dry-run mode for PR validation) # Ensure we're on master (skip this check in dry-run mode for PR validation)
branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip() branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
# Some git versions return "heads/master" instead of "master"
branch = branch.removeprefix("heads/")
if branch != "master" and not dry_run: if branch != "master" and not dry_run:
raise click.ClickException(_("Release must be run on master, currently on '{branch}'.", branch=branch)) raise click.ClickException(_("Release must be run on master, currently on '{branch}'.", branch=branch))
if branch != "master" and dry_run: if branch != "master" and dry_run:
@@ -694,7 +700,8 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
# Pull --rebase before push to handle the case where master # Pull --rebase before push to handle the case where master
# advanced between checkout and commit (e.g., another merge). # advanced between checkout and commit (e.g., another merge).
run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False) run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
run_cmd(["git", "push", "origin", "master"]) # Use refs/heads/master to avoid ambiguity with a 'master' tag
run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"])
click.echo(_("Pushed release commit to master.")) click.echo(_("Pushed release commit to master."))
else: else:
click.echo(_("Skipping commit push — no staged changes.")) click.echo(_("Skipping commit push — no staged changes."))
+3 -2
View File
@@ -13,8 +13,9 @@ import re
GITEA_API_URL = os.getenv("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1") GITEA_API_URL = os.getenv("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
VIKUNJA_API_URL = os.getenv("DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1") VIKUNJA_API_URL = os.getenv("DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
# Organization defaults # Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
REPO_OWNER = os.getenv("DEVX_REPO_OWNER", "oblachno-oss") # No default: prevents silent 404s when the wrong owner is used.
REPO_OWNER = os.getenv("DEVX_REPO_OWNER", "")
# Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.) # Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
TASK_PREFIX = os.getenv("DEVX_TASK_PREFIX", "DEVX") TASK_PREFIX = os.getenv("DEVX_TASK_PREFIX", "DEVX")
+20 -11
View File
@@ -25,7 +25,7 @@ from pathlib import Path
import click import click
from devx.i18n import _ from devx.i18n import _
from devx.molecule.platforms import PLATFORMS from devx.molecule.platforms import PLATFORMS, load_platforms
DEFAULT_MAX_RUNNERS = 3 DEFAULT_MAX_RUNNERS = 3
MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule") MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule")
@@ -41,7 +41,8 @@ class TestPair:
def encode(self) -> str: def encode(self) -> str:
"""Serialize to a pipe-delimited string for CI consumption.""" """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 @staticmethod
def decode(encoded: str) -> TestPair: def decode(encoded: str) -> TestPair:
@@ -49,7 +50,7 @@ class TestPair:
parts = encoded.split("|") parts = encoded.split("|")
return TestPair( return TestPair(
scenario=parts[0], 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__", " ")},
) )
@@ -63,9 +64,8 @@ class MultiRoleTestPair:
def encode(self) -> str: def encode(self) -> str:
"""Serialize to a pipe-delimited string: ``role|scenario|platform_name|image|command``.""" """Serialize to a pipe-delimited string: ``role|scenario|platform_name|image|command``."""
return ( cmd = self.platform["command"].replace(" ", "__SPACE__")
f"{self.role}|{self.scenario}|{self.platform['name']}|{self.platform['image']}|{self.platform['command']}" return f"{self.role}|{self.scenario}|{self.platform['name']}|{self.platform['image']}|{cmd}"
)
@staticmethod @staticmethod
def decode(encoded: str) -> MultiRoleTestPair: def decode(encoded: str) -> MultiRoleTestPair:
@@ -74,7 +74,7 @@ class MultiRoleTestPair:
return MultiRoleTestPair( return MultiRoleTestPair(
role=parts[0], role=parts[0],
scenario=parts[1], scenario=parts[1],
platform={"name": parts[2], "image": parts[3], "command": parts[4]}, platform={"name": parts[2], "image": parts[3], "command": parts[4].replace("__SPACE__", " ")},
) )
@@ -238,6 +238,13 @@ def _write_github_env(key: str, value: str) -> None:
help="Roles directory for multi-role discovery (scans */molecule/*/). " help="Roles directory for multi-role discovery (scans */molecule/*/). "
"Use this for projects with multiple Ansible roles. Default: disabled (single-role mode).", "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( def cli(
runner_index: int | None, runner_index: int | None,
max_runners: int, max_runners: int,
@@ -247,7 +254,9 @@ def cli(
skip_if_excess: bool, skip_if_excess: bool,
molecule_root: Path | None, molecule_root: Path | None,
roles_root: Path | None, roles_root: Path | None,
platforms_file: Path | None,
) -> None: ) -> None:
platforms = load_platforms(platforms_file)
# Multi-role mode: discover (role, scenario) pairs across all roles # Multi-role mode: discover (role, scenario) pairs across all roles
if roles_root is not None: if roles_root is not None:
role_scenarios = discover_multi_role_scenarios(roles_root) role_scenarios = discover_multi_role_scenarios(roles_root)
@@ -256,10 +265,10 @@ def cli(
click.echo(f"{role}|{scenario}") click.echo(f"{role}|{scenario}")
return return
if list_platforms: if list_platforms:
for p in PLATFORMS: for p in platforms:
click.echo(f"{p['name']}|{p['image']}|{p['command']}") click.echo(f"{p['name']}|{p['image']}|{p['command']}")
return return
pairs_mr = build_multi_role_pairs(role_scenarios) pairs_mr = build_multi_role_pairs(role_scenarios, platforms)
if runner_index is None: if runner_index is None:
groups = distribute_multi_role(pairs_mr, max_runners) groups = distribute_multi_role(pairs_mr, max_runners)
for i, group in enumerate(groups): for i, group in enumerate(groups):
@@ -291,10 +300,10 @@ def cli(
click.echo(s) click.echo(s)
return return
if list_platforms: if list_platforms:
for p in PLATFORMS: for p in platforms:
click.echo(f"{p['name']}|{p['image']}|{p['command']}") click.echo(f"{p['name']}|{p['image']}|{p['command']}")
return return
pairs = build_pairs(scenarios) pairs = build_pairs(scenarios, platforms)
if runner_index is None: if runner_index is None:
groups = distribute(pairs, max_runners) groups = distribute(pairs, max_runners)
for i, group in enumerate(groups): for i, group in enumerate(groups):
+21 -2
View File
@@ -114,12 +114,14 @@ def parse_pair(pair: str) -> tuple[str, str, str, str, str]:
Supports both 4-part (single-role) and 5-part (multi-role) formats. Supports both 4-part (single-role) and 5-part (multi-role) formats.
For 4-part pairs, role is empty (caller uses default role dir). 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("|") parts = pair.split("|")
if len(parts) == 4: if len(parts) == 4:
return "", parts[0], parts[1], parts[2], parts[3] return "", parts[0], parts[1], parts[2], parts[3].replace("__SPACE__", " ")
if len(parts) == 5: if len(parts) == 5:
return parts[0], parts[1], parts[2], parts[3], parts[4] 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)") raise click.ClickException(f"Invalid pair format: {pair!r} (expected 4 or 5 pipe-delimited parts)")
@@ -134,6 +136,12 @@ def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]:
elif "MOLECULE_PLATFORM_COMMAND" in env: elif "MOLECULE_PLATFORM_COMMAND" in env:
del env["MOLECULE_PLATFORM_COMMAND"] del env["MOLECULE_PLATFORM_COMMAND"]
env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true" 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 return env
@@ -313,6 +321,17 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
click.echo(_("PASSED: {pair}", pair=pair)) click.echo(_("PASSED: {pair}", pair=pair))
# Prune Docker data between scenarios to prevent disk exhaustion
# in Docker-in-Docker molecule containers (each scenario pulls
# hundreds of MB of images that accumulate across pairs).
with contextlib.suppress(subprocess.SubprocessError, OSError):
subprocess.run( # nosec B603, B607
["docker", "system", "prune", "-af", "--volumes"],
check=False,
capture_output=True,
timeout=60,
)
click.echo(_("All molecule tests passed.")) click.echo(_("All molecule tests passed."))
if junit_output: if junit_output:
write_junit_report(junit_output, testcases, current_index) write_junit_report(junit_output, testcases, current_index)
+33 -7
View File
@@ -10,13 +10,39 @@ dev tools and CI scripts.
from __future__ import annotations 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). #: Each entry maps a short name to (image, command).
#: The command must be systemd since rootless Docker requires #: Uses the project's pre-built molecule-test-base image with
#: loginctl/systemctl --user. #: ``sleep infinity`` (NOT systemd) to avoid cgroup v2 failures.
PLATFORMS: list[dict[str, str]] = [ 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": "ubuntu-2604",
{"name": "debian-12", "image": "geerlingguy/docker-debian12-ansible:latest", "command": "/lib/systemd/systemd"}, "image": "git.oblachno.oblachno.fyi/oblachno/molecule-test-base:latest",
{"name": "archlinux", "image": "marcstraube/archlinux-ansible:latest", "command": "/usr/lib/systemd/systemd"}, "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
+202
View File
@@ -0,0 +1,202 @@
#!/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 glob
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."""
docker_host = os.environ.get("DOCKER_HOST", f"unix://{DOCKER_SOCK}")
result = subprocess.run( # nosec B603 B607
["docker", "info"],
capture_output=True,
check=False,
env={**os.environ, "DOCKER_HOST": docker_host},
)
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
# Scan for any rootless sockets at other UIDs
for sock in sorted(glob.glob("/run/user/*/docker.sock")):
if sock == ROOTLESS_SOCK:
continue
click.echo(f"Trying alternative rootless socket: {sock}")
os.environ["DOCKER_HOST"] = f"unix://{sock}"
if is_docker_ready():
click.echo(_("Docker daemon already running"))
return True
click.echo(_("Host Docker not available, starting local dockerd..."))
# Reset DOCKER_HOST to host socket for local dockerd
os.environ["DOCKER_HOST"] = f"unix://{DOCKER_SOCK}"
# 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()
+95 -11
View File
@@ -1,12 +1,21 @@
#!/usr/bin/env python3 #!/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: 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 from __future__ import annotations
import os
import re import re
import subprocess # nosec B404 import subprocess # nosec B404
@@ -14,18 +23,32 @@ import click
from devx.i18n import _ 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"] 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") _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]: 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 result = subprocess.run( # nosec B603
TEST_COMMAND, TEST_COMMAND,
capture_output=True, capture_output=True,
text=True, text=True,
check=False, check=False,
env=env,
) )
return result.stdout, result.stderr 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.")) 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: 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: if duration > max_seconds:
raise click.ClickException( raise click.ClickException(
_( _(
@@ -57,19 +95,58 @@ def check_speed(duration: float, max_seconds: float) -> None:
) )
def main(max_seconds: float) -> None: def check_per_test_speed(
"""Run tests, parse timing, and enforce the budget.""" 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() stdout, stderr = run_tests()
combined = stdout + "\n" + stderr combined = stdout + "\n" + stderr
click.echo(combined, err=False) click.echo(combined, err=False)
duration = parse_duration(combined) duration = parse_duration(combined)
check_speed(duration, max_seconds) 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( 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, duration=duration,
max=max_seconds, max=max_seconds,
single=max_single_seconds,
) )
) )
@@ -80,10 +157,17 @@ def main(max_seconds: float) -> None:
type=float, type=float,
default=DEFAULT_MAX_SECONDS, default=DEFAULT_MAX_SECONDS,
show_default=True, show_default=True,
help="Maximum allowed execution time in seconds.", help="Maximum allowed total execution time in seconds.",
) )
def cli(max_seconds: float) -> None: @click.option(
main(max_seconds) "--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 if __name__ == "__main__": # pragma: no cover
+13
View File
@@ -155,6 +155,19 @@ def main(repo: str | None, owner: str | None, branch: str, api_url: str | None)
if not repo: if not repo:
raise click.ClickException(_("ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.")) raise click.ClickException(_("ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME."))
# If DEVX_REPO_NAME contains a slash (e.g. "oblachno/infra"), split into owner/repo.
# This prevents 404s when workflows set DEVX_REPO_NAME to the full path.
if "/" in repo and owner is None:
parts = repo.split("/", 1)
owner, repo = parts[0], parts[1]
click.echo(
_(
"Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME",
owner=owner,
repo=repo,
)
)
if owner is None: if owner is None:
owner = REPO_OWNER owner = REPO_OWNER
+18 -3
View File
@@ -26,9 +26,24 @@ def _run(cmd: list[str]) -> None:
def _install_python_deps(bin_dir: str, extras: str = "dev") -> 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") 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: 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: def _install_ansible_collections(bin_dir: str) -> None:
"""Install required Ansible Galaxy collections if requirements exist.""" """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") requirements = Path("ansible/requirements.yml")
if not requirements.exists(): if not requirements.exists():
click.echo(" ansible/requirements.yml not found — skipping collections.") click.echo(" ansible/requirements.yml not found — skipping collections.")
+1260 -1197
View File
@@ -1,1199 +1,1262 @@
{ {
"\n=== Summary ===": { "\n=== Summary ===": {
"en": "\n=== Summary ===", "bg": "\n=== Summary ===",
"bg": "\n=== Summary ===", "de": "\n=== Summary ===",
"de": "\n=== Summary ===", "en": "\n=== Summary ===",
"ru": "\n=== Summary ===", "ru": "\n=== Summary ===",
"zh": "\n=== Summary ===" "zh": "\n=== Summary ==="
}, },
"\nAll documentation coverage checks passed!": { "\nAll documentation coverage checks passed!": {
"en": "\nAll documentation coverage checks passed!", "bg": "\nAll documentation coverage checks passed!",
"bg": "\nAll documentation coverage checks passed!", "de": "\nAll documentation coverage checks passed!",
"de": "\nAll documentation coverage checks passed!", "en": "\nAll documentation coverage checks passed!",
"ru": "\nAll documentation coverage checks passed!", "ru": "\nAll documentation coverage checks passed!",
"zh": "\nAll documentation coverage checks passed!" "zh": "\nAll documentation coverage checks passed!"
}, },
"\nCHANGELOG version ordering:": { "\nCHANGELOG version ordering:": {
"en": "\nCHANGELOG version ordering:", "bg": "\nCHANGELOG version ordering:",
"bg": "\nCHANGELOG version ordering:", "de": "\nCHANGELOG version ordering:",
"de": "\nCHANGELOG version ordering:", "en": "\nCHANGELOG version ordering:",
"ru": "\nCHANGELOG version ordering:", "ru": "\nCHANGELOG version ordering:",
"zh": "\nCHANGELOG version ordering:" "zh": "\nCHANGELOG version ordering:"
}, },
"\nChecking CI script documentation in ci-cd-workflow.md...": { "\nChecking CI script documentation in ci-cd-workflow.md...": {
"en": "\nChecking CI script documentation in ci-cd-workflow.md...", "bg": "\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...",
"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...", "ru": "\nChecking CI script documentation in ci-cd-workflow.md...",
"zh": "\nChecking CI script documentation in ci-cd-workflow.md..." "zh": "\nChecking CI script documentation in ci-cd-workflow.md..."
}, },
"\nChecking module documentation in architecture.md...": { "\nChecking module documentation in architecture.md...": {
"en": "\nChecking module documentation in architecture.md...", "bg": "\nChecking module documentation in architecture.md...",
"bg": "\nChecking module documentation in architecture.md...", "de": "\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...", "ru": "\nChecking module documentation in architecture.md...",
"zh": "\nChecking module documentation in architecture.md..." "zh": "\nChecking module documentation in architecture.md..."
}, },
"\nDoc coverage: {covered}/{total} ({pct}%)": { "\nDoc coverage: {covered}/{total} ({pct}%)": {
"en": "\nDoc coverage: {covered}/{total} ({pct}%)", "bg": "\nDoc coverage: {covered}/{total} ({pct}%)",
"bg": "\nDoc coverage: {covered}/{total} ({pct}%)", "de": "\nDoc coverage: {covered}/{total} ({pct}%)",
"de": "\nDoc coverage: {covered}/{total} ({pct}%)", "en": "\nDoc coverage: {covered}/{total} ({pct}%)",
"ru": "\nDoc coverage: {covered}/{total} ({pct}%)", "ru": "\nDoc coverage: {covered}/{total} ({pct}%)",
"zh": "\nDoc coverage: {covered}/{total} ({pct}%)" "zh": "\nDoc coverage: {covered}/{total} ({pct}%)"
}, },
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}": { "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}": {
"en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}", "bg": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"bg": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}", "de": "\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}", "ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}" "zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
}, },
"\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": { "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": {
"en": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.", "bg": "\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.",
"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.", "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." "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.": { "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.": {
"en": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.", "bg": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
"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.",
"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.", "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." "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):": { "\nIntegrity check FAILED ({count} issues):": {
"en": "\nIntegrity check FAILED ({count} issues):", "bg": "\nIntegrity check FAILED ({count} issues):",
"bg": "\nIntegrity check FAILED ({count} issues):", "de": "\nIntegrity check FAILED ({count} issues):",
"de": "\nIntegrity check FAILED ({count} issues):", "en": "\nIntegrity check FAILED ({count} issues):",
"ru": "\nIntegrity check FAILED ({count} issues):", "ru": "\nIntegrity check FAILED ({count} issues):",
"zh": "\nIntegrity check FAILED ({count} issues):" "zh": "\nIntegrity check FAILED ({count} issues):"
}, },
"\nIntegrity check passed — all {count} pages verified.": { "\nIntegrity check passed — all {count} pages verified.": {
"en": "\nIntegrity check passed — all {count} pages verified.", "bg": "\nIntegrity check passed — all {count} pages verified.",
"bg": "\nIntegrity check passed — all {count} pages verified.", "de": "\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.", "ru": "\nIntegrity check passed — all {count} pages verified.",
"zh": "\nIntegrity check passed — all {count} pages verified." "zh": "\nIntegrity check passed — all {count} pages verified."
}, },
"\nLatest tag: {tag}": { "\nLatest tag: {tag}": {
"en": "\nLatest tag: {tag}", "bg": "\nLatest tag: {tag}",
"bg": "\nLatest tag: {tag}", "de": "\nLatest tag: {tag}",
"de": "\nLatest tag: {tag}", "en": "\nLatest tag: {tag}",
"ru": "\nLatest tag: {tag}", "ru": "\nLatest tag: {tag}",
"zh": "\nLatest tag: {tag}" "zh": "\nLatest tag: {tag}"
}, },
"\nMissing documentation:": { "\nMissing documentation:": {
"en": "\nMissing documentation:", "bg": "\nMissing documentation:",
"bg": "\nMissing documentation:", "de": "\nMissing documentation:",
"de": "\nMissing documentation:", "en": "\nMissing documentation:",
"ru": "\nMissing documentation:", "ru": "\nMissing documentation:",
"zh": "\nMissing documentation:" "zh": "\nMissing documentation:"
}, },
"\nResult: {status}": { "\nResult: {status}": {
"en": "\nResult: {status}", "bg": "\nResult: {status}",
"bg": "\nResult: {status}", "de": "\nResult: {status}",
"de": "\nResult: {status}", "en": "\nResult: {status}",
"ru": "\nResult: {status}", "ru": "\nResult: {status}",
"zh": "\nResult: {status}" "zh": "\nResult: {status}"
}, },
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).": { "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).": {
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).", "bg": "\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).",
"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).", "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)." "zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
}, },
"\nRunning full wiki integrity check...": { "\nRunning full wiki integrity check...": {
"en": "\nRunning full wiki integrity check...", "bg": "\nRunning full wiki integrity check...",
"bg": "\nRunning full wiki integrity check...", "de": "\nRunning full wiki integrity check...",
"de": "\nRunning full wiki integrity check...", "en": "\nRunning full wiki integrity check...",
"ru": "\nRunning full wiki integrity check...", "ru": "\nRunning full wiki integrity check...",
"zh": "\nRunning full wiki integrity check..." "zh": "\nRunning full wiki integrity check..."
}, },
"\nTag → Commit alignment:": { "\nTag → Commit alignment:": {
"en": "\nTag → Commit alignment:", "bg": "\nTag → Commit alignment:",
"bg": "\nTag → Commit alignment:", "de": "\nTag → Commit alignment:",
"de": "\nTag → Commit alignment:", "en": "\nTag → Commit alignment:",
"ru": "\nTag → Commit alignment:", "ru": "\nTag → Commit alignment:",
"zh": "\nTag → Commit alignment:" "zh": "\nTag → Commit alignment:"
}, },
"\nUntagged release commits:": { "\nUntagged release commits:": {
"en": "\nUntagged release commits:", "bg": "\nUntagged release commits:",
"bg": "\nUntagged release commits:", "de": "\nUntagged release commits:",
"de": "\nUntagged release commits:", "en": "\nUntagged release commits:",
"ru": "\nUntagged release commits:", "ru": "\nUntagged release commits:",
"zh": "\nUntagged release commits:" "zh": "\nUntagged release commits:"
}, },
"\nUser-facing changes ({count}):": { "\nUser-facing changes ({count}):": {
"en": "\nUser-facing changes ({count}):", "bg": "\nUser-facing changes ({count}):",
"bg": "\nUser-facing changes ({count}):", "de": "\nUser-facing changes ({count}):",
"de": "\nUser-facing changes ({count}):", "en": "\nUser-facing changes ({count}):",
"ru": "\nUser-facing changes ({count}):", "ru": "\nUser-facing changes ({count}):",
"zh": "\nUser-facing changes ({count}):" "zh": "\nUser-facing changes ({count}):"
}, },
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!": { "\nVerification FAILED: {failures} page(s) have empty or mismatched content!": {
"en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!", "bg": "\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!",
"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!", "ru": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"zh": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!" "zh": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!"
}, },
"\nVerification passed — all wiki pages have correct content.": { "\nVerification passed — all wiki pages have correct content.": {
"en": "\nVerification passed — all wiki pages have correct content.", "bg": "\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.",
"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.", "ru": "\nVerification passed — all wiki pages have correct content.",
"zh": "\nVerification passed — all wiki pages have correct content." "zh": "\nVerification passed — all wiki pages have correct content."
}, },
"\nVerifying wiki pages have content...": { "\nVerifying wiki pages have content...": {
"en": "\nVerifying wiki pages have content...", "bg": "\nVerifying wiki pages have content...",
"bg": "\nVerifying wiki pages have content...", "de": "\nVerifying wiki pages have content...",
"de": "\nVerifying wiki pages have content...", "en": "\nVerifying wiki pages have content...",
"ru": "\nVerifying wiki pages have content...", "ru": "\nVerifying wiki pages have content...",
"zh": "\nVerifying wiki pages have content..." "zh": "\nVerifying wiki pages have content..."
}, },
"\nWorkflow-only changes ({count}):": { "\nWorkflow-only changes ({count}):": {
"en": "\nWorkflow-only changes ({count}):", "bg": "\nWorkflow-only changes ({count}):",
"bg": "\nWorkflow-only changes ({count}):", "de": "\nWorkflow-only changes ({count}):",
"de": "\nWorkflow-only changes ({count}):", "en": "\nWorkflow-only changes ({count}):",
"ru": "\nWorkflow-only changes ({count}):", "ru": "\nWorkflow-only changes ({count}):",
"zh": "\nWorkflow-only changes ({count}):" "zh": "\nWorkflow-only changes ({count}):"
}, },
"\n[dry-run] Changelog:\n{changelog}": { "\n[dry-run] Changelog:\n{changelog}": {
"en": "\n[dry-run] Changelog:\n{changelog}", "bg": "\n[dry-run] Changelog:\n{changelog}",
"bg": "\n[dry-run] Changelog:\n{changelog}", "de": "\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}", "ru": "\n[dry-run] Changelog:\n{changelog}",
"zh": "\n[dry-run] Changelog:\n{changelog}" "zh": "\n[dry-run] Changelog:\n{changelog}"
}, },
"\n{label} files changed ({count}):": { "\n{label} files changed ({count}):": {
"en": "\n{label} files changed ({count}):", "bg": "\n{label} files changed ({count}):",
"bg": "\n{label} files changed ({count}):", "de": "\n{label} files changed ({count}):",
"de": "\n{label} files changed ({count}):", "en": "\n{label} files changed ({count}):",
"ru": "\n{label} files changed ({count}):", "ru": "\n{label} files changed ({count}):",
"zh": "\n{label} files changed ({count}):" "zh": "\n{label} files changed ({count}):"
}, },
"\n{tag} files ({count}):": { "\n{tag} files ({count}):": {
"en": "\n{tag} files ({count}):", "bg": "\n{tag} files ({count}):",
"bg": "\n{tag} files ({count}):", "de": "\n{tag} files ({count}):",
"de": "\n{tag} files ({count}):", "en": "\n{tag} files ({count}):",
"ru": "\n{tag} files ({count}):", "ru": "\n{tag} files ({count}):",
"zh": "\n{tag} files ({count}):" "zh": "\n{tag} files ({count}):"
}, },
" - Auto-delete branch after merge: yes": { " - Auto-delete branch after merge: yes": {
"en": " - Auto-delete branch after merge: yes", "bg": " - Автоматично изтриване на клон след сливане: да",
"bg": " - Автоматично изтриване на клон след сливане: да", "de": " - Branch nach Merge automatisch löschen: ja",
"de": " - Branch nach Merge automatisch löschen: ja", "en": " - Auto-delete branch after merge: yes",
"ru": " - Автоудаление ветки после слияния: да", "ru": " - Автоудаление ветки после слияния: да",
"zh": " - 合并后自动删除分支: 是" "zh": " - 合并后自动删除分支: 是"
}, },
" - Block outdated branches: yes": { " - Block outdated branches: yes": {
"en": " - Block outdated branches: yes", "bg": " - Блокиране на остарели клонове: да",
"bg": " - Блокиране на остарели клонове: да", "de": " - Veraltete Branches blockieren: ja",
"de": " - Veraltete Branches blockieren: ja", "en": " - Block outdated branches: yes",
"ru": " - Блокировать устаревшие ветки: да", "ru": " - Блокировать устаревшие ветки: да",
"zh": " - 阻止过时分支: 是" "zh": " - 阻止过时分支: 是"
}, },
" - Block rejected reviews: yes": { " - Block rejected reviews: yes": {
"en": " - Block rejected reviews: yes", "bg": " - Блокиране на отхвърлени рецензии: да",
"bg": " - Блокиране на отхвърлени рецензии: да", "de": " - Abgelehnte Reviews blockieren: ja",
"de": " - Abgelehnte Reviews blockieren: ja", "en": " - Block rejected reviews: yes",
"ru": " - Блокировать отклонённые ревью: да", "ru": " - Блокировать отклонённые ревью: да",
"zh": " - 阻止被拒绝的审查: 是" "zh": " - 阻止被拒绝的审查: 是"
}, },
" - Direct pushes: BLOCKED (require PR, whitelisted users can push)": { " - Direct pushes: BLOCKED (require PR, whitelisted users can push)": {
"en": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)", "bg": " - 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)",
"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)", "ru": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
"zh": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)" "zh": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)"
}, },
" - Dismiss stale approvals: yes": { " - Dismiss stale approvals: yes": {
"en": " - Dismiss stale approvals: yes", "bg": " - Анулиране на остарели одобрения: да",
"bg": " - Анулиране на остарели одобрения: да", "de": " - Veraltete Genehmigungen ablehnen: ja",
"de": " - Veraltete Genehmigungen ablehnen: ja", "en": " - Dismiss stale approvals: yes",
"ru": " - Отклонять устаревшие одобрения: да", "ru": " - Отклонять устаревшие одобрения: да",
"zh": " - 忽略过时审批: 是" "zh": " - 忽略过时审批: 是"
}, },
" - Required approvals: {count}": { " - Required approvals: {count}": {
"en": " - Required approvals: {count}", "bg": " - Необходими одобрения: {count}",
"bg": " - Необходими одобрения: {count}", "de": " - Erforderliche Genehmigungen: {count}",
"de": " - Erforderliche Genehmigungen: {count}", "en": " - Required approvals: {count}",
"ru": " - Требуемые одобрения: {count}", "ru": " - Требуемые одобрения: {count}",
"zh": " - 必需审批数: {count}" "zh": " - 必需审批数: {count}"
}, },
" - Required status checks: {checks}": { " - Required status checks: {checks}": {
"en": " - Required status checks: {checks}", "bg": " - Необходими проверки на състоянието: {checks}",
"bg": " - Необходими проверки на състоянието: {checks}", "de": " - Erforderliche Status-Checks: {checks}",
"de": " - Erforderliche Status-Checks: {checks}", "en": " - Required status checks: {checks}",
"ru": " - Требуемые проверки статуса: {checks}", "ru": " - Требуемые проверки статуса: {checks}",
"zh": " - 必需状态检查: {checks}" "zh": " - 必需状态检查: {checks}"
}, },
" Created: {title}": { " Created: {title}": {
"en": " Created: {title}", "bg": " Created: {title}",
"bg": " Created: {title}", "de": " Created: {title}",
"de": " Created: {title}", "en": " Created: {title}",
"ru": " Created: {title}", "ru": " Created: {title}",
"zh": " Created: {title}" "zh": " Created: {title}"
}, },
" FAIL: {title} — content mismatch or empty!": { " FAIL: {title} — content mismatch or empty!": {
"en": " FAIL: {title} — content mismatch or empty!", "bg": " FAIL: {title} — content mismatch or empty!",
"bg": " FAIL: {title} — content mismatch or empty!", "de": " 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!", "ru": " FAIL: {title} — content mismatch or empty!",
"zh": " FAIL: {title} — content mismatch or empty!" "zh": " FAIL: {title} — content mismatch or empty!"
}, },
" MISSING: devx {cmd}": { " MISSING: devx {cmd}": {
"en": " MISSING: devx {cmd}", "bg": " ЛИПСВА: devx {cmd}",
"bg": " ЛИПСВА: devx {cmd}", "de": " FEHLT: devx {cmd}",
"de": " FEHLT: devx {cmd}", "en": " MISSING: devx {cmd}",
"ru": " ОТСУТСТВУЕТ: devx {cmd}", "ru": " ОТСУТСТВУЕТ: devx {cmd}",
"zh": " 缺失: devx {cmd}" "zh": " 缺失: devx {cmd}"
}, },
" MISSING: {module}": { " MISSING: {module}": {
"en": " MISSING: {module}", "bg": " MISSING: {module}",
"bg": " MISSING: {module}", "de": " MISSING: {module}",
"de": " MISSING: {module}", "en": " MISSING: {module}",
"ru": " MISSING: {module}", "ru": " MISSING: {module}",
"zh": " MISSING: {module}" "zh": " MISSING: {module}"
}, },
" MISSING: {script}": { " MISSING: {script}": {
"en": " MISSING: {script}", "bg": " MISSING: {script}",
"bg": " MISSING: {script}", "de": " MISSING: {script}",
"de": " MISSING: {script}", "en": " MISSING: {script}",
"ru": " MISSING: {script}", "ru": " MISSING: {script}",
"zh": " MISSING: {script}" "zh": " MISSING: {script}"
}, },
" OK: devx {cmd}": { " OK: devx {cmd}": {
"en": " OK: devx {cmd}", "bg": " ОК: devx {cmd}",
"bg": " ОК: devx {cmd}", "de": " OK: devx {cmd}",
"de": " OK: devx {cmd}", "en": " OK: devx {cmd}",
"ru": " ОК: devx {cmd}", "ru": " ОК: devx {cmd}",
"zh": " 正常: devx {cmd}" "zh": " 正常: devx {cmd}"
}, },
" OK: {module}": { " OK: {module}": {
"en": " OK: {module}", "bg": " OK: {module}",
"bg": " OK: {module}", "de": " OK: {module}",
"de": " OK: {module}", "en": " OK: {module}",
"ru": " OK: {module}", "ru": " OK: {module}",
"zh": " OK: {module}" "zh": " OK: {module}"
}, },
" OK: {script}": { " OK: {script}": {
"en": " OK: {script}", "bg": " OK: {script}",
"bg": " OK: {script}", "de": " OK: {script}",
"de": " OK: {script}", "en": " OK: {script}",
"ru": " OK: {script}", "ru": " OK: {script}",
"zh": " OK: {script}" "zh": " OK: {script}"
}, },
" OK: {title} ({chars} chars)": { " OK: {title} ({chars} chars)": {
"en": " OK: {title} ({chars} chars)", "bg": " OK: {title} ({chars} chars)",
"bg": " OK: {title} ({chars} chars)", "de": " OK: {title} ({chars} chars)",
"de": " OK: {title} ({chars} chars)", "en": " OK: {title} ({chars} chars)",
"ru": " OK: {title} ({chars} chars)", "ru": " OK: {title} ({chars} chars)",
"zh": " OK: {title} ({chars} chars)" "zh": " OK: {title} ({chars} chars)"
}, },
" Updated: {title}": { " Updated: {title}": {
"en": " Updated: {title}", "bg": " Updated: {title}",
"bg": " Updated: {title}", "de": " Updated: {title}",
"de": " Updated: {title}", "en": " Updated: {title}",
"ru": " Updated: {title}", "ru": " Updated: {title}",
"zh": " Updated: {title}" "zh": " Updated: {title}"
}, },
"=== Release Alignment Verification ===\n": { "--skip-build: skipping package build and PyPI publish.": {
"en": "=== Release Alignment Verification ===\n", "bg": "--skip-build: skipping package build and PyPI publish.",
"bg": "=== Release Alignment Verification ===\n", "de": "--skip-build: skipping package build and PyPI publish.",
"de": "=== Release Alignment Verification ===\n", "en": "--skip-build: skipping package build and PyPI publish.",
"ru": "=== Release Alignment Verification ===\n", "ru": "--skip-build: skipping package build and PyPI publish.",
"zh": "=== Release Alignment Verification ===\n" "zh": "--skip-build: skipping package build and PyPI publish."
}, },
"API poll warning: {exc}": { "=== Release Alignment Verification ===\n": {
"en": "API poll warning: {exc}", "bg": "=== Release Alignment Verification ===\n",
"bg": "API poll warning: {exc}", "de": "=== Release Alignment Verification ===\n",
"de": "API poll warning: {exc}", "en": "=== Release Alignment Verification ===\n",
"ru": "API poll warning: {exc}", "ru": "=== Release Alignment Verification ===\n",
"zh": "API poll warning: {exc}" "zh": "=== Release Alignment Verification ===\n"
}, },
"All molecule tests passed.": { "API poll warning: {exc}": {
"en": "All molecule tests passed.", "bg": "API poll warning: {exc}",
"bg": "All molecule tests passed.", "de": "API poll warning: {exc}",
"de": "All molecule tests passed.", "en": "API poll warning: {exc}",
"ru": "All molecule tests passed.", "ru": "API poll warning: {exc}",
"zh": "All molecule tests passed." "zh": "API poll warning: {exc}"
}, },
"Another molecule runner failed. Stopping this runner early.": { "All molecule tests passed.": {
"en": "Another molecule runner failed. Stopping this runner early.", "bg": "All molecule tests passed.",
"bg": "Another molecule runner failed. Stopping this runner early.", "de": "All molecule tests passed.",
"de": "Another molecule runner failed. Stopping this runner early.", "en": "All molecule tests passed.",
"ru": "Another molecule runner failed. Stopping this runner early.", "ru": "All molecule tests passed.",
"zh": "Another molecule runner failed. Stopping this runner early." "zh": "All molecule tests passed."
}, },
"Bumping version: {current} -> v{new_version}": { "Another molecule runner failed. Stopping this runner early.": {
"en": "Bumping version: {current} -> v{new_version}", "bg": "Another molecule runner failed. Stopping this runner early.",
"bg": "Bumping version: {current} -> v{new_version}", "de": "Another molecule runner failed. Stopping this runner early.",
"de": "Bumping version: {current} -> v{new_version}", "en": "Another molecule runner failed. Stopping this runner early.",
"ru": "Bumping version: {current} -> v{new_version}", "ru": "Another molecule runner failed. Stopping this runner early.",
"zh": "Bumping version: {current} -> v{new_version}" "zh": "Another molecule runner failed. Stopping this runner early."
}, },
"Checking CLI command documentation...": { "Bumping version: {current} -> v{new_version}": {
"en": "Checking CLI command documentation...", "bg": "Bumping version: {current} -> v{new_version}",
"bg": "Checking CLI command documentation...", "de": "Bumping version: {current} -> v{new_version}",
"de": "Checking CLI command documentation...", "en": "Bumping version: {current} -> v{new_version}",
"ru": "Checking CLI command documentation...", "ru": "Bumping version: {current} -> v{new_version}",
"zh": "Checking CLI command documentation..." "zh": "Bumping version: {current} -> v{new_version}"
}, },
"Command failed ({cmd}): {stderr}": { "Checking CLI command documentation...": {
"en": "Command failed ({cmd}): {stderr}", "bg": "Checking CLI command documentation...",
"bg": "Command failed ({cmd}): {stderr}", "de": "Checking CLI command documentation...",
"de": "Command failed ({cmd}): {stderr}", "en": "Checking CLI command documentation...",
"ru": "Command failed ({cmd}): {stderr}", "ru": "Checking CLI command documentation...",
"zh": "Command failed ({cmd}): {stderr}" "zh": "Checking CLI command documentation..."
}, },
"Comparing {base}..{head} ({count} files changed)": { "Command failed ({cmd}): {stderr}": {
"en": "Comparing {base}..{head} ({count} files changed)", "bg": "Command failed ({cmd}): {stderr}",
"bg": "Comparing {base}..{head} ({count} files changed)", "de": "Command failed ({cmd}): {stderr}",
"de": "Comparing {base}..{head} ({count} files changed)", "en": "Command failed ({cmd}): {stderr}",
"ru": "Comparing {base}..{head} ({count} files changed)", "ru": "Command failed ({cmd}): {stderr}",
"zh": "Comparing {base}..{head} ({count} files changed)" "zh": "Command failed ({cmd}): {stderr}"
}, },
"Configuring branch protection for {branch}...": { "Comparing {base}..{head} ({count} files changed)": {
"en": "Configuring branch protection for {branch}...", "bg": "Comparing {base}..{head} ({count} files changed)",
"bg": "Конфигуриране на защита на клона {branch}...", "de": "Comparing {base}..{head} ({count} files changed)",
"de": "Konfiguriere Branch-Schutz für {branch}...", "en": "Comparing {base}..{head} ({count} files changed)",
"ru": "Настройка защиты ветки {branch}...", "ru": "Comparing {base}..{head} ({count} files changed)",
"zh": "正在配置 {branch} 的分支保护..." "zh": "Comparing {base}..{head} ({count} files changed)"
}, },
"Configuring repository settings...": { "Configuring branch protection for {branch}...": {
"en": "Configuring repository settings...", "bg": "Конфигуриране на защита на клона {branch}...",
"bg": "Конфигуриране на настройките на хранилището...", "de": "Konfiguriere Branch-Schutz für {branch}...",
"de": "Repository-Einstellungen konfigurieren...", "en": "Configuring branch protection for {branch}...",
"ru": "Настройка параметров репозитория...", "ru": "Настройка защиты ветки {branch}...",
"zh": "正在配置仓库设置..." "zh": "正在配置 {branch} 的分支保护..."
}, },
"Could not extract conventional commit message from PR commits.": { "Configuring repository settings...": {
"en": "Could not extract conventional commit message from PR commits.", "bg": "Конфигуриране на настройките на хранилището...",
"bg": "Could not extract conventional commit message from PR commits.", "de": "Repository-Einstellungen konfigurieren...",
"de": "Could not extract conventional commit message from PR commits.", "en": "Configuring repository settings...",
"ru": "Could not extract conventional commit message from PR commits.", "ru": "Настройка параметров репозитория...",
"zh": "Could not extract conventional commit message from PR commits." "zh": "正在配置仓库设置..."
}, },
"Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": { "Could not extract conventional commit message from PR commits.": {
"en": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.", "bg": "Could not extract conventional commit message from PR commits.",
"bg": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.", "de": "Could not extract conventional commit message from PR commits.",
"de": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.", "en": "Could not extract conventional commit message from PR commits.",
"ru": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.", "ru": "Could not extract conventional commit message from PR commits.",
"zh": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task." "zh": "Could not extract conventional commit message from PR commits."
}, },
"Could not find __version__ in {file}": { "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": {
"en": "Could not find __version__ in {file}", "bg": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"bg": "Could not find __version__ in {file}", "de": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"de": "Could not find __version__ in {file}", "en": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"ru": "Could not find __version__ in {file}", "ru": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"zh": "Could not find __version__ in {file}" "zh": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task."
}, },
"Could not parse test execution time from output.": { "Could not find __version__ in {file}": {
"en": "Could not parse test execution time from output.", "bg": "Could not find __version__ in {file}",
"bg": "Could not parse test execution time from output.", "de": "Could not find __version__ in {file}",
"de": "Could not parse test execution time from output.", "en": "Could not find __version__ in {file}",
"ru": "Could not parse test execution time from output.", "ru": "Could not find __version__ in {file}",
"zh": "Could not parse test execution time from output." "zh": "Could not find __version__ in {file}"
}, },
"Created issue #{issue_id}: {title}": { "Could not parse test execution time from output.": {
"en": "Created issue #{issue_id}: {title}", "bg": "Could not parse test execution time from output.",
"bg": "Created issue #{issue_id}: {title}", "de": "Could not parse test execution time from output.",
"de": "Created issue #{issue_id}: {title}", "en": "Could not parse test execution time from output.",
"ru": "Created issue #{issue_id}: {title}", "ru": "Could not parse test execution time from output.",
"zh": "Created issue #{issue_id}: {title}" "zh": "Could not parse test execution time from output."
}, },
"Created release commit.": { "Created issue #{issue_id}: {title}": {
"en": "Created release commit.", "bg": "Created issue #{issue_id}: {title}",
"bg": "Created release commit.", "de": "Created issue #{issue_id}: {title}",
"de": "Created release commit.", "en": "Created issue #{issue_id}: {title}",
"ru": "Created release commit.", "ru": "Created issue #{issue_id}: {title}",
"zh": "Created release commit." "zh": "Created issue #{issue_id}: {title}"
}, },
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": { "Created release commit.": {
"en": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", "bg": "Created release commit.",
"bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", "de": "Created release commit.",
"de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", "en": "Created release commit.",
"ru": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", "ru": "Created release commit.",
"zh": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently." "zh": "Created release commit."
}, },
"ERROR: REPO_TOKEN is not set.": { "Docker daemon already running": {
"en": "ERROR: REPO_TOKEN is not set.", "bg": "Докер демонът вече работи",
"bg": "ГРЕШКА: REPO_TOKEN не е зададен.", "de": "Docker-Daemon läuft bereits",
"de": "FEHLER: REPO_TOKEN ist nicht gesetzt.", "en": "Docker daemon already running",
"ru": "ОШИБКА: REPO_TOKEN не задан.", "ru": "Демон Docker уже работает",
"zh": "错误:未设置 REPO_TOKEN。" "zh": "Docker 守护进程已在运行"
}, },
"ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.": { "Docker daemon failed to start": {
"en": "ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.", "bg": "Docker daemon failed to start",
"bg": "ГРЕШКА: Името на хранилището не е указано. Използвайте --repo или задайте DEVX_REPO_NAME.", "de": "Docker-Daemon konnte nicht gestartet werden",
"de": "FEHLER: Repository-Name nicht angegeben. Verwenden Sie --repo oder setzen Sie DEVX_REPO_NAME.", "en": "Docker daemon failed to start",
"ru": "ОШИБКА: Имя репозитория не указано. Используйте --repo или задайте DEVX_REPO_NAME.", "ru": "Не удалось запустить Docker-демон",
"zh": "错误:未指定仓库名称。请使用 --repo 或设置 DEVX_REPO_NAME。" "zh": "Docker 守护进程启动失败"
}, },
"ERROR: Tag consistency check failed. Existing tags are misaligned:": { "Docker daemon started": {
"en": "ERROR: Tag consistency check failed. Existing tags are misaligned:", "bg": "Docker daemon started",
"bg": "ERROR: Tag consistency check failed. Existing tags are misaligned:", "de": "Docker-Daemon gestartet",
"de": "ERROR: Tag consistency check failed. Existing tags are misaligned:", "en": "Docker daemon started",
"ru": "ERROR: Tag consistency check failed. Existing tags are misaligned:", "ru": "Docker-демон запущен",
"zh": "ERROR: Tag consistency check failed. Existing tags are misaligned:" "zh": "Docker 守护进程已启动"
}, },
"ERROR: VIKUNJA_TOKEN is not set.": { "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": {
"en": "ERROR: VIKUNJA_TOKEN is not set.", "bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"bg": "ГРЕШКА: VIKUNJA_TOKEN не е зададен.", "de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"de": "FEHLER: VIKUNJA_TOKEN ist nicht gesetzt.", "en": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"ru": "ОШИБКА: VIKUNJA_TOKEN не задан.", "ru": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"zh": "错误:未设置 VIKUNJA_TOKEN。" "zh": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently."
}, },
"ERROR: mapping.json not found at {path}": { "ERROR: REPO_TOKEN is not set.": {
"en": "ERROR: mapping.json not found at {path}", "bg": "ГРЕШКА: REPO_TOKEN не е зададен.",
"bg": "ERROR: mapping.json not found at {path}", "de": "FEHLER: REPO_TOKEN ist nicht gesetzt.",
"de": "ERROR: mapping.json not found at {path}", "en": "ERROR: REPO_TOKEN is not set.",
"ru": "ERROR: mapping.json not found at {path}", "ru": "ОШИБКА: REPO_TOKEN не задан.",
"zh": "ERROR: mapping.json not found at {path}" "zh": "错误:未设置 REPO_TOKEN。"
}, },
"FAILED: {pair} exited with code {code}": { "ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.": {
"en": "FAILED: {pair} exited with code {code}", "bg": "ГРЕШКА: Името на хранилището не е указано. Използвайте --repo или задайте DEVX_REPO_NAME.",
"bg": "FAILED: {pair} exited with code {code}", "de": "FEHLER: Repository-Name nicht angegeben. Verwenden Sie --repo oder setzen Sie DEVX_REPO_NAME.",
"de": "FAILED: {pair} exited with code {code}", "en": "ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.",
"ru": "FAILED: {pair} exited with code {code}", "ru": "ОШИБКА: Имя репозитория не указано. Используйте --repo или задайте DEVX_REPO_NAME.",
"zh": "FAILED: {pair} exited with code {code}" "zh": "错误:未指定仓库名称。请使用 --repo 或设置 DEVX_REPO_NAME。"
}, },
"Failed to create issue via tea: {error}": { "ERROR: Tag consistency check failed. Existing tags are misaligned:": {
"en": "Failed to create issue via tea: {error}", "bg": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
"bg": "Failed to create issue via tea: {error}", "de": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
"de": "Failed to create issue via tea: {error}", "en": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
"ru": "Failed to create issue via tea: {error}", "ru": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
"zh": "Failed to create issue via tea: {error}" "zh": "ERROR: Tag consistency check failed. Existing tags are misaligned:"
}, },
"Found {count} existing wiki pages.": { "ERROR: VIKUNJA_TOKEN is not set.": {
"en": "Found {count} existing wiki pages.", "bg": "ГРЕШКА: VIKUNJA_TOKEN не е зададен.",
"bg": "Found {count} existing wiki pages.", "de": "FEHLER: VIKUNJA_TOKEN ist nicht gesetzt.",
"de": "Found {count} existing wiki pages.", "en": "ERROR: VIKUNJA_TOKEN is not set.",
"ru": "Found {count} existing wiki pages.", "ru": "ОШИБКА: VIKUNJA_TOKEN не задан.",
"zh": "Found {count} existing wiki pages." "zh": "错误:未设置 VIKUNJA_TOKEN。"
}, },
"GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": { "ERROR: mapping.json not found at {path}": {
"en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "bg": "ERROR: mapping.json not found at {path}",
"bg": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "de": "ERROR: mapping.json not found at {path}",
"de": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "en": "ERROR: mapping.json not found at {path}",
"ru": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "ru": "ERROR: mapping.json not found at {path}",
"zh": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation." "zh": "ERROR: mapping.json not found at {path}"
}, },
"Generated {file} with prefix '{prefix}'.": { "FAILED: {pair} exited with code {code}": {
"en": "Generated {file} with prefix '{prefix}'.", "bg": "FAILED: {pair} exited with code {code}",
"bg": "Generated {file} with prefix '{prefix}'.", "de": "FAILED: {pair} exited with code {code}",
"de": "Generated {file} with prefix '{prefix}'.", "en": "FAILED: {pair} exited with code {code}",
"ru": "Generated {file} with prefix '{prefix}'.", "ru": "FAILED: {pair} exited with code {code}",
"zh": "Generated {file} with prefix '{prefix}'." "zh": "FAILED: {pair} exited with code {code}"
}, },
"HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.": { "Failed to create issue via tea: {error}": {
"en": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", "bg": "Failed to create issue via tea: {error}",
"bg": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", "de": "Failed to create issue via tea: {error}",
"de": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", "en": "Failed to create issue via tea: {error}",
"ru": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.", "ru": "Failed to create issue via tea: {error}",
"zh": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag." "zh": "Failed to create issue via tea: {error}"
}, },
"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.": { "Found {count} existing wiki pages.": {
"en": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.", "bg": "Found {count} existing wiki pages.",
"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": "Found {count} existing wiki pages.",
"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": "Found {count} existing wiki pages.",
"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.", "ru": "Found {count} existing wiki pages.",
"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." "zh": "Found {count} existing wiki pages."
}, },
"HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.": { "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
"en": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.", "bg": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"bg": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.", "de": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"de": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.", "en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"ru": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.", "ru": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"zh": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping." "zh": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."
}, },
"HTTP error: {status} — {message}": { "Generated {file} with prefix '{prefix}'.": {
"en": "HTTP error: {status} — {message}", "bg": "Generated {file} with prefix '{prefix}'.",
"bg": "HTTP грешка: {status} — {message}", "de": "Generated {file} with prefix '{prefix}'.",
"de": "HTTP-Fehler: {status} — {message}", "en": "Generated {file} with prefix '{prefix}'.",
"ru": "Ошибка HTTP: {status} — {message}", "ru": "Generated {file} with prefix '{prefix}'.",
"zh": "HTTP 错误: {status} — {message}" "zh": "Generated {file} with prefix '{prefix}'."
}, },
"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.": { "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.": {
"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": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
"bg": "HTTP {status} Забранено — вашият токен няма администраторски права.\nУверете се, че токенът принадлежи на собственик на хранилище или администратор на организация.\nАлтернативно, конфигурирайте защитата на клона ръчно в Настройки → Клонове.", "de": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
"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": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
"ru": "HTTP {status} Запрещено — у вашего токена нет прав администратора.\nУбедитесь, что токен принадлежит владельцу репозитория или администратору организации.\nЛибо настройте защиту ветки вручную в разделе Настройки → Ветки.", "ru": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
"zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。" "zh": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag."
}, },
"Head branch is behind master. Pulling and rebasing...": { "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 branch is behind master. Pulling and rebasing...", "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.",
"bg": "Head branch is behind master. Pulling and rebasing...", "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.",
"de": "Head branch is behind master. Pulling and rebasing...", "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 branch is behind master. Pulling and rebasing...", "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 branch is behind master. Pulling and rebasing..." "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."
}, },
"Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": { "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.": {
"en": "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}", "bg": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
"bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}", "de": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
"de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}", "en": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
"ru": "Инфраструктурный коммит (без ID задачи DEVX-N), пропуск обновления Vikunja: {msg}", "ru": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
"zh": "基础设施提交(无 DEVX-N 任务 ID),跳过 Vikunja 更新: {msg}" "zh": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping."
}, },
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}": { "HTTP error: {status} — {message}": {
"en": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}", "bg": "HTTP грешка: {status} — {message}",
"bg": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}", "de": "HTTP-Fehler: {status} — {message}",
"de": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}", "en": "HTTP error: {status} — {message}",
"ru": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}", "ru": "Ошибка HTTP: {status} — {message}",
"zh": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}" "zh": "HTTP 错误: {status} — {message}"
}, },
"Lint passed.": { "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": "Lint passed.", "bg": "HTTP {status} Забранено — вашият токен няма администраторски права.\nУверете се, че токенът принадлежи на собственик на хранилище или администратор на организация.\nАлтернативно, конфигурирайте защитата на клона ръчно в Настройки → Клонове.",
"bg": "Lint passed.", "de": "HTTP {status} Verboten — Ihr Token hat keine Admin-Rechte.\nStellen Sie sicher, dass das Token einem Repository-Besitzer oder Organisations-Admin gehört.\nAlternativ können Sie den Branch-Schutz manuell unter Einstellungen → Branches konfigurieren.",
"de": "Lint passed.", "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": "Lint passed.", "ru": "HTTP {status} Запрещено — у вашего токена нет прав администратора.\nУбедитесь, что токен принадлежит владельцу репозитория или администратору организации.\nЛибо настройте защиту ветки вручную в разделе Настройки → Ветки.",
"zh": "Lint passed." "zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。"
}, },
"Mapped file {file} is empty. Update the content or remove from mapping.json.": { "Head branch is behind master. Pulling and rebasing...": {
"en": "Mapped file {file} is empty. Update the content or remove from mapping.json.", "bg": "Head branch is behind master. Pulling and rebasing...",
"bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.", "de": "Head branch is behind master. Pulling and rebasing...",
"de": "Mapped file {file} is empty. Update the content or remove from mapping.json.", "en": "Head branch is behind master. Pulling and rebasing...",
"ru": "Mapped file {file} is empty. Update the content or remove from mapping.json.", "ru": "Head branch is behind master. Pulling and rebasing...",
"zh": "Mapped file {file} is empty. Update the content or remove from mapping.json." "zh": "Head branch is behind master. Pulling and rebasing..."
}, },
"Mapped file {file} not found. Update mapping.json or create the file.": { "Host Docker not available, starting local dockerd...": {
"en": "Mapped file {file} not found. Update mapping.json or create the file.", "bg": "Хост Docker не е наличен, стартиране на локален dockerd...",
"bg": "Mapped file {file} not found. Update mapping.json or create the file.", "de": "Host-Docker nicht verfügbar, lokaler dockerd wird gestartet...",
"de": "Mapped file {file} not found. Update mapping.json or create the file.", "en": "Host Docker not available, starting local dockerd...",
"ru": "Mapped file {file} not found. Update mapping.json or create the file.", "ru": "Хост Docker недоступен, запускается локальный dockerd...",
"zh": "Mapped file {file} not found. Update mapping.json or create the file." "zh": "主机 Docker 不可用,正在启动本地 dockerd..."
}, },
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.": { "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": {
"en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.", "bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}",
"bg": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.", "de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}",
"de": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.", "en": "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}",
"ru": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.", "ru": "Инфраструктурный коммит (без ID задачи DEVX-N), пропуск обновления Vikunja: {msg}",
"zh": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually." "zh": "基础设施提交(无 DEVX-N 任务 ID),跳过 Vikunja 更新: {msg}"
}, },
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": { "Integration tests cancelled — another runner failed.": {
"en": "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.", "bg": "Integration tests cancelled — another runner failed.",
"bg": "Сливането неуспешно с HTTP {status}: {message}\nПроверете дали PR е готов и имате права за сливане.", "de": "Integration tests cancelled — another runner failed.",
"de": "Merge fehlgeschlagen mit HTTP {status}: {message}\nBitte prüfen Sie, ob der PR bereit ist und Sie Merge-Rechte haben.", "en": "Integration tests cancelled — another runner failed.",
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.", "ru": "Integration tests cancelled — another runner failed.",
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。" "zh": "Integration tests cancelled — another runner failed."
}, },
"Module {mod} has no main() function": { "Integration tests failed with exit code {code}": {
"en": "Module {mod} has no main() function", "bg": "Integration tests failed with exit code {code}",
"bg": "Модул {mod} няма функция main()", "de": "Integration tests failed with exit code {code}",
"de": "Modul {mod} hat keine main()-Funktion", "en": "Integration tests failed with exit code {code}",
"ru": "Модуль {mod} не имеет функции main()", "ru": "Integration tests failed with exit code {code}",
"zh": "模块 {mod} 没有 main() 函数" "zh": "Integration tests failed with exit code {code}"
}, },
"Molecule directory not found: {path}": { "Integration tests passed.": {
"en": "Molecule directory not found: {path}", "bg": "Integration tests passed.",
"bg": "Директорията на molecule не е намерена: {path}", "de": "Integration tests passed.",
"de": "Molecule-Verzeichnis nicht gefunden: {path}", "en": "Integration tests passed.",
"ru": "Директория molecule не найдена: {path}", "ru": "Integration tests passed.",
"zh": "未找到 molecule 目录: {path}" "zh": "Integration tests passed."
}, },
"Nice! Gitea release {tag} created.": { "Lint failed — refusing to release. Fix lint errors first.\n{stderr}": {
"en": "Nice! Gitea release {tag} created.", "bg": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"bg": "Отлично! Gitea release {tag} е създаден.", "de": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"de": "Prima! Gitea-Release {tag} erstellt.", "en": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"ru": "Отлично! Gitea release {tag} создан.", "ru": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"zh": "不错!Gitea release {tag} 已创建。" "zh": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}"
}, },
"Nice! PR #{pr_number} squash-merged with title: {merge_title}": { "Lint passed.": {
"en": "Nice! PR #{pr_number} squash-merged with title: {merge_title}", "bg": "Lint passed.",
"bg": "Отлично! PR #{pr_number} е squash-merge-нат със заглавие: {merge_title}", "de": "Lint passed.",
"de": "Prima! PR #{pr_number} wurde mit Titel {merge_title} squash-gemergt.", "en": "Lint passed.",
"ru": "Отлично! PR #{pr_number} squash-merge с заголовком: {merge_title}", "ru": "Lint passed.",
"zh": "不错!PR #{pr_number} 已 squash 合并,标题: {merge_title}" "zh": "Lint passed."
}, },
"Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.": { "Mapped file {file} is empty. Update the content or remove from mapping.json.": {
"en": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.", "bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"bg": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.", "de": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"de": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.", "en": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"ru": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.", "ru": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"zh": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered." "zh": "Mapped file {file} is empty. Update the content or remove from mapping.json."
}, },
"Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.": { "Mapped file {file} not found. Update mapping.json or create the file.": {
"en": "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.", "bg": "Mapped file {file} not found. Update mapping.json or create the file.",
"bg": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) е обновена и маркирана като готова.", "de": "Mapped file {file} not found. Update mapping.json or create the file.",
"de": "Prima! Vikunja-Aufgabe {task_id} (ID {vikunja_id}) aktualisiert und als erledigt markiert.", "en": "Mapped file {file} not found. Update mapping.json or create the file.",
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.", "ru": "Mapped file {file} not found. Update mapping.json or create the file.",
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。" "zh": "Mapped file {file} not found. Update mapping.json or create the file."
}, },
"No changes between {base} and {head}.": { "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.": {
"en": "No changes between {base} and {head}.", "bg": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"bg": "No changes between {base} and {head}.", "de": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"de": "No changes between {base} and {head}.", "en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"ru": "No changes between {base} and {head}.", "ru": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"zh": "No changes between {base} and {head}." "zh": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually."
}, },
"No staged changes — version and changelog already up to date.": { "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": {
"en": "No staged changes — version and changelog already up to date.", "bg": "Сливането неуспешно с HTTP {status}: {message}\nПроверете дали PR е готов и имате права за сливане.",
"bg": "No staged changes — version and changelog already up to date.", "de": "Merge fehlgeschlagen mit HTTP {status}: {message}\nBitte prüfen Sie, ob der PR bereit ist und Sie Merge-Rechte haben.",
"de": "No staged changes — version and changelog already up to date.", "en": "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.",
"ru": "No staged changes — version and changelog already up to date.", "ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
"zh": "No staged changes — version and changelog already up to date." "zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
}, },
"No tags found — treating all changes as user-facing.": { "Merged {count} reports: {tests} tests, {failures} failures → {output}": {
"en": "No tags found — treating all changes as user-facing.", "bg": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
"bg": "No tags found — treating all changes as user-facing.", "de": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
"de": "No tags found — treating all changes as user-facing.", "en": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
"ru": "No tags found — treating all changes as user-facing.", "ru": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
"zh": "No tags found — treating all changes as user-facing." "zh": "Merged {count} reports: {tests} tests, {failures} failures → {output}"
}, },
"No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.": { "Module {mod} has no main() function": {
"en": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", "bg": "Модул {mod} няма функция main()",
"bg": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", "de": "Modul {mod} hat keine main()-Funktion",
"de": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", "en": "Module {mod} has no main() function",
"ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", "ru": "Модуль {mod} не имеет функции main()",
"zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID." "zh": "模块 {mod} 没有 main() 函数"
}, },
"No unreleased changes found. Nothing to release.": { "Molecule directory not found: {path}": {
"en": "No unreleased changes found. Nothing to release.", "bg": "Директорията на molecule не е намерена: {path}",
"bg": "No unreleased changes found. Nothing to release.", "de": "Molecule-Verzeichnis nicht gefunden: {path}",
"de": "No unreleased changes found. Nothing to release.", "en": "Molecule directory not found: {path}",
"ru": "No unreleased changes found. Nothing to release.", "ru": "Директория molecule не найдена: {path}",
"zh": "No unreleased changes found. Nothing to release." "zh": "未找到 molecule 目录: {path}"
}, },
"No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.": { "Nice! Gitea release {tag} created.": {
"en": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.", "bg": "Отлично! Gitea release {tag} е създаден.",
"bg": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.", "de": "Prima! Gitea-Release {tag} erstellt.",
"de": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.", "en": "Nice! Gitea release {tag} created.",
"ru": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.", "ru": "Отлично! Gitea release {tag} создан.",
"zh": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release." "zh": "不错!Gitea release {tag} 已创建。"
}, },
"Note: Self-approval not allowed. Posting COMMENT instead.": { "Nice! PR #{pr_number} squash-merged with title: {merge_title}": {
"en": "Note: Self-approval not allowed. Posting COMMENT instead.", "bg": "Отлично! PR #{pr_number} е squash-merge-нат със заглавие: {merge_title}",
"bg": "Note: Self-approval not allowed. Posting COMMENT instead.", "de": "Prima! PR #{pr_number} wurde mit Titel {merge_title} squash-gemergt.",
"de": "Note: Self-approval not allowed. Posting COMMENT instead.", "en": "Nice! PR #{pr_number} squash-merged with title: {merge_title}",
"ru": "Note: Self-approval not allowed. Posting COMMENT instead.", "ru": "Отлично! PR #{pr_number} squash-merge с заголовком: {merge_title}",
"zh": "Note: Self-approval not allowed. Posting COMMENT instead." "zh": "不错!PR #{pr_number} 已 squash 合并,标题: {merge_title}"
}, },
"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": { "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.": {
"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": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
"bg": "Опа! Съобщението за commit трябва да следва конвенционален формат.\n Очаква се: <type>: <description>\n Получено: {subject}\n Разрешени типове: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", "de": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
"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": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
"ru": "Ой! Сообщение коммита должно соответствовать формату conventional commit.\n Ожидается: <type>: <description>\n Получено: {subject}\n Допустимые типы: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", "ru": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
"zh": "哎呀!提交消息必须遵循 conventional commit 格式。\n 预期格式: <type>: <description>\n 实际: {subject}\n 允许的类型: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE" "zh": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered."
}, },
"Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": { "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.": {
"en": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.", "bg": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) е обновена и маркирана като готова.",
"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": "Prima! Vikunja-Aufgabe {task_id} (ID {vikunja_id}) aktualisiert und als erledigt markiert.",
"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": "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.",
"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.", "ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
"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." "zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
}, },
"Oops! Gitea PyPI registry publish failed:\n{stderr}": { "No JUnit reports found matching {pattern} — skipping merge.": {
"en": "Oops! Gitea PyPI registry publish failed:\n{stderr}", "bg": "No JUnit reports found matching {pattern} — skipping merge.",
"bg": "Опа! Публикуването в Gitea PyPI registry неуспешно:\n{stderr}", "de": "No JUnit reports found matching {pattern} — skipping merge.",
"de": "Ups! Veröffentlichung in der Gitea PyPI-Registry fehlgeschlagen:\n{stderr}", "en": "No JUnit reports found matching {pattern} — skipping merge.",
"ru": "Ой! Публикация в Gitea PyPI registry не удалась:\n{stderr}", "ru": "No JUnit reports found matching {pattern} — skipping merge.",
"zh": "哎呀!Gitea PyPI registry 发布失败:\n{stderr}" "zh": "No JUnit reports found matching {pattern} — skipping merge."
}, },
"Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}": { "No changes between {base} and {head}.": {
"en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}", "bg": "No changes between {base} and {head}.",
"bg": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}", "de": "No changes between {base} and {head}.",
"de": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}", "en": "No changes between {base} and {head}.",
"ru": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}", "ru": "No changes between {base} and {head}.",
"zh": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}" "zh": "No changes between {base} and {head}."
}, },
"Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}": { "No staged changes — version and changelog already up to date.": {
"en": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}", "bg": "No staged changes — version and changelog already up to date.",
"bg": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}", "de": "No staged changes — version and changelog already up to date.",
"de": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}", "en": "No staged changes — version and changelog already up to date.",
"ru": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}", "ru": "No staged changes — version and changelog already up to date.",
"zh": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}" "zh": "No staged changes — version and changelog already up to date."
}, },
"Oops! No task ID found in .taskid file or branch name '{branch}'.": { "No tags found — treating all changes as user-facing.": {
"en": "Oops! No task ID found in .taskid file or branch name '{branch}'.", "bg": "No tags found — treating all changes as user-facing.",
"bg": "Oops! No task ID found in .taskid file or branch name '{branch}'.", "de": "No tags found — treating all changes as user-facing.",
"de": "Oops! No task ID found in .taskid file or branch name '{branch}'.", "en": "No tags found — treating all changes as user-facing.",
"ru": "Oops! No task ID found in .taskid file or branch name '{branch}'.", "ru": "No tags found — treating all changes as user-facing.",
"zh": "Oops! No task ID found in .taskid file or branch name '{branch}'." "zh": "No tags found — treating all changes as user-facing."
}, },
"Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": { "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.": {
"en": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}", "bg": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"bg": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}", "de": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"de": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}", "en": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"ru": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}", "ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"zh": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}" "zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID."
}, },
"Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}": { "No unreleased changes found. Nothing to release.": {
"en": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}", "bg": "No unreleased changes found. Nothing to release.",
"bg": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}", "de": "No unreleased changes found. Nothing to release.",
"de": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}", "en": "No unreleased changes found. Nothing to release.",
"ru": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}", "ru": "No unreleased changes found. Nothing to release.",
"zh": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}" "zh": "No unreleased changes found. Nothing to release."
}, },
"Oops! Package build failed:\n{stderr}": { "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.": {
"en": "Oops! Package build failed:\n{stderr}", "bg": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"bg": "Опа! Сборката на пакета неуспешна:\n{stderr}", "de": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"de": "Ups! Paket-Build fehlgeschlagen:\n{stderr}", "en": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"ru": "Ой! Сборка пакета не удалась:\n{stderr}", "ru": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"zh": "哎呀!包构建失败:\n{stderr}" "zh": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release."
}, },
"Oops! PyPI publish failed:\n{stderr}": { "Note: Self-approval not allowed. Posting COMMENT instead.": {
"en": "Oops! PyPI publish failed:\n{stderr}", "bg": "Note: Self-approval not allowed. Posting COMMENT instead.",
"bg": "Опа! Публикуването в PyPI неуспешно:\n{stderr}", "de": "Note: Self-approval not allowed. Posting COMMENT instead.",
"de": "Ups! PyPI-Veröffentlichung fehlgeschlagen:\n{stderr}", "en": "Note: Self-approval not allowed. Posting COMMENT instead.",
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}", "ru": "Note: Self-approval not allowed. Posting COMMENT instead.",
"zh": "哎呀!PyPI 发布失败:\n{stderr}" "zh": "Note: Self-approval not allowed. Posting COMMENT instead."
}, },
"PASSED: {pair}": { "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": "PASSED: {pair}", "bg": "Опа! Съобщението за commit трябва да следва конвенционален формат.\n Очаква се: <type>: <description>\n Получено: {subject}\n Разрешени типове: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"bg": "PASSED: {pair}", "de": "Ups! Commit-Nachricht muss dem konventionellen Commit-Format folgen.\n Erwartet: <type>: <description>\n Erhalten: {subject}\n Erlaubte Typen: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"de": "PASSED: {pair}", "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": "PASSED: {pair}", "ru": "Ой! Сообщение коммита должно соответствовать формату conventional commit.\n Ожидается: <type>: <description>\n Получено: {subject}\n Допустимые типы: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"zh": "PASSED: {pair}" "zh": "哎呀!提交消息必须遵循 conventional commit 格式。\n 预期格式: <type>: <description>\n 实际: {subject}\n 允许的类型: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE"
}, },
"PR number must be an integer, got: {pr_number}": { "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": "PR number must be an integer, got: {pr_number}", "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.",
"bg": "PR number must be an integer, got: {pr_number}", "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.",
"de": "PR number must be an integer, got: {pr_number}", "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": "PR number must be an integer, got: {pr_number}", "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": "PR number must be an integer, got: {pr_number}" "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."
}, },
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": { "Oops! Gitea PyPI registry publish failed:\n{stderr}": {
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", "bg": "Опа! Публикуването в Gitea PyPI registry неуспешно:\n{stderr}",
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", "de": "Ups! Veröffentlichung in der Gitea PyPI-Registry fehlgeschlagen:\n{stderr}",
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", "en": "Oops! Gitea PyPI registry publish failed:\n{stderr}",
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", "ru": "Ой! Публикация в Gitea PyPI registry не удалась:\n{stderr}",
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}" "zh": "哎呀!Gitea PyPI registry 发布失败:\n{stderr}"
}, },
"PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.": { "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}": {
"en": "PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.", "bg": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
"bg": "PYPI_TOKEN не е зададен и няма конфигуриран URL на registry — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.", "de": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
"de": "PYPI_TOKEN nicht gesetzt und keine Registry-URL konfiguriert — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.", "en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
"ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.", "ru": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
"zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。" "zh": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}"
}, },
"Published to Gitea PyPI registry.": { "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}": {
"en": "Published to Gitea PyPI registry.", "bg": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
"bg": "Публикувано в Gitea PyPI registry.", "de": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
"de": "In der Gitea PyPI-Registry veröffentlicht.", "en": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
"ru": "Опубликовано в Gitea PyPI registry.", "ru": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
"zh": "已发布到 Gitea PyPI registry。" "zh": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}"
}, },
"Published to PyPI.": { "Oops! No task ID found in .taskid file or branch name '{branch}'.": {
"en": "Published to PyPI.", "bg": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
"bg": "Публикувано в PyPI.", "de": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
"de": "In PyPI veröffentlicht.", "en": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
"ru": "Опубликовано в PyPI.", "ru": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
"zh": "已发布到 PyPI。" "zh": "Oops! No task ID found in .taskid file or branch name '{branch}'."
}, },
"Pushed release commit to master.": { "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
"en": "Pushed release commit to master.", "bg": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"bg": "Pushed release commit to master.", "de": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"de": "Pushed release commit to master.", "en": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"ru": "Pushed release commit to master.", "ru": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"zh": "Pushed release commit to master." "zh": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}"
}, },
"Rebased and pushed. Retrying merge...": { "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}": {
"en": "Rebased and pushed. Retrying merge...", "bg": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
"bg": "Rebased and pushed. Retrying merge...", "de": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
"de": "Rebased and pushed. Retrying merge...", "en": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
"ru": "Rebased and pushed. Retrying merge...", "ru": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
"zh": "Rebased and pushed. Retrying merge..." "zh": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}"
}, },
"Release creation failed: {error}": { "Oops! Package build failed:\n{stderr}": {
"en": "Release creation failed: {error}", "bg": "Опа! Сборката на пакета неуспешна:\n{stderr}",
"bg": "Release creation failed: {error}", "de": "Ups! Paket-Build fehlgeschlagen:\n{stderr}",
"de": "Release creation failed: {error}", "en": "Oops! Package build failed:\n{stderr}",
"ru": "Release creation failed: {error}", "ru": "Ой! Сборка пакета не удалась:\n{stderr}",
"zh": "Release creation failed: {error}" "zh": "哎呀!包构建失败:\n{stderr}"
}, },
"Release must be run on master, currently on '{branch}'.": { "Oops! PyPI publish failed:\n{stderr}": {
"en": "Release must be run on master, currently on '{branch}'.", "bg": "Опа! Публикуването в PyPI неуспешно:\n{stderr}",
"bg": "Release must be run on master, currently on '{branch}'.", "de": "Ups! PyPI-Veröffentlichung fehlgeschlagen:\n{stderr}",
"de": "Release must be run on master, currently on '{branch}'.", "en": "Oops! PyPI publish failed:\n{stderr}",
"ru": "Release must be run on master, currently on '{branch}'.", "ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
"zh": "Release must be run on master, currently on '{branch}'." "zh": "哎呀!PyPI 发布失败:\n{stderr}"
}, },
"Repo must be in 'owner/name' format, got: {repo}": { "Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME": {
"en": "Repo must be in 'owner/name' format, got: {repo}", "bg": "Разбор на owner={owner}, repo={repo} от DEVX_REPO_NAME",
"bg": "Repo must be in 'owner/name' format, got: {repo}", "de": "Owner={owner}, repo={repo} aus DEVX_REPO_NAME analysiert",
"de": "Repo must be in 'owner/name' format, got: {repo}", "en": "Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME",
"ru": "Repo must be in 'owner/name' format, got: {repo}", "ru": "Извлечён owner={owner}, repo={repo} из DEVX_REPO_NAME",
"zh": "Repo must be in 'owner/name' format, got: {repo}" "zh": "从 DEVX_REPO_NAME 解析 owner={owner}, repo={repo}"
}, },
"Repository configuration complete.": { "PASSED: {pair}": {
"en": "Repository configuration complete.", "bg": "PASSED: {pair}",
"bg": "Конфигурирането на хранилището е завършено.", "de": "PASSED: {pair}",
"de": "Repository-Konfiguration abgeschlossen.", "en": "PASSED: {pair}",
"ru": "Конфигурация репозитория завершена.", "ru": "PASSED: {pair}",
"zh": "仓库配置完成。" "zh": "PASSED: {pair}"
}, },
"Runner index {index} out of range (0..{max})": { "PR number must be an integer, got: {pr_number}": {
"en": "Runner index {index} out of range (0..{max})", "bg": "PR number must be an integer, got: {pr_number}",
"bg": "Индексът на runner {index} е извън диапазона (0..{max})", "de": "PR number must be an integer, got: {pr_number}",
"de": "Runner-Index {index} außerhalb des Bereichs (0..{max})", "en": "PR number must be an integer, got: {pr_number}",
"ru": "Индекс runner {index} вне диапазона (0..{max})", "ru": "PR number must be an integer, got: {pr_number}",
"zh": "Runner 索引 {index} 超出范围 (0..{max})" "zh": "PR number must be an integer, got: {pr_number}"
}, },
"Running lint checks...": { "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": {
"en": "Running lint checks...", "bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"bg": "Running lint checks...", "de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"de": "Running lint checks...", "en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"ru": "Running lint checks...", "ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"zh": "Running lint checks..." "zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}"
}, },
"Running tests...": { "PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.": {
"en": "Running tests...", "bg": "PYPI_TOKEN не е зададен и няма конфигуриран URL на registry — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.",
"bg": "Running tests...", "de": "PYPI_TOKEN nicht gesetzt und keine Registry-URL konfiguriert — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.",
"de": "Running tests...", "en": "PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.",
"ru": "Running tests...", "ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
"zh": "Running tests..." "zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
}, },
"Running: {scenario} on {platform}": { "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.": {
"en": "Running: {scenario} on {platform}", "bg": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
"bg": "Running: {scenario} on {platform}", "de": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
"de": "Running: {scenario} on {platform}", "en": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
"ru": "Running: {scenario} on {platform}", "ru": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
"zh": "Running: {scenario} on {platform}" "zh": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit."
}, },
"Skipping commit push — no staged changes.": { "Published to Gitea PyPI registry.": {
"en": "Skipping commit push — no staged changes.", "bg": "Публикувано в Gitea PyPI registry.",
"bg": "Skipping commit push — no staged changes.", "de": "In der Gitea PyPI-Registry veröffentlicht.",
"de": "Skipping commit push — no staged changes.", "en": "Published to Gitea PyPI registry.",
"ru": "Skipping commit push — no staged changes.", "ru": "Опубликовано в Gitea PyPI registry.",
"zh": "Skipping commit push — no staged changes." "zh": "已发布到 Gitea PyPI registry。"
}, },
"Syncing {count} documentation pages to wiki...": { "Published to PyPI.": {
"en": "Syncing {count} documentation pages to wiki...", "bg": "Публикувано в PyPI.",
"bg": "Syncing {count} documentation pages to wiki...", "de": "In PyPI veröffentlicht.",
"de": "Syncing {count} documentation pages to wiki...", "en": "Published to PyPI.",
"ru": "Syncing {count} documentation pages to wiki...", "ru": "Опубликовано в PyPI.",
"zh": "Syncing {count} documentation pages to wiki..." "zh": "已发布到 PyPI。"
}, },
"Tag consistency check failed.": { "Pushed release commit to master.": {
"en": "Tag consistency check failed.", "bg": "Pushed release commit to master.",
"bg": "Tag consistency check failed.", "de": "Pushed release commit to master.",
"de": "Tag consistency check failed.", "en": "Pushed release commit to master.",
"ru": "Tag consistency check failed.", "ru": "Pushed release commit to master.",
"zh": "Tag consistency check failed." "zh": "Pushed release commit to master."
}, },
"Tag v{version} already existed. Publish workflow should already have been triggered.": { "Rebased and pushed. Retrying merge...": {
"en": "Tag v{version} already existed. Publish workflow should already have been triggered.", "bg": "Rebased and pushed. Retrying merge...",
"bg": "Tag v{version} already existed. Publish workflow should already have been triggered.", "de": "Rebased and pushed. Retrying merge...",
"de": "Tag v{version} already existed. Publish workflow should already have been triggered.", "en": "Rebased and pushed. Retrying merge...",
"ru": "Tag v{version} already existed. Publish workflow should already have been triggered.", "ru": "Rebased and pushed. Retrying merge...",
"zh": "Tag v{version} already existed. Publish workflow should already have been triggered." "zh": "Rebased and pushed. Retrying merge..."
}, },
"Tag {tag} already exists and points to HEAD. Skipping creation.": { "Release creation failed: {error}": {
"en": "Tag {tag} already exists and points to HEAD. Skipping creation.", "bg": "Release creation failed: {error}",
"bg": "Tag {tag} already exists and points to HEAD. Skipping creation.", "de": "Release creation failed: {error}",
"de": "Tag {tag} already exists and points to HEAD. Skipping creation.", "en": "Release creation failed: {error}",
"ru": "Tag {tag} already exists and points to HEAD. Skipping creation.", "ru": "Release creation failed: {error}",
"zh": "Tag {tag} already exists and points to HEAD. Skipping creation." "zh": "Release creation failed: {error}"
}, },
"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.": { "Release must be run on master, currently on '{branch}'.": {
"en": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.", "bg": "Release must be run on master, currently on '{branch}'.",
"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": "Release must be run on master, currently on '{branch}'.",
"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": "Release must be run on master, currently on '{branch}'.",
"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.", "ru": "Release must be run on master, currently on '{branch}'.",
"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." "zh": "Release must be run on master, currently on '{branch}'."
}, },
"Task ID: {task_id}": { "Repo must be in 'owner/name' format, got: {repo}": {
"en": "Task ID: {task_id}", "bg": "Repo must be in 'owner/name' format, got: {repo}",
"bg": "Task ID: {task_id}", "de": "Repo must be in 'owner/name' format, got: {repo}",
"de": "Task ID: {task_id}", "en": "Repo must be in 'owner/name' format, got: {repo}",
"ru": "Task ID: {task_id}", "ru": "Repo must be in 'owner/name' format, got: {repo}",
"zh": "Task ID: {task_id}" "zh": "Repo must be in 'owner/name' format, got: {repo}"
}, },
"Tests failed — refusing to release. Fix test failures first.\n{stderr}": { "Repository configuration complete.": {
"en": "Tests failed — refusing to release. Fix test failures first.\n{stderr}", "bg": "Конфигурирането на хранилището е завършено.",
"bg": "Tests failed — refusing to release. Fix test failures first.\n{stderr}", "de": "Repository-Konfiguration abgeschlossen.",
"de": "Tests failed — refusing to release. Fix test failures first.\n{stderr}", "en": "Repository configuration complete.",
"ru": "Tests failed — refusing to release. Fix test failures first.\n{stderr}", "ru": "Конфигурация репозитория завершена.",
"zh": "Tests failed — refusing to release. Fix test failures first.\n{stderr}" "zh": "仓库配置完成。"
}, },
"Tests passed.": { "Roles directory not found: {path}": {
"en": "Tests passed.", "bg": "Roles directory not found: {path}",
"bg": "Tests passed.", "de": "Roles directory not found: {path}",
"de": "Tests passed.", "en": "Roles directory not found: {path}",
"ru": "Tests passed.", "ru": "Roles directory not found: {path}",
"zh": "Tests passed." "zh": "Roles directory not found: {path}"
}, },
"Unit tests passed in {duration:.2f}s (under {max}s limit).": { "Runner index {index} out of range (0..{max})": {
"en": "Unit tests passed in {duration:.2f}s (under {max}s limit).", "bg": "Индексът на runner {index} е извън диапазона (0..{max})",
"bg": "Unit tests passed in {duration:.2f}s (under {max}s limit).", "de": "Runner-Index {index} außerhalb des Bereichs (0..{max})",
"de": "Unit tests passed in {duration:.2f}s (under {max}s limit).", "en": "Runner index {index} out of range (0..{max})",
"ru": "Unit tests passed in {duration:.2f}s (under {max}s limit).", "ru": "Индекс runner {index} вне диапазона (0..{max})",
"zh": "Unit tests passed in {duration:.2f}s (under {max}s limit)." "zh": "Runner 索引 {index} 超出范围 (0..{max})"
}, },
"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.": { "Running lint checks...": {
"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": "Running lint checks...",
"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": "Running lint checks...",
"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": "Running lint checks...",
"ru": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.", "ru": "Running lint checks...",
"zh": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures." "zh": "Running lint checks..."
}, },
"Unknown check category '{check}'. Available: all, user-facing{tags}": { "Running tests...": {
"en": "Unknown check category '{check}'. Available: all, user-facing{tags}", "bg": "Running tests...",
"bg": "Unknown check category '{check}'. Available: all, user-facing{tags}", "de": "Running tests...",
"de": "Unknown check category '{check}'. Available: all, user-facing{tags}", "en": "Running tests...",
"ru": "Unknown check category '{check}'. Available: all, user-facing{tags}", "ru": "Running tests...",
"zh": "Unknown check category '{check}'. Available: all, user-facing{tags}" "zh": "Running tests..."
}, },
"Updated version in {init}": { "Running: {scenario} on {platform}": {
"en": "Updated version in {init}", "bg": "Running: {scenario} on {platform}",
"bg": "Updated version in {init}", "de": "Running: {scenario} on {platform}",
"de": "Updated version in {init}", "en": "Running: {scenario} on {platform}",
"ru": "Updated version in {init}", "ru": "Running: {scenario} on {platform}",
"zh": "Updated version in {init}" "zh": "Running: {scenario} on {platform}"
}, },
"Updated {changelog_file}": { "Skipping commit push — no staged changes.": {
"en": "Updated {changelog_file}", "bg": "Skipping commit push — no staged changes.",
"bg": "Updated {changelog_file}", "de": "Skipping commit push — no staged changes.",
"de": "Updated {changelog_file}", "en": "Skipping commit push — no staged changes.",
"ru": "Updated {changelog_file}", "ru": "Skipping commit push — no staged changes.",
"zh": "Updated {changelog_file}" "zh": "Skipping commit push — no staged changes."
}, },
"VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.": { "Syncing {count} documentation pages to wiki...": {
"en": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", "bg": "Syncing {count} documentation pages to wiki...",
"bg": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", "de": "Syncing {count} documentation pages to wiki...",
"de": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", "en": "Syncing {count} documentation pages to wiki...",
"ru": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", "ru": "Syncing {count} documentation pages to wiki...",
"zh": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles." "zh": "Syncing {count} documentation pages to wiki..."
}, },
"Version file: {file}": { "Tag consistency check failed.": {
"en": "Version file: {file}", "bg": "Tag consistency check failed.",
"bg": "Version file: {file}", "de": "Tag consistency check failed.",
"de": "Version file: {file}", "en": "Tag consistency check failed.",
"ru": "Version file: {file}", "ru": "Tag consistency check failed.",
"zh": "Version file: {file}" "zh": "Tag consistency check failed."
}, },
"Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.": { "Tag v{version} already existed. Publish workflow should already have been triggered.": {
"en": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", "bg": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"bg": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", "de": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"de": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", "en": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"ru": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", "ru": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"zh": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update." "zh": "Tag v{version} already existed. Publish workflow should already have been triggered."
}, },
"WARNING: --skip-tests passed — skipping test verification.": { "Tag {tag} already exists and points to HEAD. Skipping creation.": {
"en": "WARNING: --skip-tests passed — skipping test verification.", "bg": "Tag {tag} already exists and points to HEAD. Skipping creation.",
"bg": "WARNING: --skip-tests passed — skipping test verification.", "de": "Tag {tag} already exists and points to HEAD. Skipping creation.",
"de": "WARNING: --skip-tests passed — skipping test verification.", "en": "Tag {tag} already exists and points to HEAD. Skipping creation.",
"ru": "WARNING: --skip-tests passed — skipping test verification.", "ru": "Tag {tag} already exists and points to HEAD. Skipping creation.",
"zh": "WARNING: --skip-tests passed — skipping test verification." "zh": "Tag {tag} already exists and points to HEAD. Skipping creation."
}, },
"Warning: could not fetch tags from origin.": { "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": "Warning: could not fetch tags from origin.", "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.",
"bg": "Warning: could not fetch tags from origin.", "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.",
"de": "Warning: could not fetch tags from origin.", "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": "Warning: could not fetch tags from origin.", "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": "Warning: could not fetch tags from origin." "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."
}, },
"Wiki integrity check failed — {count} issue(s)": { "Task ID: {task_id}": {
"en": "Wiki integrity check failed — {count} issue(s)", "bg": "Task ID: {task_id}",
"bg": "Wiki integrity check failed — {count} issue(s)", "de": "Task ID: {task_id}",
"de": "Wiki integrity check failed — {count} issue(s)", "en": "Task ID: {task_id}",
"ru": "Wiki integrity check failed — {count} issue(s)", "ru": "Task ID: {task_id}",
"zh": "Wiki integrity check failed — {count} issue(s)" "zh": "Task ID: {task_id}"
}, },
"Wiki verification failed — {failures} page(s) empty or mismatched": { "Task ID from .taskid file: {task_id} (not found in branch name '{branch}')": {
"en": "Wiki verification failed — {failures} page(s) empty or mismatched", "bg": "Task ID от .taskid файл: {task_id} (не е намерен в името на клона '{branch}')",
"bg": "Wiki verification failed — {failures} page(s) empty or mismatched", "de": "Task ID aus .taskid-Datei: {task_id} (nicht im Branch-Namen '{branch}' gefunden)",
"de": "Wiki verification failed — {failures} page(s) empty or mismatched", "en": "Task ID from .taskid file: {task_id} (not found in branch name '{branch}')",
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched", "ru": "Task ID из файла .taskid: {task_id} (не найден в имени ветки '{branch}')",
"zh": "Wiki verification failed — {failures} page(s) empty or mismatched" "zh": "来自 .taskid 文件的 Task ID: {task_id}(在分支名 '{branch}' 中未找到)"
}, },
"[dry-run] Would commit: release: v{version}": { "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.": {
"en": "[dry-run] Would commit: release: v{version}", "bg": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
"bg": "[dry-run] Would commit: release: v{version}", "de": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
"de": "[dry-run] Would commit: release: v{version}", "en": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
"ru": "[dry-run] Would commit: release: v{version}", "ru": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
"zh": "[dry-run] Would commit: release: v{version}" "zh": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls."
}, },
"[dry-run] Would create tag: v{version}": { "Tests failed — refusing to release. Fix test failures first.\n{stderr}": {
"en": "[dry-run] Would create tag: v{version}", "bg": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
"bg": "[dry-run] Would create tag: v{version}", "de": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
"de": "[dry-run] Would create tag: v{version}", "en": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
"ru": "[dry-run] Would create tag: v{version}", "ru": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
"zh": "[dry-run] Would create tag: v{version}" "zh": "Tests failed — refusing to release. Fix test failures first.\n{stderr}"
}, },
"[dry-run] Would create tag: {tag}": { "Tests passed.": {
"en": "[dry-run] Would create tag: {tag}", "bg": "Tests passed.",
"bg": "[dry-run] Would create tag: {tag}", "de": "Tests passed.",
"de": "[dry-run] Would create tag: {tag}", "en": "Tests passed.",
"ru": "[dry-run] Would create tag: {tag}", "ru": "Tests passed.",
"zh": "[dry-run] Would create tag: {tag}" "zh": "Tests passed."
}, },
"[dry-run] Would push commit to master": { "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).": {
"en": "[dry-run] Would push commit to master", "bg": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
"bg": "[dry-run] Would push commit to master", "de": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
"de": "[dry-run] Would push commit to master", "en": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
"ru": "[dry-run] Would push commit to master", "ru": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
"zh": "[dry-run] Would push commit to master" "zh": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit)."
}, },
"[dry-run] Would sync page: {title} ({chars} chars)": { "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": "[dry-run] Would sync page: {title} ({chars} chars)", "bg": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
"bg": "[dry-run] Would sync page: {title} ({chars} chars)", "de": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
"de": "[dry-run] Would sync page: {title} ({chars} chars)", "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": "[dry-run] Would sync page: {title} ({chars} chars)", "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": "[dry-run] Would sync page: {title} ({chars} chars)" "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."
}, },
"[dry-run] Would update {changelog_file}": { "Unknown check category '{check}'. Available: all, user-facing{tags}": {
"en": "[dry-run] Would update {changelog_file}", "bg": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"bg": "[dry-run] Would update {changelog_file}", "de": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"de": "[dry-run] Would update {changelog_file}", "en": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"ru": "[dry-run] Would update {changelog_file}", "ru": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"zh": "[dry-run] Would update {changelog_file}" "zh": "Unknown check category '{check}'. Available: all, user-facing{tags}"
}, },
"[dry-run] Would update {init}": { "Updated version in {init}": {
"en": "[dry-run] Would update {init}", "bg": "Updated version in {init}",
"bg": "[dry-run] Would update {init}", "de": "Updated version in {init}",
"de": "[dry-run] Would update {init}", "en": "Updated version in {init}",
"ru": "[dry-run] Would update {init}", "ru": "Updated version in {init}",
"zh": "[dry-run] Would update {init}" "zh": "Updated version in {init}"
}, },
"active": { "Updated {changelog_file}": {
"en": "active", "bg": "Updated {changelog_file}",
"bg": "активен", "de": "Updated {changelog_file}",
"de": "aktiv", "en": "Updated {changelog_file}",
"ru": "активен", "ru": "Updated {changelog_file}",
"zh": "活跃" "zh": "Updated {changelog_file}"
}, },
"completed": { "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.": {
"en": "completed", "bg": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"bg": "завършен", "de": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"de": "abgeschlossen", "en": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"ru": "завершён", "ru": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"zh": "已完成" "zh": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles."
}, },
"failed": { "Version file: {file}": {
"en": "failed", "bg": "Version file: {file}",
"bg": "неуспешен", "de": "Version file: {file}",
"de": "fehlgeschlagen", "en": "Version file: {file}",
"ru": "неудачный", "ru": "Version file: {file}",
"zh": "失败" "zh": "Version file: {file}"
}, },
"git command failed ({cmd}): {stderr}": { "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.": {
"en": "git command failed ({cmd}): {stderr}", "bg": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"bg": "git command failed ({cmd}): {stderr}", "de": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"de": "git command failed ({cmd}): {stderr}", "en": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"ru": "git command failed ({cmd}): {stderr}", "ru": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"zh": "git command failed ({cmd}): {stderr}" "zh": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update."
}, },
"git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.": { "WARNING: --skip-tests passed — skipping test verification.": {
"en": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", "bg": "WARNING: --skip-tests passed — skipping test verification.",
"bg": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", "de": "WARNING: --skip-tests passed — skipping test verification.",
"de": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", "en": "WARNING: --skip-tests passed — skipping test verification.",
"ru": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.", "ru": "WARNING: --skip-tests passed — skipping test verification.",
"zh": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history." "zh": "WARNING: --skip-tests passed — skipping test verification."
}, },
"git-cliff returned empty version.": { "WARNING: .taskid file ({file_id}) disagrees with branch name ({branch_id}). Using branch task ID. Update or delete .taskid to silence this warning.": {
"en": "git-cliff returned empty version.", "bg": "ВНИМАНИЕ: .taskid файл ({file_id}) не съвпада с името на клона ({branch_id}). Използва се task ID от клона. Актуализирайте или изтрийте .taskid за да премахнете това предупреждение.",
"bg": "git-cliff returned empty version.", "de": "WARNUNG: .taskid-Datei ({file_id}) stimmt nicht mit Branch-Namen ({branch_id}) überein. Branch-Task-ID wird verwendet. Aktualisieren oder löschen Sie .taskid, um diese Warnung zu unterdrücken.",
"de": "git-cliff returned empty version.", "en": "WARNING: .taskid file ({file_id}) disagrees with branch name ({branch_id}). Using branch task ID. Update or delete .taskid to silence this warning.",
"ru": "git-cliff returned empty version.", "ru": "ВНИМАНИЕ: файл .taskid ({file_id}) не совпадает с именем ветки ({branch_id}). Используется Task ID из ветки. Обновите или удалите .taskid, чтобы скрыть это предупреждение.",
"zh": "git-cliff returned empty version." "zh": "警告:.taskid 文件 ({file_id}) 与分支名 ({branch_id}) 不一致。使用分支 Task ID。更新或删除 .taskid 以消除此警告。"
}, },
"git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).": { "Warning: could not fetch tags from origin.": {
"en": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", "bg": "Warning: could not fetch tags from origin.",
"bg": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", "de": "Warning: could not fetch tags from origin.",
"de": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", "en": "Warning: could not fetch tags from origin.",
"ru": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", "ru": "Warning: could not fetch tags from origin.",
"zh": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1)." "zh": "Warning: could not fetch tags from origin."
}, },
"in_progress": { "Wiki integrity check failed — {count} issue(s)": {
"en": "in progress", "bg": "Wiki integrity check failed — {count} issue(s)",
"bg": "в процес", "de": "Wiki integrity check failed — {count} issue(s)",
"de": "in Bearbeitung", "en": "Wiki integrity check failed — {count} issue(s)",
"ru": "в процессе", "ru": "Wiki integrity check failed — {count} issue(s)",
"zh": "进行中" "zh": "Wiki integrity check failed — {count} issue(s)"
}, },
"inactive": { "Wiki verification failed — {failures} page(s) empty or mismatched": {
"en": "inactive", "bg": "Wiki verification failed — {failures} page(s) empty or mismatched",
"bg": "неактивен", "de": "Wiki verification failed — {failures} page(s) empty or mismatched",
"de": "inaktiv", "en": "Wiki verification failed — {failures} page(s) empty or mismatched",
"ru": "неактивен", "ru": "Wiki verification failed — {failures} page(s) empty or mismatched",
"zh": "未激活" "zh": "Wiki verification failed — {failures} page(s) empty or mismatched"
}, },
"mapping.json keys and values must be strings, got {k}={v}": { "[dry-run] Would commit: release: v{version}": {
"en": "mapping.json keys and values must be strings, got {k}={v}", "bg": "[dry-run] Would commit: release: v{version}",
"bg": "mapping.json keys and values must be strings, got {k}={v}", "de": "[dry-run] Would commit: release: v{version}",
"de": "mapping.json keys and values must be strings, got {k}={v}", "en": "[dry-run] Would commit: release: v{version}",
"ru": "mapping.json keys and values must be strings, got {k}={v}", "ru": "[dry-run] Would commit: release: v{version}",
"zh": "mapping.json keys and values must be strings, got {k}={v}" "zh": "[dry-run] Would commit: release: v{version}"
}, },
"mapping.json must be a dict of file-path -> page-title, got {type}": { "[dry-run] Would create tag: v{version}": {
"en": "mapping.json must be a dict of file-path -> page-title, got {type}", "bg": "[dry-run] Would create tag: v{version}",
"bg": "mapping.json must be a dict of file-path -> page-title, got {type}", "de": "[dry-run] Would create tag: v{version}",
"de": "mapping.json must be a dict of file-path -> page-title, got {type}", "en": "[dry-run] Would create tag: v{version}",
"ru": "mapping.json must be a dict of file-path -> page-title, got {type}", "ru": "[dry-run] Would create tag: v{version}",
"zh": "mapping.json must be a dict of file-path -> page-title, got {type}" "zh": "[dry-run] Would create tag: v{version}"
}, },
"pending": { "[dry-run] Would create tag: {tag}": {
"en": "pending", "bg": "[dry-run] Would create tag: {tag}",
"bg": "в очакване", "de": "[dry-run] Would create tag: {tag}",
"de": "ausstehend", "en": "[dry-run] Would create tag: {tag}",
"ru": "ожидает", "ru": "[dry-run] Would create tag: {tag}",
"zh": "待处理" "zh": "[dry-run] Would create tag: {tag}"
}, },
"unknown": { "[dry-run] Would push commit to master": {
"en": "unknown", "bg": "[dry-run] Would push commit to master",
"bg": "неизвестен", "de": "[dry-run] Would push commit to master",
"de": "unbekannt", "en": "[dry-run] Would push commit to master",
"ru": "неизвестно", "ru": "[dry-run] Would push commit to master",
"zh": "未知" "zh": "[dry-run] Would push commit to master"
}, },
"{file} already exists. Use --force to overwrite.": { "[dry-run] Would sync page: {title} ({chars} chars)": {
"en": "{file} already exists. Use --force to overwrite.", "bg": "[dry-run] Would sync page: {title} ({chars} chars)",
"bg": "{file} already exists. Use --force to overwrite.", "de": "[dry-run] Would sync page: {title} ({chars} chars)",
"de": "{file} already exists. Use --force to overwrite.", "en": "[dry-run] Would sync page: {title} ({chars} chars)",
"ru": "{file} already exists. Use --force to overwrite.", "ru": "[dry-run] Would sync page: {title} ({chars} chars)",
"zh": "{file} already exists. Use --force to overwrite." "zh": "[dry-run] Would sync page: {title} ({chars} chars)"
}, },
"--skip-build: skipping package build and PyPI publish.": { "[dry-run] Would update {changelog_file}": {
"en": "--skip-build: skipping package build and PyPI publish.", "bg": "[dry-run] Would update {changelog_file}",
"bg": "--skip-build: skipping package build and PyPI publish.", "de": "[dry-run] Would update {changelog_file}",
"de": "--skip-build: skipping package build and PyPI publish.", "en": "[dry-run] Would update {changelog_file}",
"ru": "--skip-build: skipping package build and PyPI publish.", "ru": "[dry-run] Would update {changelog_file}",
"zh": "--skip-build: skipping package build and PyPI publish." "zh": "[dry-run] Would update {changelog_file}"
}, },
"Integration tests cancelled — another runner failed.": { "[dry-run] Would update {init}": {
"en": "Integration tests cancelled — another runner failed.", "bg": "[dry-run] Would update {init}",
"bg": "Integration tests cancelled — another runner failed.", "de": "[dry-run] Would update {init}",
"de": "Integration tests cancelled — another runner failed.", "en": "[dry-run] Would update {init}",
"ru": "Integration tests cancelled — another runner failed.", "ru": "[dry-run] Would update {init}",
"zh": "Integration tests cancelled — another runner failed." "zh": "[dry-run] Would update {init}"
}, },
"Integration tests failed with exit code {code}": { "active": {
"en": "Integration tests failed with exit code {code}", "bg": "активен",
"bg": "Integration tests failed with exit code {code}", "de": "aktiv",
"de": "Integration tests failed with exit code {code}", "en": "active",
"ru": "Integration tests failed with exit code {code}", "ru": "активен",
"zh": "Integration tests failed with exit code {code}" "zh": "活跃"
}, },
"Integration tests passed.": { "completed": {
"en": "Integration tests passed.", "bg": "завършен",
"bg": "Integration tests passed.", "de": "abgeschlossen",
"de": "Integration tests passed.", "en": "completed",
"ru": "Integration tests passed.", "ru": "завершён",
"zh": "Integration tests passed." "zh": "已完成"
}, },
"Merged {count} reports: {tests} tests, {failures} failures → {output}": { "failed": {
"en": "Merged {count} reports: {tests} tests, {failures} failures → {output}", "bg": "неуспешен",
"bg": "Merged {count} reports: {tests} tests, {failures} failures → {output}", "de": "fehlgeschlagen",
"de": "Merged {count} reports: {tests} tests, {failures} failures → {output}", "en": "failed",
"ru": "Merged {count} reports: {tests} tests, {failures} failures → {output}", "ru": "неудачный",
"zh": "Merged {count} reports: {tests} tests, {failures} failures → {output}" "zh": "失败"
}, },
"No JUnit reports found matching {pattern} — skipping merge.": { "git command failed ({cmd}): {stderr}": {
"en": "No JUnit reports found matching {pattern} — skipping merge.", "bg": "git command failed ({cmd}): {stderr}",
"bg": "No JUnit reports found matching {pattern} — skipping merge.", "de": "git command failed ({cmd}): {stderr}",
"de": "No JUnit reports found matching {pattern} — skipping merge.", "en": "git command failed ({cmd}): {stderr}",
"ru": "No JUnit reports found matching {pattern} — skipping merge.", "ru": "git command failed ({cmd}): {stderr}",
"zh": "No JUnit reports found matching {pattern} — skipping merge." "zh": "git command failed ({cmd}): {stderr}"
}, },
"Roles directory not found: {path}": { "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.": {
"en": "Roles directory not found: {path}", "bg": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
"bg": "Roles directory not found: {path}", "de": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
"de": "Roles directory not found: {path}", "en": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
"ru": "Roles directory not found: {path}", "ru": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
"zh": "Roles directory not found: {path}" "zh": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history."
} },
"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."
},
"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": {
"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": {
"bg": "в очакване",
"de": "ausstehend",
"en": "pending",
"ru": "ожидает",
"zh": "待处理"
},
"unknown": {
"bg": "неизвестен",
"de": "unbekannt",
"en": "unknown",
"ru": "неизвестно",
"zh": "未知"
},
"{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."
}
} }
+2 -1
View File
@@ -566,7 +566,8 @@ class TestVikunjaClient:
json={"done": True}, 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") client = VikunjaClient("https://work.example.com", "tok")
mock_resp = MagicMock() mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = _mock_http_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error") mock_resp.raise_for_status.side_effect = _mock_http_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
+18 -1
View File
@@ -21,9 +21,16 @@ from devx.exceptions import APIError
class TestReadTaskid: class TestReadTaskid:
def test_reads_from_file(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] def test_prefers_branch_name_over_file(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-60\n") (tmp_path / ".taskid").write_text("DEVX-60\n")
# Branch name takes priority over .taskid file
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
def test_falls_back_to_file_when_no_branch_match(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-60\n")
# No task ID in branch name → fall back to .taskid
assert read_taskid("some-branch") == "DEVX-60" assert read_taskid("some-branch") == "DEVX-60"
def test_falls_back_to_branch_name(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] def test_falls_back_to_branch_name(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
@@ -39,6 +46,16 @@ class TestReadTaskid:
(tmp_path / ".taskid").write_text("\n") (tmp_path / ".taskid").write_text("\n")
assert read_taskid("DEVX-42-test") == "DEVX-42" assert read_taskid("DEVX-42-test") == "DEVX-42"
def test_warns_on_stale_taskid_file(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-60\n")
# Branch name takes priority, but stale .taskid should produce a warning
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
captured = capsys.readouterr()
assert "WARNING" in captured.out
assert "DEVX-60" in captured.out
assert "DEVX-19" in captured.out
# -- extract_task_id (legacy fallback) -- # -- extract_task_id (legacy fallback) --
+164 -10
View File
@@ -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 from unittest.mock import MagicMock, patch
@@ -8,10 +8,13 @@ from click.testing import CliRunner
from devx.tools.check_test_speed import ( from devx.tools.check_test_speed import (
DEFAULT_MAX_SECONDS, DEFAULT_MAX_SECONDS,
DEFAULT_MAX_SINGLE_SECONDS,
TEST_COMMAND, TEST_COMMAND,
check_per_test_speed,
check_speed, check_speed,
cli, cli,
parse_duration, parse_duration,
parse_per_test_durations,
run_tests, run_tests,
) )
@@ -23,12 +26,23 @@ class TestRunTests:
stdout, stderr = run_tests() stdout, stderr = run_tests()
assert stdout == "out" assert stdout == "out"
assert stderr == "err" assert stderr == "err"
mock_run.assert_called_once_with( mock_run.assert_called_once()
TEST_COMMAND, call_kwargs = mock_run.call_args
capture_output=True, assert call_kwargs.args[0] == TEST_COMMAND
text=True, assert call_kwargs.kwargs["capture_output"] is True
check=False, 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: class TestParseDuration:
@@ -48,6 +62,38 @@ class TestParseDuration:
assert "Could not parse" in str(exc.value) 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: class TestCheckSpeed:
def test_under_budget_passes(self) -> None: def test_under_budget_passes(self) -> None:
check_speed(1.0, 2.0) # should not raise check_speed(1.0, 2.0) # should not raise
@@ -64,6 +110,31 @@ class TestCheckSpeed:
assert "max allowed: 2.0s" in msg 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: def test_main_module_block() -> None:
import devx.tools.check_test_speed as cts 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.run_tests")
@patch("devx.tools.check_test_speed.parse_duration") @patch("devx.tools.check_test_speed.parse_duration")
@patch("devx.tools.check_test_speed.check_speed") @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( def test_successful_run(
self, self,
mock_check_per: MagicMock,
mock_parse_per: MagicMock,
mock_check: MagicMock, mock_check: MagicMock,
mock_parse: MagicMock, mock_parse: MagicMock,
mock_run: MagicMock, mock_run: MagicMock,
) -> None: ) -> None:
mock_run.return_value = ("stdout\n", "stderr\n") mock_run.return_value = ("stdout\n", "stderr\n")
mock_parse.return_value = 1.5 mock_parse.return_value = 1.5
mock_parse_per.return_value = []
mock_check_per.return_value = []
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, []) result = runner.invoke(cli, [])
assert result.exit_code == 0 assert result.exit_code == 0
assert "1.50s" in result.output 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_run.assert_called_once()
mock_parse.assert_called_once_with("stdout\n\nstderr\n") mock_parse.assert_called_once_with("stdout\n\nstderr\n")
mock_check.assert_called_once_with(1.5, DEFAULT_MAX_SECONDS) 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.run_tests")
@patch("devx.tools.check_test_speed.parse_duration") @patch("devx.tools.check_test_speed.parse_duration")
def test_slow_tests_exit( def test_slow_total_exits(
self, self,
mock_parse: MagicMock, mock_parse: MagicMock,
mock_run: MagicMock, mock_run: MagicMock,
) -> None: ) -> None:
mock_run.return_value = ("out\n", "err\n") mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 3.0 mock_parse.return_value = 15.0
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, []) result = runner.invoke(cli, [])
assert result.exit_code == 1 assert result.exit_code == 1
assert "too slow" in result.output.lower() 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") @patch("devx.tools.check_test_speed.run_tests")
def test_parse_failure_exits( def test_parse_failure_exits(
self, self,
@@ -125,16 +228,67 @@ class TestMain:
@patch("devx.tools.check_test_speed.run_tests") @patch("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration") @patch("devx.tools.check_test_speed.parse_duration")
@patch("devx.tools.check_test_speed.check_speed") @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( def test_custom_max_seconds(
self, self,
mock_check_per: MagicMock,
mock_parse_per: MagicMock,
mock_check: MagicMock, mock_check: MagicMock,
mock_parse: MagicMock, mock_parse: MagicMock,
mock_run: MagicMock, mock_run: MagicMock,
) -> None: ) -> None:
mock_run.return_value = ("out\n", "err\n") mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 0.5 mock_parse.return_value = 0.5
mock_parse_per.return_value = []
mock_check_per.return_value = []
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["--max-seconds", "1.5"]) result = runner.invoke(cli, ["--max-seconds", "1.5"])
assert result.exit_code == 0 assert result.exit_code == 0
mock_check.assert_called_once_with(0.5, 1.5) 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)
+1 -1
View File
@@ -37,7 +37,7 @@ class TestConfigConstants:
assert DEFAULT_PER_PAGE == 50 assert DEFAULT_PER_PAGE == 50
def test_owner(self) -> None: def test_owner(self) -> None:
assert REPO_OWNER == "oblachno-oss" assert REPO_OWNER == ""
def test_task_prefix(self) -> None: def test_task_prefix(self) -> None:
assert TASK_PREFIX == "DEVX" assert TASK_PREFIX == "DEVX"
+34
View File
@@ -163,3 +163,37 @@ class TestMain:
mock_client.ensure_branch_protection.assert_called_once() mock_client.ensure_branch_protection.assert_called_once()
args = mock_client.ensure_branch_protection.call_args args = mock_client.ensure_branch_protection.call_args
assert args[0][0] == "develop" assert args[0][0] == "develop"
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "oblachno/infra"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_parses_owner_repo_from_env(self, mock_client_cls: MagicMock) -> None:
"""DEVX_REPO_NAME with 'owner/repo' format should be split."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
# Verify GiteaClient was constructed with parsed owner and repo (positional)
call_args = mock_client_cls.call_args
assert call_args[0][2] == "oblachno" # owner is 3rd positional arg
assert call_args[0][3] == "infra" # repo is 4th positional arg
@patch.dict(
"os.environ",
{"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "infra", "DEVX_REPO_OWNER": "oblachno"},
clear=True,
)
@patch("devx.tools.configure_repo.REPO_OWNER", "oblachno")
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_no_slash_when_owner_set_separately(self, mock_client_cls: MagicMock) -> None:
"""When DEVX_REPO_OWNER is set, DEVX_REPO_NAME should not be split."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
call_args = mock_client_cls.call_args
assert call_args[0][2] == "oblachno" # owner
assert call_args[0][3] == "infra" # repo
+24 -6
View File
@@ -163,10 +163,7 @@ class TestCli:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["--list-platforms"]) result = runner.invoke(cli, ["--list-platforms"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "ubuntu-2204" in result.output assert "ubuntu-2604" in result.output
assert "ubuntu-2404" in result.output
assert "debian-12" in result.output
assert "archlinux" in result.output
def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None: def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None:
from click.testing import CliRunner from click.testing import CliRunner
@@ -198,7 +195,7 @@ class TestCli:
assert result.exit_code == 0 assert result.exit_code == 0
# Output should contain encoded pairs with platform info # Output should contain encoded pairs with platform info
assert "alpha|" in result.output assert "alpha|" in result.output
assert "ubuntu-2204" in result.output assert "ubuntu-2604" in result.output
class TestGithubEnv: class TestGithubEnv:
@@ -409,7 +406,7 @@ class TestCliMultiRole:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["--roles-root", str(roles), "--list-platforms"]) result = runner.invoke(cli, ["--roles-root", str(roles), "--list-platforms"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "ubuntu-2204" in result.output assert "ubuntu-2604" in result.output
def test_roles_root_no_runner_index_prints_groups(self, tmp_path: Path) -> None: def test_roles_root_no_runner_index_prints_groups(self, tmp_path: Path) -> None:
"""--roles-root without --runner-index prints all groups.""" """--roles-root without --runner-index prints all groups."""
@@ -422,6 +419,27 @@ class TestCliMultiRole:
assert "Runner 0:" in result.output assert "Runner 0:" in result.output
assert "Runner 1:" 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: def test_roles_root_skips_non_dir_role(self, tmp_path: Path) -> None:
"""Non-directory entries in roles root are skipped.""" """Non-directory entries in roles root are skipped."""
roles = tmp_path / "roles" roles = tmp_path / "roles"
+3
View File
@@ -131,6 +131,7 @@ class TestCli:
clear=True, clear=True,
), ),
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01), 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.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect), patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg, patch("os.killpg") as mock_killpg,
@@ -177,6 +178,7 @@ class TestCli:
clear=True, clear=True,
), ),
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01), 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.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect), patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg", side_effect=ProcessLookupError("no such process")), patch("os.killpg", side_effect=ProcessLookupError("no such process")),
@@ -221,6 +223,7 @@ class TestCli:
clear=True, clear=True,
), ),
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01), 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.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect), patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg, patch("os.killpg") as mock_killpg,
+15
View File
@@ -162,17 +162,26 @@ class TestCli:
with ( with (
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
patch("time.sleep"), patch("time.sleep"),
): ):
proc = MagicMock() proc = MagicMock()
proc.poll.return_value = 0 proc.poll.return_value = 0
proc.returncode = 0 proc.returncode = 0
mock_popen.return_value = proc mock_popen.return_value = proc
mock_run.return_value = MagicMock(returncode=0)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"]) result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "All molecule tests passed" in result.output assert "All molecule tests passed" in result.output
# Verify Docker prune was called between scenarios
mock_run.assert_called_once_with(
["docker", "system", "prune", "-af", "--volumes"],
check=False,
capture_output=True,
timeout=60,
)
def test_invalid_pair_format_raises(self) -> None: def test_invalid_pair_format_raises(self) -> None:
"""Pair with fewer than 2 parts should raise.""" """Pair with fewer than 2 parts should raise."""
@@ -324,6 +333,7 @@ class TestCli:
), ),
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01), 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.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs, patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
patch("time.sleep", side_effect=lambda x: real_sleep(0.05)), patch("time.sleep", side_effect=lambda x: real_sleep(0.05)),
): ):
@@ -332,6 +342,7 @@ class TestCli:
proc.poll.return_value = 0 proc.poll.return_value = 0
proc.returncode = 0 proc.returncode = 0
mock_popen.return_value = proc mock_popen.return_value = proc
mock_run.return_value = MagicMock(returncode=0)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"]) result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
@@ -536,12 +547,14 @@ class TestCliMultiRole:
with ( with (
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
patch("time.sleep"), patch("time.sleep"),
): ):
proc = MagicMock() proc = MagicMock()
proc.poll.return_value = 0 proc.poll.return_value = 0
proc.returncode = 0 proc.returncode = 0
mock_popen.return_value = proc mock_popen.return_value = proc
mock_run.return_value = MagicMock(returncode=0)
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
@@ -560,12 +573,14 @@ class TestCliMultiRole:
with ( with (
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
patch("time.sleep"), patch("time.sleep"),
): ):
proc = MagicMock() proc = MagicMock()
proc.poll.return_value = 0 proc.poll.return_value = 0
proc.returncode = 0 proc.returncode = 0
mock_popen.return_value = proc mock_popen.return_value = proc
mock_run.return_value = MagicMock(returncode=0)
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
+40 -7
View File
@@ -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: class TestPlatforms:
def test_platforms_not_empty(self) -> None: def test_platforms_not_empty(self) -> None:
assert len(PLATFORMS) >= 4 assert len(PLATFORMS) >= 1
def test_each_platform_has_required_keys(self) -> None: def test_each_platform_has_required_keys(self) -> None:
for p in PLATFORMS: for p in PLATFORMS:
@@ -17,9 +19,40 @@ class TestPlatforms:
names = [p["name"] for p in PLATFORMS] names = [p["name"] for p in PLATFORMS]
assert len(names) == len(set(names)) 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: def test_known_platforms_present(self) -> None:
names = {p["name"] for p in PLATFORMS} names = {p["name"] for p in PLATFORMS}
assert "ubuntu-2204" in names assert "ubuntu-2604" in names
assert "ubuntu-2404" in names
assert "debian-12" in names
assert "archlinux" 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
+11 -2
View File
@@ -270,6 +270,15 @@ class TestVerifyTagConsistency:
mock_tags.return_value = [] mock_tags.return_value = []
assert verify_tag_consistency() == [] assert verify_tag_consistency() == []
@patch("devx.ci.release.get_commit_version")
@patch("devx.ci.release.get_all_tags")
def test_non_version_tags_ignored(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None:
"""Non-version tags like 'master' should be skipped, not crash."""
mock_tags.return_value = ["v0.2.0", "master", "v0.1.0"]
mock_cv.side_effect = ["0.2.0", "0.1.0"] # only version tags get checked
errors = verify_tag_consistency()
assert errors == []
class TestGetInitVersion: class TestGetInitVersion:
def test_returns_version(self, tmp_path, monkeypatch) -> None: def test_returns_version(self, tmp_path, monkeypatch) -> None:
@@ -776,7 +785,7 @@ class TestCreateAndPushTag:
create_and_push_tag("0.2.0", "changelog", dry_run=False) create_and_push_tag("0.2.0", "changelog", dry_run=False)
calls = [c.args[0] for c in mock_run_cmd.call_args_list] calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls assert ["git", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls
assert ["git", "push", "origin", "v0.2.0"] in calls assert ["git", "push", "origin", "refs/tags/v0.2.0"] in calls
@patch("devx.ci.release.tag_exists", return_value=False) @patch("devx.ci.release.tag_exists", return_value=False)
@patch("devx.ci.release.run_cmd") @patch("devx.ci.release.run_cmd")
@@ -803,7 +812,7 @@ class TestCreateAndPushTag:
# Should not create tag, but should ensure it's pushed # Should not create tag, but should ensure it's pushed
calls = [c.args[0] for c in mock_run_cmd.call_args_list] calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "tag", "-a"] not in [c[:3] for c in calls] assert ["git", "tag", "-a"] not in [c[:3] for c in calls]
assert ["git", "push", "origin", "v0.1.0"] in calls assert ["git", "push", "origin", "refs/tags/v0.1.0"] in calls
@patch("devx.ci.release.get_head_commit", return_value="def456") @patch("devx.ci.release.get_head_commit", return_value="def456")
@patch("devx.ci.release.get_tag_commit", return_value="abc123") @patch("devx.ci.release.get_tag_commit", return_value="abc123")
+38 -8
View File
@@ -1,5 +1,6 @@
"""Unit tests for devx.tools.setup.""" """Unit tests for devx.tools.setup."""
import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -32,20 +33,48 @@ class TestRun:
class TestInstallPythonDeps: class TestInstallPythonDeps:
@patch("devx.tools.setup._run") @patch("devx.tools.setup.subprocess.run")
def test_install_dev(self, mock_run: MagicMock) -> None: def test_install_dev(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0)
_install_python_deps(".venv/bin", "dev") _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: def test_install_ci(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0)
_install_python_deps(".venv/bin", "ci") _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") @patch("devx.tools.setup._run")
def test_install_custom_extras(self, mock_run: MagicMock) -> None: @patch("devx.tools.setup.subprocess.run")
_install_python_deps(".venv/bin", "ci,lint") def test_install_retry_with_ignore_installed(self, mock_subprocess: MagicMock, mock_run: MagicMock) -> None:
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci,lint]"]) 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: class TestInstallPreCommitHooks:
@@ -60,8 +89,9 @@ class TestInstallPreCommitHooks:
class TestInstallAnsibleCollections: class TestInstallAnsibleCollections:
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/ansible-galaxy")
@patch("devx.tools.setup._run") @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 = tmp_path / "ansible" / "requirements.yml"
req.parent.mkdir(parents=True) req.parent.mkdir(parents=True)
req.write_text("collections: []") req.write_text("collections: []")
+278
View File
@@ -0,0 +1,278 @@
"""Unit tests for devx.molecule.start_docker."""
import os
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)
with patch.dict("os.environ", {"DOCKER_HOST": f"unix://{DOCKER_SOCK}"}, clear=False):
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
@patch("devx.molecule.start_docker.subprocess.run")
def test_uses_docker_host_env(self, mock_run: MagicMock) -> None:
"""Should check the socket specified by DOCKER_HOST env var."""
mock_run.return_value = MagicMock(returncode=0)
rootless = "unix:///run/user/999/docker.sock"
with patch.dict("os.environ", {"DOCKER_HOST": rootless}, clear=False):
assert is_docker_ready() is True
call_kwargs = mock_run.call_args
assert call_kwargs.kwargs["env"]["DOCKER_HOST"] == rootless
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]
with patch("devx.molecule.start_docker.glob.glob", return_value=[]):
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=True)
@patch("devx.molecule.start_docker.is_docker_ready")
def test_alt_rootless_socket_found(
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
) -> None:
"""Should find rootless socket at a different UID via glob scan."""
# Host fails, own rootless fails, alt rootless succeeds
mock_ready.side_effect = [False, False, True]
alt_sock = "/run/user/999/docker.sock"
with patch("devx.molecule.start_docker.glob.glob", return_value=[alt_sock]):
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=True)
@patch("devx.molecule.start_docker.is_docker_ready")
def test_alt_rootless_socket_skips_own(
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
) -> None:
"""Should skip the own rootless socket in glob scan (already tried)."""
# Host fails, own rootless fails, alt rootless also fails, dockerd fails
mock_ready.side_effect = [False, False, False, False, False, False]
own_sock = f"/run/user/{os.getuid()}/docker.sock"
alt_sock = "/run/user/999/docker.sock"
with (
patch("devx.molecule.start_docker.glob.glob", return_value=[own_sock, alt_sock]),
patch("devx.molecule.start_docker.time.sleep"),
patch("devx.molecule.start_docker.subprocess.Popen"),
patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile") as mock_ntf,
patch("builtins.open", mock_open(read_data="err")),
):
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
assert start_docker_daemon(timeout=2) is False
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
@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,
mock_glob: 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.glob.glob", return_value=[])
@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,
mock_glob: 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.glob.glob", return_value=[])
@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,
mock_glob: 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.glob.glob", return_value=[])
@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,
mock_glob: 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