Compare commits

...
35 Commits
Author SHA1 Message Date
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
devx-ci-bot 547fef4f27 release: v0.6.0 [skip ci] 2026-06-23 15:38:44 +02:00
emil 23183df7c7 DEVX-12: feat: add opentofu helpers, CLI entry points, shared utility, and CI improvements
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / release (push) Successful in 1m22s
Post-merge / vikunja (push) Successful in 32s
Post-merge / badges (push) Successful in 50s
Post-merge / sync-wiki (push) Successful in 57s
2026-06-23 13:37:10 +00:00
devx-ci-bot f382408115 release: v0.5.0 [skip ci] 2026-06-23 03:36:10 +02:00
emil 19bec24f45 DEVX-10: feat: add tag verification, idempotency, and --verify mode to release script
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / release (push) Successful in 50s
Post-merge / vikunja (push) Successful in 17s
Post-merge / sync-wiki (push) Successful in 40s
Post-merge / badges (push) Successful in 1m0s
2026-06-23 01:28:27 +00:00
devx-ci-bot 37772f21a9 release: v0.4.4 [skip ci] 2026-06-22 23:45:40 +02:00
emil b07132e3c6 DEVX-9: fix: configurable task prefix and CWD-relative DOCS_DIR 2026-06-22 21:44:35 +00:00
devx-ci-bot 7b3b604c2c release: v0.4.3 [skip ci] 2026-06-22 23:13:27 +02:00
emil 53990dc10c DEVX-8: fix: expand DEFAULT_INFRASTRUCTURE to cover all common project files 2026-06-22 21:12:16 +00:00
devx-ci-bot 8ea044a942 release: v0.4.2 [skip ci] 2026-06-22 22:55:31 +02:00
emil 8411c92c95 DEVX-7: fix: make all warnings into errors across devx tools 2026-06-22 20:54:25 +00:00
devx-ci-bot 621b9936c8 release: v0.4.1 [skip ci] 2026-06-22 22:22:57 +02:00
emil 33063038a1 DEVX-6: fix: correct version tags, changelog, and release script recovery 2026-06-22 20:21:44 +00:00
devx-ci-bot 0c3e8a7b8d release: v0.4.0 [skip ci] 2026-06-22 21:04:55 +02:00
emil 6436c5dd38 DEVX-5: feat: add DEFAULT_INFRASTRUCTURE and configurable task prefix 2026-06-22 19:03:53 +00:00
devx-ci-bot 33b09c162d release: v0.3.0 [skip ci] 2026-06-22 20:23:30 +02:00
emil a2c856d8b2 DEVX-4: feat: add --no-ansible-collections option to setup tool 2026-06-22 18:22:26 +00:00
devx-ci-bot 7d9a081c92 release: v0.2.0 [skip ci] 2026-06-22 19:32:37 +02:00
emil 87d730d8be DEVX-3: feat: pluggable change classification framework 2026-06-22 17:31:30 +00:00
devx-ci-bot 89a165be46 release: v0.1.2 [skip ci] 2026-06-22 19:17:18 +02:00
emil 388c3df043 DEVX-2: fix: make sync-wiki and vikunja depend on release 2026-06-22 17:16:02 +00:00
devx-ci-bot ac8a1d3be4 release: v0.1.1 [skip ci] 2026-06-22 19:04:48 +02:00
emil 07cca5de36 DEVX-1: fix: disable push whitelist, allow direct pushes to master
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 33s
Post-merge / configure-repo (push) Successful in 12s
Post-merge / release (push) Successful in 54s
Post-merge / sync-wiki (push) Successful in 1m0s
Post-merge / badges (push) Successful in 37s
2026-06-22 17:03:41 +00:00
60 changed files with 6521 additions and 757 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.tools.check_test_speed --max-seconds 10
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
- name: Documentation coverage check
env:
PYTHONPATH: src
+10 -6
View File
@@ -7,10 +7,15 @@ name: Post-merge
# Job dependency graph:
#
# detect-type ──┬── release (skip if release commit)
# ├── sync-wiki (skip if release commit)
# ├── badges (ALWAYS runs — even on release commits)
# ├── vikunja (skip if release commit)
# ── configure-repo (skip if release commit)
# ├── configure-repo (independent — skip if release commit)
# ── sync-wiki (needs release — skip if release commit/fails)
# └── vikunja (needs release — skip if release commit/fails)
#
# sync-wiki and vikunja depend on release succeeding so that the wiki
# and task tracker are only updated when the code is actually released.
# If release fails, they are skipped to avoid leaving the wiki or
# Vikunja in an inconsistent state with the codebase on master.
#
# The badges job depends on release so it picks up the latest version
# number. It uses `if: always()` with no is-release condition so it
@@ -106,7 +111,7 @@ jobs:
--commit "${{ github.sha }}"
sync-wiki:
needs: [detect-type]
needs: [detect-type, release]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
timeout-minutes: 10
@@ -173,7 +178,7 @@ jobs:
--commit "${{ github.sha }}"
vikunja:
needs: [detect-type]
needs: [detect-type, release]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
timeout-minutes: 10
@@ -221,7 +226,6 @@ jobs:
- name: Ensure branch protection and labels
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
DEVX_PUSH_WHITELIST: "emil"
PYTHONPATH: src
run: python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
- name: Notify on failure
+1 -1
View File
@@ -1 +1 @@
DEVX-1
DEVX-18
+13 -3
View File
@@ -55,16 +55,20 @@ src/devx/
├── translations.json # Translation strings (en, bg)
├── ci/ # CI/CD automation modules (run by workflows)
│ ├── release.py # Automated versioning, tagging, changelog
│ ├── publish.py # Build and publish to Gitea PyPI registry
│ ├── publish.py # Build and publish to Gitea PyPI registry (--skip-build for non-Python repos)
│ ├── auto_merge.py # Squash-merge PRs with task ID validation
│ ├── _shared.py # Shared utilities (get_latest_tag)
│ ├── classify_changes.py # User-facing vs workflow-only change detection
│ ├── detect_release_commit.py # Detect release commits on master
│ ├── validate_commit_msg.py # Conventional commit validation
│ ├── pr_review.py # Automated PR review
│ ├── post_merge.py # Vikunja task updates after merge
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
│ ├── push_badges.py # Generate and push quality badges
│ ├── notify_failure.py # Create Gitea issues on CI failures
│ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures)
│ ├── notify_failure.py # Create Gitea issues on CI failures (--auto-login)
│ ├── merge_junit.py # Merge JUnit XML reports from parallel runners
│ ├── distribute_files.py # Distribute files across parallel runners
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast + JUnit output
│ ├── check_translations.py # Translation completeness check
│ └── doc_coverage.py # Documentation coverage check
├── tools/ # Developer tooling modules (run locally or by CI)
@@ -73,7 +77,13 @@ src/devx/
│ ├── check_test_speed.py # Measure unit test execution time
│ ├── configure_repo.py # Branch protection and label setup
│ └── generate_badges.py # Badge SVG generation
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
├── discover_runners.py # Dynamic Gitea runner discovery
├── distribute_molecule.py # Distribute molecule scenarios across runners (--roles-root for multi-role)
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast + JUnit output (--roles-root, --junit-output)
├── molecule_all.py # Run all molecule scenarios locally
└── platforms.py # Supported molecule platforms
```
### Key Design Principles
+107 -14
View File
@@ -2,24 +2,117 @@
All notable changes to this project will be documented in this file.
## [0.8.4] - 2026-06-23
### Bug Fixes
- Add --ignore-installed to pip in CI to bypass debian packages
## [0.8.3] - 2026-06-23
### Bug Fixes
- Lower check_test_speed threshold to 4 seconds
- Pass --break-system-packages to pip in CI environments
## [0.8.2] - 2026-06-23
### Bug Fixes
- Encode spaces in pair commands to survive shell word-splitting
## [0.8.1] - 2026-06-23
### Bug Fixes
- Set fresh MOLECULE_HOME per pair to avoid stale config cache
## [0.8.0] - 2026-06-23
### Features
- Fix molecule platforms to use sleep infinity, add --platforms-file
## [0.7.0] - 2026-06-23
### Features
- Add per-test timing quality gate to check_test_speed
## [0.6.0] - 2026-06-23
### Features
- Add opentofu helpers, CLI entry points, shared utility, and CI improvements
## [0.5.0] - 2026-06-23
### Features
- Add tag verification, idempotency, and --verify mode to release script
## [0.4.4] - 2026-06-22
### Bug Fixes
- Configurable task prefix and CWD-relative DOCS_DIR
## [0.4.3] - 2026-06-22
### Bug Fixes
- Expand DEFAULT_INFRASTRUCTURE to cover all common project files
## [0.4.2] - 2026-06-22
### Bug Fixes
- Make all warnings into errors across devx tools
## [0.4.1] - 2026-06-22
### Bug Fixes
- Correct version tags, changelog, and release script recovery
## [0.4.0] - 2026-06-22
### Features
- Add DEFAULT_INFRASTRUCTURE and configurable task prefix
## [0.3.0] - 2026-06-22
### Features
- Add --no-ansible-collections option to setup tool
## [0.2.0] - 2026-06-22
### Features
- Pluggable change classification framework
## [0.1.2] - 2026-06-22
### Bug Fixes
- Make sync-wiki and vikunja depend on release
## [0.1.1] - 2026-06-22
### Bug Fixes
- Disable push whitelist, allow direct pushes to master
## [0.1.0] - 2026-06-22
### Features
- Extract reusable dev/CI tools from GRM into devx package
## [unreleased]
### Features
- Extract reusable development and CI/CD tools from GRM into a standalone Python package
- Port core modules: config, exceptions, i18n, api_clients, gitea_cli
- Port 14 CI scripts: auto_merge, check_translations, classify_changes, detect_release_commit, discover_runners, doc_coverage, notify_failure, post_merge, pr_review, publish, push_badges, release, sync_wiki, validate_commit_msg
- Port 6 dev tools: check_test_speed, generate_badges, install_checkmake, install_tools, setup, configure_repo
- Port 5 molecule tools as optional extra: platforms, distribute_molecule, discover_runners, molecule_ci_guard, molecule_all
- Add CLI entry point with subcommands: devx ci, devx tools, devx molecule
- Add Gitea PyPI registry publishing support in publish.py
- Add configurable workflow-only patterns in classify_changes.py
- Add configurable version file path in release.py
- Replicate GRM's automated workflow: CI, auto-merge, post-merge, release, badges, wiki sync, Vikunja
### Bug Fixes
- Use python3 and venv python in workflows and Makefile
- Fix post-merge job failures (configure-repo, badges, notify-failure)
- Allow release bot to push to protected master
+5 -2
View File
@@ -39,8 +39,8 @@ sort_commits = "oldest"
recurse_submodules = false
commit_preprocessors = [
# Strip DEVX-N task ID prefix from merge commits so git-cliff sees conventional commits
{ pattern = "^DEVX-\\d+\\s+", replace = "" },
# Strip DEVX-N: task ID prefix from squash-merge commits so git-cliff sees conventional commits
{ pattern = "^DEVX-\\d+:\\s+", replace = "" },
]
commit_parsers = [
@@ -66,3 +66,6 @@ commit_parsers = [
features_always_bump_minor = true
breaking_always_bump_major = false
initial_tag = "0.1.0"
# Refactor commits bump patch — structural changes to src/ or pyproject.toml
# affect users even though no new feature was added.
refactor_always_bump_patch = true
+30 -1
View File
@@ -24,10 +24,22 @@ Detect whether the latest git commit is a release commit (`release: vX.Y.Z [skip
Discover available Gitea Actions runners for dynamic job distribution.
### `devx ci distribute-files`
Distribute files across parallel runners (round-robin). Used for splitting test suites or workloads across CI runners.
### `devx ci doc-coverage`
Check documentation coverage for CLI commands and major modules.
### `devx ci integration-guard`
Run pytest with cross-runner failure detection and JUnit XML output. Monitors other runners for failures and aborts early if a critical failure is detected.
### `devx ci merge-junit`
Merge multiple JUnit XML reports from parallel runners into a single consolidated report.
### `devx ci notify-failure`
Create a Gitea issue when a CI workflow fails.
@@ -64,7 +76,13 @@ Validate commit messages for conventional commit format.
### `devx tools check-test-speed`
Run unit tests and enforce a maximum execution-time budget.
Run unit tests and enforce execution-time budgets:
- **Total suite time** must not exceed `--max-seconds` (default: 10s).
- **Per-test time** — no individual test may exceed `--max-single-seconds` (default: 0.5s, 0 to disable).
```bash
python3 -m devx.tools.check_test_speed --max-seconds 10 --max-single-seconds 0.5
```
### `devx tools configure-repo`
@@ -74,6 +92,17 @@ Configure repository: branch protection + labels via Gitea API.
Generate self-contained SVG badge files from project metrics.
### `devx tools generate-cliff-config`
Generate a `cliff.toml` configuration file with the correct task ID prefix.
Eliminates the need to manually duplicate and maintain cliff.toml across
repos that use devx.
```bash
python -m devx.tools.generate_cliff_config --prefix GRM
python -m devx.tools.generate_cliff_config --prefix GRM --force # overwrite existing
```
### `devx tools install-checkmake`
Install checkmake (Makefile linter) if not already present.
+4 -3
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# pre-commit hook: fail if unit tests take longer than 10 seconds.
# Aligned with CI timeout (ci.yml uses --max-seconds 10).
# pre-commit hook: fail if unit tests are too slow.
# Checks both total suite time (10s) and per-test time (0.5s).
# Aligned with CI (ci.yml uses same thresholds).
set -e
export PYTHONPATH=src
python3 -m devx.tools.check_test_speed --max-seconds 10
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
+41
View File
@@ -83,3 +83,44 @@ indent-style = "space"
include = ["src"]
pythonVersion = "3.12"
strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "src/devx/api_clients.py", "src/devx/gitea_cli.py"]
# ---------------------------------------------------------------------------
# Change classification — determines which changes trigger a release
# ---------------------------------------------------------------------------
# The framework provides DEFAULT_INFRASTRUCTURE (CI workflows, tests, docs,
# lint config, etc.) that applies to any Python project. We only specify
# what's different about devx.
#
# Rule priority (first match wins):
# 1. user_facing_overrides (safety — highest priority)
# 2. infrastructure_overrides (explicit per-file)
# 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns)
# 4. Default: user-facing (safe)
[tool.devx.classify]
# use_defaults = true # (default) merge with DEFAULT_INFRASTRUCTURE
# Project-specific infrastructure paths (merged with defaults).
# devx has no additional infrastructure paths — everything not in the
# defaults is user-facing (src/devx/**, pyproject.toml, translations.json).
infrastructure = []
# Infrastructure overrides — files that would default to user-facing
# but are actually infrastructure:
# - __init__.py: only contains __version__ (set by release.py, not user code)
#
# NOTE: api_clients.py is NOT here — it's used by devx's CI modules
# (auto_merge.py, release.py, pr_review.py, etc.) which consumer projects
# call via `python -m devx.ci.*`. Changes to api_clients.py affect consumer
# projects' CI behavior, so it IS user-facing.
infrastructure_overrides = [
"src/devx/__init__.py",
]
# User-facing overrides — safety override for broad infrastructure patterns
# (empty — add when an infrastructure pattern is too broad)
user_facing_overrides = []
# Tag patterns — additional categories for CI conditional execution
# Orthogonal to release impact (user-facing vs infrastructure)
[tool.devx.classify.tags]
# No tags needed for devx itself — it has no ansible/ directory
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.1.0"
__version__ = "0.8.4"
+2 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import logging
import time
from typing import Any
@@ -21,7 +22,7 @@ def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
try:
body: dict[str, Any] = response.json() if response is not None else {}
message: str = body.get("message", str(e))
except Exception:
except (json.JSONDecodeError, ValueError, AttributeError):
message = str(e)
return status, message
+18
View File
@@ -0,0 +1,18 @@
"""Shared utilities for CI modules."""
from __future__ import annotations
import subprocess # nosec B404
def get_latest_tag() -> str:
"""Get the latest git tag, or empty string if none exists."""
result = subprocess.run( # nosec B603 B607
["git", "describe", "--tags", "--abbrev=0"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return ""
return result.stdout.strip()
+28 -19
View File
@@ -6,8 +6,11 @@ Runs as the final job in ci.yml. Reads the task ID from ``.taskid`` file
validates the PR title, and squash-merges with a conventional commit
message prefixed by the task ID.
PR title format: ``DEVX-N: <vikunja task title>``
Merge commit format: ``DEVX-N: <conventional commit message>``
PR title format: ``{PREFIX}-N: <vikunja task title>``
Merge commit format: ``{PREFIX}-N <conventional commit message>``
The ``{PREFIX}`` is determined by ``DEVX_TASK_PREFIX`` (default: ``DEVX``).
Each project sets its own prefix (e.g., ``GRM``, ``INFRA``).
The conventional commit message is extracted from the PR commits.
This allows the PR title to be a human-friendly Vikunja task title
@@ -32,6 +35,7 @@ from devx.config import (
DEFAULT_PER_PAGE,
GITEA_API_URL,
TASK_ID_RE,
TASK_PREFIX,
VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID,
)
@@ -39,7 +43,7 @@ from devx.exceptions import APIError
from devx.i18n import _
TASKID_FILE = ".taskid"
PR_TITLE_RE = re.compile(r"^DEVX-\d+:\s+.+")
PR_TITLE_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+")
load_dotenv()
@@ -84,14 +88,15 @@ def extract_task_id(branch: str) -> str:
def validate_pr_title(pr_title: str, task_id: str) -> None:
"""Raise ClickException if PR title does not follow the required format.
Expected: ``DEVX-N: <vikunja task title>``
Expected: ``{PREFIX}-N: <vikunja task title>``
"""
if not PR_TITLE_RE.match(pr_title):
raise click.ClickException(
_(
"Oops! PR title must follow format 'DEVX-N: <task title>'.\n"
"Oops! PR title must follow format '{prefix}-N: <task title>'.\n"
" Expected: {task_id}: <task title>\n"
" Got: {pr_title}",
prefix=TASK_PREFIX,
task_id=task_id,
pr_title=pr_title,
)
@@ -109,12 +114,11 @@ def validate_pr_title(pr_title: str, task_id: str) -> None:
def get_vikunja_task_title(task_id: str) -> str:
"""Fetch the Vikunja task title for the given DEVX-N identifier.
Returns empty string if VIKUNJA_TOKEN is not set (local dev without token).
Raises ClickException if the token is set but the task is not found.
Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found.
"""
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
return ""
raise click.ClickException(_("VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles."))
client = VikunjaClient(VIKUNJA_API_URL, token)
page = 1
while True:
@@ -140,14 +144,10 @@ def get_vikunja_task_title(task_id: str) -> str:
def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None:
"""Validate that PR title matches the Vikunja task title.
Skips validation if VIKUNJA_TOKEN is not set (local dev).
Raises ClickException if the task is not found or the title doesn't match.
Raises ClickException if VIKUNJA_TOKEN is not set, the task is not found,
or the title doesn't match.
"""
vikunja_title = get_vikunja_task_title(task_id)
if not vikunja_title:
# VIKUNJA_TOKEN not set — skip validation (local dev)
click.echo(_("Warning: VIKUNJA_TOKEN not set, skipping title match validation."))
return
expected = f"{task_id}: {vikunja_title}"
if pr_title != expected:
raise click.ClickException(
@@ -188,7 +188,16 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
owner, repo_name = repo.split("/")
# Validate PR number is an integer
try:
pr_num = int(pr_number)
except ValueError:
raise click.ClickException(_("PR number must be an integer, got: {pr_number}", pr_number=pr_number)) from None
# Validate repo format
if "/" not in repo:
raise click.ClickException(_("Repo must be in 'owner/name' format, got: {repo}", repo=repo))
owner, repo_name = repo.split("/", 1)
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
task_id = read_taskid(branch)
@@ -205,14 +214,14 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
validate_pr_title_matches_vikunja(pr_title, task_id)
# Build merge title: DEVX-N: <conventional commit message>
commits = client.get_pr_commits(pr_number)
commits = client.get_pr_commits(pr_num)
conv_msg = extract_conventional_msg(commits)
if not conv_msg:
raise click.ClickException(_("Could not extract conventional commit message from PR commits."))
merge_title = f"{task_id}: {conv_msg}"
try:
client.merge_pr(pr_number, merge_title)
client.merge_pr(pr_num, merge_title)
except APIError as e:
if e.status == 405 and "behind" in e.message.lower():
# Head branch is behind master — pull master and rebase, then retry
@@ -224,7 +233,7 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
run_cmd(["git", "rebase", "origin/master"])
run_cmd(["git", "push", "--force-with-lease", "origin", f"HEAD:{branch}"])
click.echo(_("Rebased and pushed. Retrying merge..."))
client.merge_pr(pr_number, merge_title)
client.merge_pr(pr_num, merge_title)
except (APIError, Exception) as retry_err:
raise click.ClickException(
_(
@@ -245,7 +254,7 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
click.echo(
_(
"Nice! PR #{pr_number} squash-merged with title: {merge_title}",
pr_number=pr_number,
pr_number=pr_num,
merge_title=merge_title,
)
)
+8 -17
View File
@@ -12,13 +12,13 @@ Checks performed (all fail with exit code 1 on error):
translations file.
- **Dead keys**: a key in a translations file is not used in any code.
- **Missing languages**: a key exists but is missing one of the 5 supported
languages (en, bg, de, ru, zh). Reported as a warning, not an error.
languages (en, bg, de, ru, zh). This is an error — all supported languages
must have translations for every key.
Usage::
python3 -m devx.ci.check_translations
python3 -m devx.ci.check_translations --translations path/to/translations.json
python3 -m devx.ci.check_translations --strict # warnings are errors
"""
from __future__ import annotations
@@ -130,14 +130,15 @@ def check_translation_set(name: str, src_dir: Path, trans_file: Path) -> Transla
# Check for dead keys (in translations but not used in code)
result.dead_keys = result.defined_keys - result.used_keys
for key in sorted(result.dead_keys):
result.warnings.append(f"Dead key in {name}: {key!r}")
result.errors.append(f"Dead key in {name}: {key!r}")
# Check for missing languages
# Check for missing languages — this is an error, not a warning.
# All supported languages must have translations for every key.
for key, langs in translations.items():
missing = [lang for lang in SUPPORTED_LANGS if lang not in langs]
if missing:
result.missing_langs[key] = missing
result.warnings.append(f"Missing languages {missing} for key {key!r} in {name}")
result.errors.append(f"Missing languages {missing} for key {key!r} in {name}")
return result
@@ -170,8 +171,7 @@ def print_result(result: TranslationCheckResult) -> None:
type=click.Path(exists=False, path_type=Path),
help="Path to a translations JSON file to check (can be repeated). Defaults to src/devx/translations.json.",
)
@click.option("--strict", is_flag=True, default=False, help="Treat warnings as errors.")
def main(translations: tuple[Path, ...], strict: bool) -> None:
def main(translations: tuple[Path, ...]) -> None:
"""Check translation files for gaps, dead keys, and missing languages."""
if not translations:
# Default: check the devx package's own translations
@@ -187,25 +187,16 @@ def main(translations: tuple[Path, ...], strict: bool) -> None:
results.append(check_translation_set(name, src_dir, trans_file))
has_errors = False
has_warnings = False
for result in results:
print_result(result)
if result.errors:
has_errors = True
if result.warnings:
has_warnings = True
click.echo()
if has_errors:
click.echo("FAIL: Translation check found errors.", err=True)
sys.exit(1)
if strict and has_warnings:
click.echo("FAIL: Translation check found warnings (--strict mode).", err=True)
sys.exit(1)
if has_warnings:
click.echo("PASS with warnings: Translation check passed (warnings present).")
else:
click.echo("PASS: All translations are complete and up to date.")
click.echo("PASS: All translations are complete and up to date.")
if __name__ == "__main__": # pragma: no cover
+554 -147
View File
@@ -1,106 +1,475 @@
#!/usr/bin/env python3
"""Classify git changes as user-facing or workflow-only.
"""Classify git changes as user-facing or infrastructure.
Determines whether changes between two git refs (e.g., last tag and HEAD)
affect the tool itself (user-facing) or only the CI/CD infrastructure
affect the published package (user-facing) or only the CI/CD infrastructure
(workflow-only). This is used by:
- **release.py** — skips release when only workflow files changed
- **release.py** — skips release when only infrastructure files changed
- **CI workflow** — skips molecule tests and release dry-run when only
workflow files changed
infrastructure files changed
Classification strategy (safe-by-default):
== Design Philosophy ==
Any file that is NOT in the explicit workflow-only allowlist is treated
as user-facing. This ensures new file types default to requiring a
release rather than silently skipping it.
**Safe-by-default**: Any file that doesn't match a rule defaults to
user-facing. This prevents new file types from accidentally skipping
releases — a critical safety property. When in doubt, release.
The workflow-only patterns are configurable via the ``patterns``
parameter on ``classify_changes()`` and ``has_user_facing_changes()``.
The default set (``DEFAULT_WORKFLOW_ONLY_PATTERNS``) covers common
infrastructure paths. Each project can pass its own frozenset to
accommodate different source layouts.
**Framework-provided defaults**: The framework ships with
``DEFAULT_INFRASTRUCTURE`` — a curated list of paths that are
infrastructure for ANY Python project (CI workflows, tests, docs,
lint config, etc.). Projects inherit these automatically and only
need to specify what's *different* about their project.
Default workflow-only paths (infrastructure → no release needed):
- .gitea/workflows/** — Gitea Actions workflows
- scripts/** — All scripts (CI/CD, dev tools, setup)
- src/devx/__init__.py — Version file (release artifact)
- src/devx/api_clients.py — Gitea API client (CI/CD only, not used by CLI)
- docs/** — Documentation
- tests/** — Test files
- hooks/** — Git hooks
- AGENTS.md — Agent conventions
- README.md — README (lean, links to wiki)
- CHANGELOG.md — Changelog (generated)
- TROUBLESHOOTING.md — Troubleshooting guide
- cliff.toml — git-cliff config
- Makefile — Build automation
- .pre-commit-config.yaml — Pre-commit config
- .ansible-lint — Ansible lint config
- .env.example — Environment template
- .gitignore — Git ignore rules
- .ruff.toml — Ruff config (if separate)
- .github/** — GitHub config (if present)
**Config-driven**: Classification rules are read from ``[tool.devx.classify]``
in ``pyproject.toml``. No project needs to modify the framework code.
Each project declares its own paths; the framework handles the logic.
Everything else is user-facing (tool changes → release needed),
including but not limited to:
- src/devx/*.py — Python CLI source (except __init__.py)
- ansible/** — Ansible role
- pyproject.toml — Package metadata
- Any new file type not in the allowlist
**Layered rules** (evaluated in priority order):
1. **User-facing overrides** (highest priority — safety override)
Files that match infrastructure patterns but MUST be treated as
user-facing. Use this when an infrastructure pattern is too broad.
2. **Infrastructure overrides**
Files that would default to user-facing but are actually
infrastructure (e.g., ``src/pkg/__init__.py`` which only contains
``__version__`` — a release artifact, not user-facing code).
3. **Infrastructure patterns** (deny-list)
Path globs matching infrastructure files. This is the union of
``DEFAULT_INFRASTRUCTURE`` and the project's ``infrastructure`` list.
Changes to these don't trigger a release.
4. **Default**: user-facing (lowest priority — safe default)
**Tag system** (orthogonal to release impact):
Projects can define custom tags (e.g., ``ansible``, ``docs``) for CI
conditional execution. A file can be both infrastructure (no release)
and tagged ``ansible`` (run molecule tests). Tags are evaluated
independently of the user-facing/infrastructure classification.
The ``--check`` CLI option accepts any tag name defined in the config,
and ``--github-output`` writes ``<tag>-changed`` for each configured tag.
== Configuration ==
In ``pyproject.toml``::
[tool.devx.classify]
# Whether to merge with DEFAULT_INFRASTRUCTURE (default: true).
# Set to false to specify all patterns explicitly.
# use_defaults = true
# Project-specific infrastructure paths (merged with defaults).
# Only list paths NOT already in DEFAULT_INFRASTRUCTURE.
infrastructure = [
"scripts/**", # e.g., if scripts/ is dev-only tooling
]
# Infrastructure overrides — files that would default to user-facing
# but are actually infrastructure
infrastructure_overrides = [
"src/mypkg/__init__.py", # only contains __version__
]
# User-facing overrides — safety override for broad infrastructure patterns
# (empty by default)
user_facing_overrides = []
# Tag patterns — additional categories for CI conditional execution
[tool.devx.classify.tags]
ansible = ["ansible/**", ".ansible-lint"]
== What counts as "user-facing" ==
A change is user-facing if it affects the behavior of the installed
package. For a library/CLI tool, this means:
- Source code in ``src/`` (except ``__init__.py`` which only holds
``__version__``)
- Package metadata (``pyproject.toml`` — dependencies, entry points)
- Ansible roles, playbooks, templates (if the project ships Ansible)
- Translation files (user-visible messages)
- Any file not explicitly classified as infrastructure
A change is infrastructure if it only affects the project's own
development/CI environment:
- CI/CD workflows (``.gitea/**``, ``.github/**``)
- Tests (``tests/**``)
- Documentation (``docs/**``, ``README.md``, ``CHANGELOG.md``)
- Linting/formatting config (``.ruff.toml``, ``.pre-commit-config.yaml``)
- Build tooling (``Makefile``, ``cliff.toml``)
- Git hooks (``hooks/**``)
- Generated scripts (``activate.sh``, ``activate.fish``, ``activate.zsh``)
== Glob Syntax ==
Patterns support standard glob syntax:
- ``**`` matches any number of path segments (including zero)
- ``*`` matches any characters within a single path segment
- ``?`` matches a single character within a single path segment
- Everything else is matched literally
Examples:
- ``.gitea/**`` matches ``.gitea/workflows/ci.yml``, ``.gitea/actionlint.yaml``
- ``tests/**`` matches ``tests/unit/test_cli.py``, ``tests/conftest.py``
- ``src/devx/__init__.py`` matches exactly that file
- ``Makefile`` matches exactly that file
Usage:
python3 -m devx.ci.classify_changes [--base <ref>] [--head <ref>]
python3 -m devx.ci.classify_changes --base v0.3.0 --head HEAD
python3 -m devx.ci.classify_changes --check ansible --quiet
python3 -m devx.ci.classify_changes --github-output
"""
from __future__ import annotations
import os
import re
import subprocess # nosec B404
import sys
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import click
from devx.ci._shared import get_latest_tag
from devx.i18n import _
# Explicit allowlist of workflow-only path patterns.
# Anything NOT matching these is treated as user-facing (safe default).
# This is the default set — projects can override via the ``patterns``
# parameter on classify_changes() / has_user_facing_changes().
DEFAULT_WORKFLOW_ONLY_PATTERNS: frozenset[str] = frozenset(
[
# CI/CD infrastructure
".gitea/",
# All scripts are infrastructure (CI/CD, dev tools, setup)
# User-facing code lives in src/devx/
"scripts/",
# Version file — only contains __version__, not user-facing code.
# Version bumps are a release artifact, not a feature.
"src/devx/__init__.py",
# Gitea API client — used only by CI/CD scripts, not by the CLI.
"src/devx/api_clients.py",
# Documentation
"docs/",
"AGENTS.md",
"README.md",
"CHANGELOG.md",
"TROUBLESHOOTING.md",
# Tests
"tests/",
# Config / build automation
"cliff.toml",
"Makefile",
".pre-commit-config.yaml",
".ansible-lint",
".env.example",
".gitignore",
".ruff.toml",
# Hooks
"hooks/",
# GitHub (if ever added)
".github/",
]
)
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class FileClassification:
"""Result of classifying a single file.
Attributes:
path: The file path relative to repo root.
is_user_facing: True if changes to this file require a release.
reason: Human-readable explanation of the classification.
matched_rule: Which rule matched (e.g., "infrastructure: .gitea/**").
None if the default rule was used.
tags: Custom category tags (e.g., {"ansible"}).
"""
path: str
is_user_facing: bool
reason: str
matched_rule: str | None
tags: frozenset[str] = frozenset()
@dataclass
class ClassificationResult:
"""Result of classifying a set of changed files.
Attributes:
files: Per-file classification details.
user_facing: List of file paths classified as user-facing.
infrastructure: List of file paths classified as infrastructure.
tags: Dict mapping tag name to list of file paths matching that tag.
"""
files: list[FileClassification] = field(default_factory=list)
user_facing: list[str] = field(default_factory=list)
infrastructure: list[str] = field(default_factory=list)
tags: dict[str, list[str]] = field(default_factory=dict)
@property
def has_user_facing(self) -> bool:
"""True if any user-facing files were found."""
return bool(self.user_facing)
def has_tag(self, tag: str) -> bool:
"""True if any files matched the given tag."""
return bool(self.tags.get(tag))
# ---------------------------------------------------------------------------
# Glob matching
# ---------------------------------------------------------------------------
def _glob_to_regex(pattern: str) -> re.Pattern[str]:
"""Convert a glob pattern to a compiled regex.
Supports:
- ``**`` -> matches any number of path segments (including zero)
- ``*`` -> matches any chars within a single path segment
- ``?`` -> matches a single char within a path segment
- All other characters are matched literally
"""
# Handle ** at the end (e.g., ".gitea/**")
# ** matches anything including slashes
parts: list[str] = []
i = 0
while i < len(pattern):
c = pattern[i]
if c == "*" and i + 1 < len(pattern) and pattern[i + 1] == "*":
parts.append(".*")
i += 2
# Skip trailing slash after **
if i < len(pattern) and pattern[i] == "/":
i += 1
elif c == "*":
parts.append("[^/]*")
i += 1
elif c == "?":
parts.append("[^/]")
i += 1
else:
parts.append(re.escape(c))
i += 1
return re.compile("^" + "".join(parts) + "$")
def _matches_glob(file_path: str, pattern: str) -> bool:
"""Check if a file path matches a glob pattern.
Also supports prefix matching: if the pattern ends with ``/``,
any file starting with that prefix matches. This is a convenience
for patterns like ``.gitea/`` (equivalent to ``.gitea/**``).
"""
# Prefix matching for patterns ending with /
if pattern.endswith("/") and (file_path.startswith(pattern) or file_path == pattern.rstrip("/")):
return True
return _glob_to_regex(pattern).match(file_path) is not None
# ---------------------------------------------------------------------------
# Classifier
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Default infrastructure patterns
# ---------------------------------------------------------------------------
# Common infrastructure paths that apply to ANY Python project using devx.
# Projects inherit these automatically and only need to specify project-specific
# paths in their [tool.devx.classify] section.
#
# Rationale: these files/directories are development tooling, CI/CD config,
# or generated artifacts. Changes to them don't affect the installed package's
# behavior, so they don't warrant a release.
DEFAULT_INFRASTRUCTURE: list[str] = [
# CI/CD workflow definitions
".gitea/**",
".github/**",
# Test files
"tests/**",
# Documentation
"docs/**",
# Git hooks
"hooks/**",
# Build tooling
"Makefile",
"cliff.toml",
"uv.lock",
# Linting / formatting config
".pre-commit-config.yaml",
".ruff.toml",
".ansible-lint",
".checkmake.ini",
".editorconfig",
# Environment templates (not the actual .env which is gitignored)
".env.example",
# Git config
".gitignore",
".gitattributes",
# Project-level documentation (not part of the installed package)
"AGENTS.md",
"README.md",
"CHANGELOG.md",
"TROUBLESHOOTING.md",
"CONTRIBUTING.md",
"CODE_OF_CONDUCT.md",
"REVIEW_CHECKLIST.md",
# Agent/CI tooling config (not part of the installed package)
".devin/**",
# Generated venv activation scripts (created by `make setup`)
"activate.sh",
"activate.fish",
"activate.zsh",
# CI task tracking file (written by CI, not by developers)
".taskid",
]
@dataclass
class ClassifierConfig:
"""Configuration for the change classifier.
Loaded from ``[tool.devx.classify]`` in ``pyproject.toml``.
By default, the framework's ``DEFAULT_INFRASTRUCTURE`` patterns are
merged with the project's ``infrastructure`` list. Set
``use_defaults = false`` to disable defaults and specify all
patterns explicitly.
Attributes:
infrastructure: Glob patterns for infrastructure paths
(merged with DEFAULT_INFRASTRUCTURE unless use_defaults is False).
infrastructure_overrides: Exact paths that are infrastructure
despite not matching any infrastructure pattern.
user_facing_overrides: Exact paths that are user-facing
despite matching an infrastructure pattern (safety override).
tags: Dict mapping tag name to list of glob patterns.
use_defaults: If True (default), merge with DEFAULT_INFRASTRUCTURE.
"""
infrastructure: list[str] = field(default_factory=list)
infrastructure_overrides: list[str] = field(default_factory=list)
user_facing_overrides: list[str] = field(default_factory=list)
tags: dict[str, list[str]] = field(default_factory=dict)
use_defaults: bool = True
@classmethod
def from_pyproject(cls, pyproject_path: str = "pyproject.toml") -> ClassifierConfig:
"""Load classifier config from pyproject.toml.
Reads the ``[tool.devx.classify]`` section. If the section or
file is missing, returns a config with only DEFAULT_INFRASTRUCTURE
(everything else defaults to user-facing — safe-by-default).
"""
path = Path(pyproject_path)
if not path.exists():
return cls(infrastructure=list(DEFAULT_INFRASTRUCTURE))
with open(path, "rb") as f: # noqa: PTH123
data: dict[str, Any] = tomllib.load(f)
classify_cfg = data.get("tool", {}).get("devx", {}).get("classify", {})
use_defaults = classify_cfg.get("use_defaults", True)
project_infra = list(classify_cfg.get("infrastructure", []))
if use_defaults:
# Merge defaults with project-specific patterns (deduplicated)
merged = list(DEFAULT_INFRASTRUCTURE)
for p in project_infra:
if p not in merged:
merged.append(p)
infrastructure = merged
else:
infrastructure = project_infra
return cls(
infrastructure=infrastructure,
infrastructure_overrides=list(classify_cfg.get("infrastructure_overrides", [])),
user_facing_overrides=list(classify_cfg.get("user_facing_overrides", [])),
tags={k: list(v) for k, v in classify_cfg.get("tags", {}).items()},
use_defaults=use_defaults,
)
class ChangeClassifier:
"""Classify changed files as user-facing or infrastructure.
Uses layered rules with safe-by-default semantics.
Rule evaluation order (first match wins):
1. User-facing overrides (safety — highest priority)
2. Infrastructure overrides
3. Infrastructure patterns
4. Default: user-facing (safe)
"""
def __init__(self, config: ClassifierConfig | None = None) -> None:
self.config = config or ClassifierConfig.from_pyproject()
# Pre-compile infrastructure patterns for efficiency
self._infra_patterns = list(self.config.infrastructure)
self._infra_overrides = set(self.config.infrastructure_overrides)
self._user_overrides = set(self.config.user_facing_overrides)
def classify_file(self, file_path: str) -> FileClassification:
"""Classify a single file path.
Returns a FileClassification with the decision and reason.
"""
tags = self._compute_tags(file_path)
# 1. User-facing overrides (highest priority — safety)
if file_path in self._user_overrides:
return FileClassification(
path=file_path,
is_user_facing=True,
reason="User-facing override (safety override)",
matched_rule="user_facing_overrides",
tags=tags,
)
# 2. Infrastructure overrides
if file_path in self._infra_overrides:
return FileClassification(
path=file_path,
is_user_facing=False,
reason="Infrastructure override (explicitly listed)",
matched_rule="infrastructure_overrides",
tags=tags,
)
# 3. Infrastructure patterns
for pattern in self._infra_patterns:
if _matches_glob(file_path, pattern):
return FileClassification(
path=file_path,
is_user_facing=False,
reason=f"Infrastructure (matches '{pattern}')",
matched_rule=f"infrastructure: {pattern}",
tags=tags,
)
# 4. Default: user-facing (safe-by-default)
return FileClassification(
path=file_path,
is_user_facing=True,
reason="User-facing (default — not in infrastructure patterns)",
matched_rule=None,
tags=tags,
)
def classify(self, files: list[str]) -> ClassificationResult:
"""Classify a list of changed files.
Returns a ClassificationResult with per-file details and
aggregated lists.
"""
result = ClassificationResult()
all_tags: dict[str, list[str]] = {}
for f in files:
fc = self.classify_file(f)
result.files.append(fc)
if fc.is_user_facing:
result.user_facing.append(f)
else:
result.infrastructure.append(f)
for tag in fc.tags:
all_tags.setdefault(tag, []).append(f)
result.tags = all_tags
return result
def _compute_tags(self, file_path: str) -> frozenset[str]:
"""Compute custom category tags for a file path."""
matched: set[str] = set()
for tag_name, patterns in self.config.tags.items():
for pattern in patterns:
if _matches_glob(file_path, pattern):
matched.add(tag_name)
break
return frozenset(matched)
# ---------------------------------------------------------------------------
# Git helpers
# ---------------------------------------------------------------------------
def run_git(args: list[str]) -> str:
@@ -126,27 +495,49 @@ def get_changed_files(base: str, head: str) -> list[str]:
return output.split("\n")
# ---------------------------------------------------------------------------
# Backward-compatible API (used by release.py and CI workflows)
# ---------------------------------------------------------------------------
# Singleton classifier — loaded lazily from pyproject.toml
_classifier: ChangeClassifier | None = None
def _get_classifier() -> ChangeClassifier:
"""Get or create the singleton classifier from pyproject.toml."""
global _classifier # noqa: PLW0603
if _classifier is None:
_classifier = ChangeClassifier()
return _classifier
def is_workflow_only(
file_path: str,
patterns: frozenset[str] | None = None,
) -> bool:
"""Check if a file path is workflow-only (infrastructure, not the tool itself).
"""Check if a file path is infrastructure (not user-facing).
Uses an explicit allowlist — anything not in the list is treated as
user-facing (safe default that prevents accidental release skips).
Backward-compatible API. Prefer ``ChangeClassifier.classify_file()``
for new code.
Args:
file_path: Path relative to repo root.
patterns: Deprecated. If provided, uses simple prefix matching
against these patterns instead of the config-driven classifier.
"""
p = patterns if patterns is not None else DEFAULT_WORKFLOW_ONLY_PATTERNS
return any(file_path.startswith(pattern) or file_path == pattern for pattern in p)
if patterns is not None:
# Legacy mode — simple prefix matching
return any(file_path.startswith(p) or file_path == p for p in patterns)
return not _get_classifier().classify_file(file_path).is_user_facing
def is_user_facing(
file_path: str,
patterns: frozenset[str] | None = None,
) -> bool:
"""Check if a file path is user-facing (affects the tool).
"""Check if a file path is user-facing (affects the released package).
Inverse of is_workflow_only — anything not explicitly workflow-only
is treated as user-facing.
Inverse of ``is_workflow_only()``.
"""
return not is_workflow_only(file_path, patterns)
@@ -159,14 +550,19 @@ def classify_changes(
Returns a dict with keys "user_facing" and "workflow_only".
"""
user_facing: list[str] = []
workflow_only: list[str] = []
for f in files:
if is_user_facing(f, patterns):
user_facing.append(f)
else:
workflow_only.append(f)
return {"user_facing": user_facing, "workflow_only": workflow_only}
if patterns is not None:
# Legacy mode
user_facing: list[str] = []
workflow_only: list[str] = []
for f in files:
if is_user_facing(f, patterns):
user_facing.append(f)
else:
workflow_only.append(f)
return {"user_facing": user_facing, "workflow_only": workflow_only}
result = _get_classifier().classify(files)
return {"user_facing": result.user_facing, "workflow_only": result.infrastructure}
def has_user_facing_changes(
@@ -176,30 +572,21 @@ def has_user_facing_changes(
) -> bool:
"""Check if any user-facing files changed between base and head.
Imported by ``devx.ci.release`` to decide whether a release
is needed. This is a cross-CI import that requires ``PYTHONPATH=.``.
Imported by ``devx.ci.release`` to decide whether a release is needed.
"""
files = get_changed_files(base, head)
return any(is_user_facing(f, patterns) for f in files)
if patterns is not None:
return any(is_user_facing(f, patterns) for f in files)
return _get_classifier().classify(files).has_user_facing
def get_latest_tag() -> str:
"""Get the latest git tag, or empty string if none exists."""
result = subprocess.run( # nosec B603 B607
["git", "describe", "--tags", "--abbrev=0"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return ""
return result.stdout.strip()
# ---------------------------------------------------------------------------
# Gitea Actions output
# ---------------------------------------------------------------------------
def _write_github_output(key: str, value: str) -> None:
"""Append a key=value line to the $GITHUB_OUTPUT file."""
import os
gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
@@ -207,30 +594,41 @@ def _write_github_output(key: str, value: str) -> None:
f.write(f"{key}={value}\n")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
@click.command()
@click.option("--base", default=None, help="Base ref (default: latest tag).")
@click.option("--head", default="HEAD", help="Head ref (default: HEAD).")
@click.option("--quiet", is_flag=True, default=False, help="Only output true/false.")
@click.option(
"--check",
type=click.Choice(["all", "ansible", "user-facing"]),
default="all",
help="Check specific category: all (default), ansible, or user-facing.",
help="Check specific category: 'all' (default), 'user-facing', or any tag name "
"defined in [tool.devx.classify.tags] (e.g., 'ansible').",
)
@click.option(
"--github-output",
"github_output",
is_flag=True,
default=False,
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).",
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps). "
"Outputs 'user-facing-changed' and '<tag>-changed' for each configured tag.",
)
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None:
"""Classify git changes and output results."""
classifier = _get_classifier()
available_tags = list(classifier.config.tags.keys())
if base is None:
base = get_latest_tag()
if not base:
if github_output:
_write_github_output("ansible-changed", "true")
_write_github_output("user-facing-changed", "true")
for tag in available_tags:
_write_github_output(f"{tag}-changed", "true")
click.echo("No tags found — treating all changes as user-facing.")
return
if quiet:
@@ -242,8 +640,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
files = get_changed_files(base, head)
if not files:
if github_output:
_write_github_output("ansible-changed", "false")
_write_github_output("user-facing-changed", "false")
for tag in available_tags:
_write_github_output(f"{tag}-changed", "false")
click.echo(f"No changes between {base} and {head}.")
return
if quiet:
@@ -252,57 +651,65 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
click.echo(_("No changes between {base} and {head}.", base=base, head=head))
return
result = classifier.classify(files)
if github_output:
ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"]
user_files = [f for f in files if is_user_facing(f)]
_write_github_output("ansible-changed", "true" if ansible_files else "false")
_write_github_output("user-facing-changed", "true" if user_files else "false")
click.echo(f"Ansible files changed: {bool(ansible_files)}")
click.echo(f"User-facing files changed: {bool(user_files)}")
_write_github_output("user-facing-changed", "true" if result.has_user_facing else "false")
for tag in available_tags:
_write_github_output(f"{tag}-changed", "true" if result.has_tag(tag) else "false")
click.echo(f"User-facing files changed: {result.has_user_facing}")
for tag in available_tags:
click.echo(f"{tag.capitalize()} files changed: {result.has_tag(tag)}")
return
if check == "ansible":
# Check only for Ansible-related file changes
ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"]
has_ansible = bool(ansible_files)
# --check: check a specific tag or user-facing
if check != "all":
if check == "user-facing":
checked_files = result.user_facing
has_checked = bool(checked_files)
label = "User-facing"
elif check in available_tags:
checked_files = result.tags.get(check, [])
has_checked = bool(checked_files)
label = check.capitalize()
else:
raise click.ClickException(
_(
"Unknown check category '{check}'. Available: all, user-facing{tags}",
check=check,
tags=", " + ", ".join(available_tags) if available_tags else "",
)
)
if quiet:
click.echo("true" if has_ansible else "false")
click.echo("true" if has_checked else "false")
return
click.echo(_("\nAnsible files changed ({count}):", count=len(ansible_files)))
for f in ansible_files:
click.echo(f" {f}")
click.echo(_("\nResult: {status}", status="Ansible changes detected" if has_ansible else "No Ansible changes"))
return
if check == "user-facing":
# Check only for user-facing file changes (inverse of workflow-only)
user_files = [f for f in files if is_user_facing(f)]
has_user = bool(user_files)
if quiet:
click.echo("true" if has_user else "false")
return
click.echo(_("\nUser-facing files changed ({count}):", count=len(user_files)))
for f in user_files:
click.echo(_("\n{label} files changed ({count}):", label=label, count=len(checked_files)))
for f in checked_files:
click.echo(f" {f}")
click.echo(
_("\nResult: {status}", status="User-facing changes detected" if has_user else "No user-facing changes")
_("\nResult: {status}", status=f"{label} changes detected" if has_checked else f"No {label} changes")
)
return
result = classify_changes(files)
has_user = bool(result["user_facing"])
has_user = result.has_user_facing
if quiet:
click.echo("true" if has_user else "false")
return
click.echo(_("Comparing {base}..{head} ({count} files changed)", base=base, head=head, count=len(files)))
click.echo(_("\nUser-facing changes ({count}):", count=len(result["user_facing"])))
for f in result["user_facing"]:
click.echo(_("\nUser-facing changes ({count}):", count=len(result.user_facing)))
for f in result.user_facing:
click.echo(f" {f}")
click.echo(_("\nWorkflow-only changes ({count}):", count=len(result["workflow_only"])))
for f in result["workflow_only"]:
click.echo(_("\nWorkflow-only changes ({count}):", count=len(result.infrastructure)))
for f in result.infrastructure:
click.echo(f" {f}")
for tag in available_tags:
tag_files = result.tags.get(tag, [])
if tag_files:
click.echo(_("\n{tag} files ({count}):", tag=tag.capitalize(), count=len(tag_files)))
for f in tag_files:
click.echo(f" {f}")
if has_user:
status = "USER-FACING changes detected — release needed"
else:
+13 -7
View File
@@ -39,7 +39,7 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
Returns the total count of active runners. If the API call fails
(e.g., no admin access for instance-level runners), falls back to
what we can see.
what we can see. Fallbacks are logged to stderr for debugging.
"""
headers = {"Authorization": f"token {token}"}
total = 0
@@ -54,8 +54,10 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
if r.status_code == 200:
data = r.json()
total += data.get("total_count", 0)
except (requests.RequestException, ValueError):
pass
else:
click.echo(f"Warning: repo-level runners query returned HTTP {r.status_code}", err=True)
except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: repo-level runners query failed: {e}", err=True)
# 2. Organization-level runners
try:
@@ -67,8 +69,10 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
if r.status_code == 200:
data = r.json()
total += data.get("total_count", 0)
except (requests.RequestException, ValueError):
pass
else:
click.echo(f"Warning: org-level runners query returned HTTP {r.status_code}", err=True)
except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: org-level runners query failed: {e}", err=True)
# 3. Instance-level runners (requires admin scope)
try:
@@ -80,8 +84,10 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
if r.status_code == 200:
data = r.json()
total += data.get("total_count", 0)
except (requests.RequestException, ValueError):
pass
elif r.status_code != 403: # 403 is expected without admin scope
click.echo(f"Warning: instance-level runners query returned HTTP {r.status_code}", err=True)
except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: instance-level runners query failed: {e}", err=True)
return total
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Distribute a list of files across N parallel runners (round-robin).
Generic file-based test distribution for CI matrix jobs. Discovers files
matching a glob pattern, sorts them for deterministic ordering, then
assigns them round-robin to *max_runners* groups. The assigned group for
*runner_index* is written to ``$GITHUB_ENV`` for use by subsequent steps.
Usage::
python3 -m devx.ci.distribute_files \\
--pattern "tests/integration/test_*.py" \\
--runner-index 1 \\
--max-runners 3 \\
--github-env --skip-if-excess
"""
from __future__ import annotations
import glob
import os
import click
from devx.i18n import _
DEFAULT_MAX_RUNNERS = 3
def discover_files(pattern: str) -> list[str]:
"""Return sorted list of file paths matching *pattern*."""
return sorted(glob.glob(pattern))
def distribute(files: list[str], max_runners: int) -> list[list[str]]:
"""Split *files* into *max_runners* balanced groups (round-robin)."""
groups: list[list[str]] = [[] for _ in range(max_runners)]
for i, f in enumerate(files):
groups[i % max_runners].append(f)
return groups
def files_for_runner(files: list[str], runner_index: int, max_runners: int) -> list[str]:
"""Return the subset of files assigned to *runner_index* (0-based)."""
groups = distribute(files, max_runners)
if runner_index < 0 or runner_index >= len(groups):
raise click.ClickException(
_("Runner index {index} out of range (0..{max})", index=runner_index, max=max_runners - 1)
)
return groups[runner_index]
def _write_github_env(key: str, value: str) -> None:
gh_env = os.environ.get("GITHUB_ENV")
if not gh_env:
raise click.ClickException("GITHUB_ENV environment variable is not set")
with open(gh_env, "a") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
@click.command()
@click.option("--pattern", required=True, help="Glob pattern for files to distribute.")
@click.option(
"--runner-index",
type=int,
default=None,
help="One-based runner index. If omitted, prints all groups.",
)
@click.option(
"--max-runners",
type=int,
default=DEFAULT_MAX_RUNNERS,
show_default=True,
help="Total number of parallel runners.",
)
@click.option(
"--github-env",
is_flag=True,
default=False,
help="Write ASSIGNED_FILES and SKIP to $GITHUB_ENV.",
)
@click.option(
"--skip-if-excess",
is_flag=True,
default=False,
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.",
)
def main(pattern: str, runner_index: int | None, max_runners: int, github_env: bool, skip_if_excess: bool) -> None:
files = discover_files(pattern)
if runner_index is None:
groups = distribute(files, max_runners)
for i, group in enumerate(groups):
labels = " ".join(group) if group else "(none)"
click.echo(f"Runner {i}: {labels}")
return
if skip_if_excess and github_env and runner_index > max_runners:
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
_write_github_env("ASSIGNED_FILES", "")
_write_github_env("SKIP", "true")
return
if runner_index < 1:
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
zero_based = runner_index - 1
assigned = files_for_runner(files, zero_based, max_runners)
encoded = "\n".join(assigned)
if github_env:
_write_github_env("ASSIGNED_FILES", encoded)
_write_github_env("SKIP", "false")
click.echo(f"Assigned {len(assigned)} files to runner {runner_index}")
return
click.echo(encoded)
if __name__ == "__main__": # pragma: no cover
main()
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Run integration tests with cross-runner failure detection.
Wraps ``pytest`` with the same Gitea API polling mechanism used by
``molecule_ci_guard``. If any other integration-tests matrix runner
reports failure, the current pytest subprocess is killed and this runner
exits early with code 1.
JUnit XML is generated via pytest's ``--junitxml`` flag (passed through
to the pytest invocation).
Usage::
python3 -m devx.ci.integration_guard \\
--junit-output junit-results/runner-1.xml \\
-- test_file1.py test_file2.py
# With pytest options
python3 -m devx.ci.integration_guard \\
--junit-output junit-results/runner-1.xml \\
-- -x -v --tb=short test_file1.py
Environment variables:
GITEA_URL Base URL of the Gitea instance.
REPO_TOKEN API token with repo access.
RUN_ID Workflow run ID (GITHUB_RUN_ID).
JOB_NAME Base job name (GITHUB_JOB), e.g. "integration-tests".
MATRIX_INDEX Current matrix index (runner-index).
GITEA_REPOSITORY Repository in "owner/repo" format.
"""
from __future__ import annotations
import contextlib
import os
import signal
import subprocess # nosec B404
import sys
import threading
import time
import click
from devx.i18n import _
from devx.molecule.molecule_ci_guard import (
poll_for_other_failures,
)
POLL_INTERVAL = 10
@click.command(context_settings={"ignore_unknown_options": True})
@click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED, required=True)
@click.option(
"--junit-output",
default=None,
help="Path for JUnit XML output (passed to pytest as --junitxml).",
)
def cli(pytest_args: tuple[str, ...], junit_output: str | None) -> None:
"""Run pytest with cross-runner failure detection."""
gitea_url = os.environ.get("GITEA_URL", "")
token = os.environ.get("REPO_TOKEN", "")
run_id = int(os.environ.get("RUN_ID", "0"))
job_name = os.environ.get("JOB_NAME", "integration-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx")
owner, _sep, repo = repository.partition("/")
if not owner or not repo:
owner, repo = "oblachno-oss", "devx"
if not all([gitea_url, token, run_id]):
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
stop_event = threading.Event()
failed_event = threading.Event()
if gitea_url and token and run_id:
poller = threading.Thread(
target=poll_for_other_failures,
args=(
gitea_url,
owner,
repo,
token,
run_id,
job_name,
current_index,
stop_event,
failed_event,
),
daemon=True,
)
poller.start()
cmd = [sys.executable, "-m", "pytest"]
if junit_output:
cmd.extend(["--junitxml", junit_output])
cmd.extend(pytest_args)
click.echo(f"Running: {' '.join(cmd)}")
process = subprocess.Popen( # nosec B603
cmd,
preexec_fn=os.setsid,
)
try:
while process.poll() is None:
if failed_event.is_set():
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
process.wait()
click.echo(_("Integration tests cancelled — another runner failed."))
sys.exit(1)
time.sleep(1)
except KeyboardInterrupt:
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
process.wait()
sys.exit(1)
finally:
stop_event.set()
rc = process.returncode
if rc != 0:
click.echo(_("Integration tests failed with exit code {code}", code=rc))
else:
click.echo(_("Integration tests passed."))
sys.exit(rc)
if __name__ == "__main__": # pragma: no cover
cli()
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Merge multiple JUnit XML reports into a single report.
Used by CI workflows to consolidate JUnit XML files produced by
parallel matrix runners into a single merged report for archival
and dashboard consumption.
Usage::
python3 -m devx.ci.merge_junit \\
--pattern "junit-results/runner-*.xml" \\
--output junit-merged.xml
Exit code is non-zero if any merged test suite reports failures,
making this suitable as a CI gating step after matrix jobs.
"""
from __future__ import annotations
import glob
import sys
import xml.etree.ElementTree as ET # nosec B405
import click
from devx.i18n import _
def merge_files(pattern: str) -> tuple[ET.Element, int, int]:
"""Merge JUnit XML files matching *pattern* into a single ``<testsuites>`` element.
Returns ``(merged_element, total_tests, total_failures)``.
If no files match, returns an empty ``<testsuites>`` with zero counts.
"""
files = sorted(glob.glob(pattern))
merged = ET.Element("testsuites")
total_tests = 0
total_failures = 0
for f in files:
tree = ET.parse(f) # nosec B314
suite = tree.getroot()
# Handle both <testsuites> (wrapper) and <testsuite> (single) roots
if suite.tag == "testsuites":
for child in suite:
merged.append(child)
total_tests += int(child.get("tests", 0))
total_failures += int(child.get("failures", 0))
else:
merged.append(suite)
total_tests += int(suite.get("tests", 0))
total_failures += int(suite.get("failures", 0))
merged.set("tests", str(total_tests))
merged.set("failures", str(total_failures))
return merged, total_tests, total_failures
@click.command()
@click.option(
"--pattern",
default="junit-results/runner-*.xml",
show_default=True,
help="Glob pattern for input JUnit XML files.",
)
@click.option(
"--output",
default="junit-merged.xml",
show_default=True,
help="Output path for the merged JUnit XML file.",
)
def main(pattern: str, output: str) -> None:
merged, total_tests, total_failures = merge_files(pattern)
if total_tests == 0:
click.echo(_("No JUnit reports found matching {pattern} — skipping merge.", pattern=pattern))
return
ET.indent(merged)
tree = ET.ElementTree(merged)
tree.write(output, encoding="UTF-8", xml_declaration=True)
click.echo(
_(
"Merged {count} reports: {tests} tests, {failures} failures → {output}",
count=len(glob.glob(pattern)),
tests=total_tests,
failures=total_failures,
output=output,
)
)
if total_failures > 0:
sys.exit(1)
if __name__ == "__main__": # pragma: no cover
main()
+73 -6
View File
@@ -10,13 +10,20 @@ Usage:
--repo <owner/repo> \
--run-id <run_id> \
--workflow <workflow_name> \
--commit <commit_sha>
--commit <commit_sha> \
--auto-login
With ``--auto-login``, the script configures the tea CLI login profile
from ``REPO_TOKEN`` and ``DEVX_GITEA_API_URL`` before creating the issue,
eliminating the need for a separate ``tea login add`` step in the workflow.
"""
from __future__ import annotations
import contextlib
import logging
import os
import shutil
import subprocess # nosec B404
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
@@ -27,25 +34,76 @@ from devx.i18n import _
load_dotenv()
logger = logging.getLogger("devx")
def _configure_tea_login(login_name: str = "devx") -> None:
"""Configure tea CLI login from REPO_TOKEN and DEVX_GITEA_API_URL.
Idempotent: if a login with the same name already exists, it is not re-added.
Skips silently if tea is not installed or REPO_TOKEN is not set.
"""
tea_bin = shutil.which("tea")
if tea_bin is None:
click.echo("notify_failure: tea not installed — skipping login configuration.")
return
token = os.environ.get("REPO_TOKEN", "")
if not token:
click.echo("notify_failure: REPO_TOKEN not set — skipping login configuration.")
return
gitea_url = GITEA_API_URL.replace("/api/v1", "")
result = subprocess.run( # nosec B603
[tea_bin, "login", "list", "--output", "simple"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0 and login_name in result.stdout:
click.echo(f"notify_failure: tea login '{login_name}' already configured.")
return
click.echo(f"notify_failure: configuring tea login '{login_name}' for {gitea_url}...")
subprocess.run( # nosec B603
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
capture_output=True,
text=True,
check=False,
)
subprocess.run( # nosec B603
[tea_bin, "login", "default", login_name],
capture_output=True,
text=True,
check=False,
)
def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
"""Create issue via tea CLI. Returns issue index.
Raises TeaCLIError if tea is not installed or the command fails.
Label operations are best-effort — failures are logged but don't
prevent issue creation.
"""
tea = TeaCLI(repo=repo)
# Check if "bug" label exists
# Check if "bug" label exists (best-effort)
labels: list[str] = []
with contextlib.suppress(TeaCLIError):
try:
existing_labels = tea.list_labels(repo)
if any(label.get("name") == "bug" for label in existing_labels):
labels = ["bug"]
except TeaCLIError as e:
logger.warning("Could not fetch labels (best-effort): %s", e)
issue = tea.create_issue(repo, title=title, body=body, labels=labels if labels else None)
if labels:
with contextlib.suppress(TeaCLIError):
try:
tea.add_label(repo, issue["index"], labels)
except TeaCLIError as e:
logger.warning("Could not add label to issue #%s (best-effort): %s", issue.get("index"), e)
return int(issue.get("index", 0))
@@ -54,11 +112,20 @@ def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
@click.option("--run-id", required=True, help="CI run ID.")
@click.option("--workflow", required=True, help="Workflow name.")
@click.option("--commit", required=True, help="Commit SHA.")
def main(repo: str, run_id: str, workflow: str, commit: str) -> None:
@click.option(
"--auto-login",
is_flag=True,
default=False,
help="Configure tea CLI login from REPO_TOKEN before creating the issue.",
)
def main(repo: str, run_id: str, workflow: str, commit: str, auto_login: bool) -> None:
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
if auto_login:
_configure_tea_login()
title = f"[CI] {workflow} workflow failed (run #{run_id})"
body = (
f"The **{workflow}** workflow failed.\n\n"
+14 -13
View File
@@ -13,7 +13,7 @@ import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.api_clients import VikunjaClient
from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.exceptions import APIError
from devx.i18n import _
@@ -151,14 +151,16 @@ def main(commit_msg: str | None, commit_sha: str, from_git: bool, git_sha: str)
)
)
return
# Non-infrastructure commits without DEVX-N prefix — warn but don't fail
click.echo(
# Non-infrastructure commits without DEVX-N prefix — this is a
# convention violation. Fail the post-merge job so the issue is visible.
raise click.ClickException(
_(
"Warning: No task ID (DEVX-N) found in commit message: {msg}. Skipping Vikunja update.",
"No task ID ({prefix}-N) found in commit message: {msg}. "
"Every non-infrastructure commit must have a task ID.",
prefix=TASK_PREFIX,
msg=first_line,
)
)
return
client = VikunjaClient(VIKUNJA_API_URL, token)
vikunja_task_id = resolve_task_id(client, task_id)
@@ -170,19 +172,18 @@ def main(commit_msg: str | None, commit_sha: str, from_git: bool, git_sha: str)
client.post_comment(vikunja_task_id, html)
client.update_task(vikunja_task_id, done=True)
except APIError as e:
# Vikunja is a project management tool — if it's down, the merge
# still succeeded. Warn but don't fail the post-merge workflow.
click.echo(
# Vikunja API failures must be visible — the task was not updated
# and needs manual intervention. Failing the CI job makes this visible.
raise click.ClickException(
_(
"Warning: Vikunja API error (HTTP {status}): {message}. "
"Task {task_id} was NOT updated. The merge succeeded — "
"please update the Vikunja task manually.",
"Vikunja API error (HTTP {status}): {message}. "
"Task {task_id} was NOT updated. "
"The merge succeeded but the Vikunja task needs manual update.",
status=e.status,
message=e.message,
task_id=task_id,
)
)
return
) from e
click.echo(
_(
+24 -14
View File
@@ -164,7 +164,14 @@ def _default_gitea_registry_url() -> str:
"or a URL derived from GITEA_API_URL. When set, publishes to Gitea PyPI "
"instead of standard PyPI (unless PYPI_TOKEN is also set).",
)
def main(tag: str, repo: str, registry_url: str | None) -> None:
@click.option(
"--skip-build",
is_flag=True,
default=False,
help="Skip package build and PyPI publish (for non-Python repos that only "
"need a Gitea release with git-cliff notes).",
)
def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> None:
gitea_token = os.environ.get("REPO_TOKEN", "")
if not gitea_token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
@@ -177,21 +184,24 @@ def main(tag: str, repo: str, registry_url: str | None) -> None:
if not registry_url:
registry_url = _default_gitea_registry_url()
build_package()
if not skip_build:
build_package()
if pypi_token:
# Standard PyPI flow takes precedence when PYPI_TOKEN is set
publish_to_pypi(pypi_token)
elif registry_url:
# Gitea PyPI registry flow
publish_to_gitea_registry(registry_url, gitea_token)
else:
click.echo(
_(
"PYPI_TOKEN not set and no registry URL configured — "
"skipping PyPI publish. No worries, we'll just create the Gitea release."
if pypi_token:
# Standard PyPI flow takes precedence when PYPI_TOKEN is set
publish_to_pypi(pypi_token)
elif registry_url:
# Gitea PyPI registry flow
publish_to_gitea_registry(registry_url, gitea_token)
else:
click.echo(
_(
"PYPI_TOKEN not set and no registry URL configured — "
"skipping PyPI publish. No worries, we'll just create the Gitea release."
)
)
)
else:
click.echo(_("--skip-build: skipping package build and PyPI publish."))
tea = TeaCLI(repo=repo)
release_body = generate_release_notes(tag)
+29 -6
View File
@@ -17,9 +17,11 @@ Usage::
from __future__ import annotations
import contextlib
import re
import subprocess # nosec B404
import sys
import time
from pathlib import Path
from typing import Any
@@ -160,13 +162,34 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
default=False,
help="Skip updating README with cache-busting URLs (for local testing).",
)
def main(output_dir: str, branch: str, no_readme_update: bool) -> None:
@click.option(
"--retries",
default=1,
type=int,
help="Number of attempts on git push failures (default: 1, no retry). "
"Between attempts, fetches latest master and waits 10s.",
)
def main(output_dir: str, branch: str, no_readme_update: bool, retries: int) -> None:
"""Generate badges and push them to the badges branch."""
fetch_latest_master(branch)
generate_badges(output_dir)
badges_sha = push_to_badges_branch(output_dir)
if not no_readme_update:
update_readme_with_badge_sha(badges_sha)
last_error: Exception | None = None
for attempt in range(1, retries + 1):
try:
fetch_latest_master(branch)
generate_badges(output_dir)
badges_sha = push_to_badges_branch(output_dir)
if not no_readme_update:
update_readme_with_badge_sha(badges_sha)
return
except (subprocess.CalledProcessError, RuntimeError) as exc:
last_error = exc
if attempt < retries:
click.echo(f"Badge push attempt {attempt}/{retries} failed — retrying: {exc}")
time.sleep(10)
with contextlib.suppress(subprocess.CalledProcessError):
fetch_latest_master(branch)
else:
click.echo(f"Badge push failed after {retries} attempts: {exc}")
raise click.ClickException(f"Badge push failed after {retries} attempts: {last_error}")
if __name__ == "__main__": # pragma: no cover
+366 -20
View File
@@ -23,8 +23,14 @@ This script is idempotent: if there are no new conventional commits since the
last tag, it exits with a message and does nothing. If the tag already exists
(e.g., from a partial previous run), it skips tag creation and only pushes.
**Tag consistency**: Before releasing, the script fetches remote tags and
verifies all existing tags point to commits whose message matches the tag
version. This prevents duplicate release commits (a common issue when CI
checkouts don't fetch tags) and ensures tag/version/commit alignment.
Usage:
REPO_TOKEN=<token> python3 -m devx.ci.release [--dry-run] [--skip-tests]
python3 -m devx.ci.release --verify # Check tag/version/release alignment
"""
from __future__ import annotations
@@ -32,10 +38,12 @@ from __future__ import annotations
import os
import re
import subprocess # nosec B404
import sys
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.ci._shared import get_latest_tag
from devx.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=.
from devx.i18n import _
@@ -65,20 +73,87 @@ def run_cmd(args: list[str], check: bool = True, capture: bool = True) -> subpro
return result
def get_latest_tag() -> str:
"""Get the latest git tag, or empty string if none exists."""
result = run_cmd(["git", "describe", "--tags", "--abbrev=0"], check=False)
if result.returncode != 0:
return ""
return result.stdout.strip()
def tag_exists(tag: str) -> bool:
"""Check if a git tag already exists."""
result = run_cmd(["git", "tag", "-l", tag], check=False)
return bool(result.stdout.strip())
def get_tag_commit(tag: str) -> str:
"""Get the commit hash a tag points to."""
result = run_cmd(["git", "rev-list", "-n1", tag], check=False)
return result.stdout.strip()
def get_head_commit() -> str:
"""Get the current HEAD commit hash."""
result = run_cmd(["git", "rev-parse", "HEAD"], check=False)
return result.stdout.strip()
def fetch_tags() -> None:
"""Fetch tags from remote to ensure local tag state is current.
This is critical in CI environments where a fresh checkout may not
include tags from previous runs. Without this, the script may
create duplicate release commits because ``tag_exists`` returns False
for a tag that exists on the remote but wasn't fetched.
"""
result = run_cmd(["git", "fetch", "--tags", "origin"], check=False)
if result.returncode != 0:
# Don't fail hard — maybe there's no remote (local-only repo)
click.echo(_("Warning: could not fetch tags from origin."))
def get_all_tags() -> list[str]:
"""Get all git tags sorted by version (newest first)."""
result = run_cmd(["git", "tag", "-l", "--sort=-v:refname"], check=False)
if result.returncode != 0:
return []
return [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
def get_commit_version(commit: str) -> str | None:
"""Extract version from a release commit message.
Returns the version string (e.g., '0.4.4') or None if the commit
is not a release commit.
"""
result = run_cmd(["git", "log", "-1", "--pretty=%s", commit], check=False)
match = re.match(r"^release: v(\d+\.\d+\.\d+)", result.stdout.strip())
return match.group(1) if match else None
def verify_tag_consistency() -> list[str]:
"""Verify all tags point to commits with matching version in message.
Returns a list of error messages for inconsistent tags.
An empty list means all tags are consistent.
The first release (v0.1.0 or earliest tag) is exempt — initial releases
often don't have a "release:" commit message (e.g., the initial commit
serves as the first release).
"""
errors: list[str] = []
tags = get_all_tags()
# Sort oldest first to identify the first tag
sorted_tags = sorted(tags, key=lambda t: [int(x) for x in t.lstrip("v").split(".")])
first_tag = sorted_tags[0] if sorted_tags else None
for tag in tags:
tag_version = tag.lstrip("v")
commit_version = get_commit_version(tag)
if commit_version is None:
# First tag is allowed to point to a non-release commit (initial release)
if tag == first_tag:
continue
errors.append(
f" {tag} → points to non-release commit (expected 'release: v{tag_version}', got non-release commit)"
)
elif commit_version != tag_version:
errors.append(f" {tag} → commit says 'release: v{commit_version}' (expected 'release: v{tag_version}')")
return errors
def get_bumped_version() -> str:
"""Use git-cliff to calculate the next version from conventional commits."""
result = run_cmd(["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG])
@@ -86,7 +161,13 @@ def get_bumped_version() -> str:
if not version:
raise click.ClickException(_("git-cliff returned empty version."))
# git-cliff may return with or without 'v' prefix
return version.lstrip("v")
version = version.lstrip("v")
# Validate semver format
if not re.match(r"^\d+\.\d+\.\d+$", version):
raise click.ClickException(
_("git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", version=version)
)
return version
def get_changelog(new_version: str) -> str:
@@ -117,9 +198,12 @@ def has_unreleased_changes(bumped_version: str | None = None) -> bool:
latest = get_latest_tag()
if not latest:
return True
# Check for any commits since the last tag
# Check for any commits since the last tag, excluding release commits
# (release commits themselves are not "unreleased changes" — they ARE
# the release). This prevents duplicate release commits when the
# script runs multiple times.
result = run_cmd(
["git", "log", f"{latest}..HEAD", "--oneline"],
["git", "log", f"{latest}..HEAD", "--oneline", "--no-merges", "--invert-grep", "--grep=^release: v"],
check=False,
)
if result.returncode != 0:
@@ -233,10 +317,27 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
"""Create an annotated tag with the changelog as message and push it.
Returns True if the tag was created/pushed, False if it already existed.
Raises an error if the tag exists but points to a different commit than HEAD.
"""
tag = f"v{new_version}"
if tag_exists(tag):
click.echo(_("Tag {tag} already exists, skipping creation.", tag=tag))
# Verify the tag points to HEAD — if it points elsewhere, that's
# a consistency error, not a skip condition.
tag_commit = get_tag_commit(tag)
head_commit = get_head_commit()
if tag_commit != head_commit:
raise click.ClickException(
_(
"Tag {tag} already exists but points to {tag_commit} "
"(expected HEAD {head_commit}). "
"This indicates a tag/commit misalignment. "
"Run 'python3 -m devx.ci.release --verify' for details.",
tag=tag,
tag_commit=tag_commit[:7],
head_commit=head_commit[:7],
)
)
click.echo(_("Tag {tag} already exists and points to HEAD. Skipping creation.", tag=tag))
if not dry_run:
# Ensure the existing tag is pushed
run_cmd(["git", "push", "origin", tag], check=False)
@@ -250,6 +351,178 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
return True
# ---------------------------------------------------------------------------
# Verification mode
# ---------------------------------------------------------------------------
def get_init_version() -> str | None:
"""Read __version__ from the version file."""
try:
with open(INIT_FILE) as f:
content = f.read()
match = re.search(r'^__version__\s*=\s*"([^"]*)"', content, flags=re.MULTILINE)
return match.group(1) if match else None
except FileNotFoundError:
return None
def get_changelog_versions() -> list[str]:
"""Extract version numbers from CHANGELOG.md headers, in order."""
try:
with open(CHANGELOG_FILE) as f:
content = f.read()
return re.findall(r"^## \[(\d+\.\d+\.\d+)\]", content, flags=re.MULTILINE)
except FileNotFoundError:
return []
def verify_alignment() -> int:
"""Verify tag/version/changelog alignment. Returns exit code (0=ok, 1=issues)."""
click.echo(_("=== Release Alignment Verification ===\n"))
has_issues = False
# 1. Check __version__ matches latest tag
init_version = get_init_version()
latest_tag = get_latest_tag()
latest_tag_version = latest_tag.lstrip("v") if latest_tag else None
click.echo(_("Version file: {file}", file=INIT_FILE))
if init_version:
click.echo(f' __version__ = "{init_version}"')
else:
click.echo(" __version__ = NOT FOUND")
has_issues = True
click.echo(_("\nLatest tag: {tag}", tag=latest_tag or "(none)"))
if latest_tag_version and init_version:
if latest_tag_version == init_version:
click.echo(f" ✓ Tag version matches __version__ ({init_version})")
else:
click.echo(f" ✗ MISMATCH: tag={latest_tag_version}, __version__={init_version}")
has_issues = True
# 2. Check all tags point to commits with matching version
click.echo(_("\nTag → Commit alignment:"))
tag_errors = verify_tag_consistency()
all_tags = get_all_tags()
if not all_tags:
click.echo(" (no tags)")
elif not tag_errors:
click.echo(f" ✓ All {len(all_tags)} tags point to matching release commits")
else:
has_issues = True
for err in tag_errors:
click.echo(f"{err}")
# 3. Check CHANGELOG versions are in descending order
click.echo(_("\nCHANGELOG version ordering:"))
changelog_versions = get_changelog_versions()
if not changelog_versions:
click.echo(" (no versions in CHANGELOG)")
else:
# Check for duplicates
seen: set[str] = set()
duplicates: list[str] = []
for v in changelog_versions:
if v in seen:
duplicates.append(v)
seen.add(v)
# Check ordering (should be descending)
is_ordered = all(changelog_versions[i] >= changelog_versions[i + 1] for i in range(len(changelog_versions) - 1))
if duplicates:
has_issues = True
click.echo(f" ✗ Duplicate entries: {', '.join(duplicates)}")
elif not is_ordered:
has_issues = True
click.echo(f" ✗ Versions not in descending order: {changelog_versions}")
else:
click.echo(f"{len(changelog_versions)} versions, all in descending order")
# Check latest CHANGELOG version matches latest tag.
# The CHANGELOG may have one unreleased section ahead of the latest tag
# (e.g., CHANGELOG has 0.6.4 but latest tag is v0.6.3 — 0.6.4 is unreleased).
if changelog_versions and latest_tag_version:
if changelog_versions[0] == latest_tag_version:
click.echo(f" ✓ Latest CHANGELOG version matches latest tag ({latest_tag_version})")
elif latest_tag_version in changelog_versions:
tag_idx = changelog_versions.index(latest_tag_version)
# Latest tag should be at index 0 or 1 (0 = released, 1 = unreleased ahead)
if tag_idx == 1:
click.echo(
f" ✓ Latest CHANGELOG version ({changelog_versions[0]}) is unreleased, "
f"latest tag is {latest_tag_version}"
)
else:
click.echo(
f" ✗ MISMATCH: CHANGELOG latest={changelog_versions[0]}, "
f"tag={latest_tag_version} (tag is at position {tag_idx})"
)
has_issues = True
else:
click.echo(f" ✗ MISMATCH: CHANGELOG latest={changelog_versions[0]}, tag={latest_tag_version}")
has_issues = True
# 4. Check for untagged release commits.
# Distinguish between:
# - Truly untagged: no tag exists for that version (needs a tag)
# - Duplicates: a tag for that version exists but on a different commit
# (historical artifact from buggy release script — informational, not an error)
click.echo(_("\nUntagged release commits:"))
result = run_cmd(
["git", "log", "--all", "--format=%h %s", "--grep=^release: v"],
check=False,
)
if result.returncode == 0 and result.stdout.strip():
all_release_commits = result.stdout.strip().split("\n")
all_tags_set = {t.lstrip("v") for t in get_all_tags()}
truly_untagged: list[str] = []
duplicates: list[str] = []
for line in all_release_commits:
short_hash = line.split()[0]
tags_at = run_cmd(["git", "tag", "--points-at", short_hash], check=False)
if not tags_at.stdout.strip():
# Check if a tag for this version exists elsewhere
match = re.search(r"release: v(\d+\.\d+\.\d+)", line)
if match and match.group(1) in all_tags_set:
duplicates.append(line)
else:
truly_untagged.append(line)
if truly_untagged:
has_issues = True
click.echo(f"{len(truly_untagged)} untagged release commits (no tag for version):")
for c in truly_untagged[:10]:
click.echo(f" {c}")
if len(truly_untagged) > 10:
click.echo(f" ... and {len(truly_untagged) - 10} more")
else:
click.echo(" ✓ All release commits have tags")
if duplicates:
click.echo(f" {len(duplicates)} duplicate release commits (tag exists on different commit):")
for c in duplicates[:5]:
click.echo(f" {c}")
if len(duplicates) > 5:
click.echo(f" ... and {len(duplicates) - 5} more")
else:
click.echo(" (no release commits found)")
# Summary
click.echo(_("\n=== Summary ==="))
if has_issues:
click.echo("✗ Issues found — see above for details.")
return 1
click.echo("✓ All checks passed — tags, versions, and changelog are aligned.")
return 0
# ---------------------------------------------------------------------------
# Main command
# ---------------------------------------------------------------------------
@click.command()
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.")
@click.option(
@@ -258,7 +531,20 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
default=False,
help="Skip lint and test verification (NOT recommended — only for emergency releases).",
)
def main(dry_run: bool, skip_tests: bool) -> None:
@click.option(
"--verify",
is_flag=True,
default=False,
help="Verify tag/version/changelog alignment and exit (no changes made).",
)
def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
"""Automated release: calculate next version, update files, tag, and push.
Use --verify to check tag/version/changelog alignment without making changes.
"""
if verify:
sys.exit(verify_alignment())
# Ensure we're on master (skip this check in dry-run mode for PR validation)
branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
if branch != "master" and not dry_run:
@@ -271,16 +557,71 @@ def main(dry_run: bool, skip_tests: bool) -> None:
)
)
# Release lock: if HEAD is already a release commit, another release
# run is in progress (or already completed). Skip to prevent duplicate tags.
head_msg = run_cmd(["git", "log", "-1", "--pretty=%s"]).stdout.strip()
if re.match(r"^release: v\d+\.\d+\.\d+", head_msg):
# Fetch tags from remote to ensure local tag state is current.
# This is critical in CI where a fresh checkout may not include tags
# from previous runs. Without this, tag_exists() returns False for
# tags that exist on the remote, leading to duplicate release commits.
if not dry_run:
fetch_tags()
# Pre-flight: verify existing tags are consistent. If any tag points
# to a commit with a mismatched version, abort before creating more
# inconsistencies.
tag_errors = verify_tag_consistency()
if tag_errors:
click.echo(_("ERROR: Tag consistency check failed. Existing tags are misaligned:"))
for err in tag_errors:
click.echo(err)
click.echo(
_(
"HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping.",
msg=head_msg,
"\nFix the misaligned tags before creating new releases. "
"Run 'python3 -m devx.ci.release --verify' for a full report."
)
)
raise click.ClickException(_("Tag consistency check failed."))
# Release lock: if HEAD is already a release commit, check if the tag
# exists AND points to HEAD. If the tag is missing (e.g., tag push
# failed in a previous run), create and push it. If the tag exists
# but points elsewhere, that's an error.
head_msg = run_cmd(["git", "log", "-1", "--pretty=%s"]).stdout.strip()
release_match = re.match(r"^release: v(\d+\.\d+\.\d+)", head_msg)
if release_match:
release_version = release_match.group(1)
release_tag = f"v{release_version}"
if tag_exists(release_tag):
tag_commit = get_tag_commit(release_tag)
head_commit = get_head_commit()
if tag_commit != head_commit:
raise click.ClickException(
_(
"HEAD is a release commit for v{version} but tag {tag} "
"points to a different commit ({tag_commit} vs HEAD {head_commit}). "
"This indicates a tag/commit misalignment.",
version=release_version,
tag=release_tag,
tag_commit=tag_commit[:7],
head_commit=head_commit[:7],
)
)
click.echo(
_(
"HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
msg=head_msg,
tag=release_tag,
)
)
return
# Tag is missing — recover by creating and pushing it
click.echo(
_(
"HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
msg=head_msg,
tag=release_tag,
)
)
changelog = get_changelog(release_version)
create_and_push_tag(release_version, changelog, dry_run)
return
# Check if any user-facing files changed since the last tag.
@@ -315,7 +656,12 @@ def main(dry_run: bool, skip_tests: bool) -> None:
# Generate changelog
changelog = get_changelog(new_version)
if not changelog:
click.echo(_("Warning: git-cliff generated empty changelog."))
raise click.ClickException(
_(
"git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
version=new_version,
)
)
if dry_run:
click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog))
+26 -9
View File
@@ -34,14 +34,31 @@ from devx.i18n import _
load_dotenv()
DOCS_DIR = Path(__file__).resolve().parent.parent.parent.parent / "docs"
# DOCS_DIR is the repo's docs/ directory. When devx is installed as a
# package (e.g., in .venv/lib/python3.12/site-packages/devx/), the
# __file__-relative path would point inside the venv, not the repo.
# Use DEVX_DOCS_DIR env var if set, otherwise fall back to ./docs
# (relative to the current working directory, which is the repo root
# in CI and local development).
DOCS_DIR = Path(os.environ.get("DEVX_DOCS_DIR", "docs"))
MAPPING_FILE = DOCS_DIR / "mapping.json"
def load_mapping() -> dict[str, str]:
"""Load the file-to-wiki-page mapping from mapping.json."""
"""Load the file-to-wiki-page mapping from mapping.json.
Validates that the mapping is a dict of string-to-string pairs.
"""
with open(MAPPING_FILE) as f:
return json.load(f)
data = json.load(f)
if not isinstance(data, dict):
raise click.ClickException(
_("mapping.json must be a dict of file-path -> page-title, got {type}", type=type(data).__name__)
)
for k, v in data.items():
if not isinstance(k, str) or not isinstance(v, str):
raise click.ClickException(_("mapping.json keys and values must be strings, got {k}={v}", k=k, v=v))
return data
def read_doc_content(file_path: str) -> str:
@@ -239,14 +256,14 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
try:
content = read_doc_content(file_path)
except FileNotFoundError:
click.echo(_("WARNING: File {file} not found — skipping.", file=file_path))
skipped += 1
continue
raise click.ClickException(
_("Mapped file {file} not found. Update mapping.json or create the file.", file=file_path)
) from None
if not content.strip():
click.echo(_("WARNING: File {file} is empty — skipping.", file=file_path))
skipped += 1
continue
raise click.ClickException(
_("Mapped file {file} is empty. Update the content or remove from mapping.json.", file=file_path)
) from None
result = sync_page(client, page_title, content, existing_pages, dry_run)
if result == "created":
+15 -7
View File
@@ -2,9 +2,14 @@
"""Validate commit messages for devx.
Rules:
- On feature branches: conventional commits ONLY, must NOT include DEVX-N prefix.
- On feature branches: conventional commits ONLY, must NOT include <PREFIX>-N prefix.
- On master branch: must follow '<task-id>: <conventional commit>' pattern,
e.g. 'DEVX-24: fix: resolve timeout'.
The task ID prefix is configurable via the ``DEVX_TASK_PREFIX`` environment
variable (default: ``DEVX``). Projects consuming devx (e.g., GRM) set
their own prefix (e.g., ``GRM``) so the validator enforces the correct
task ID format for each project.
"""
import re
@@ -12,10 +17,10 @@ import subprocess # nosec B404
import click
from devx.config import CONVENTIONAL_RE
from devx.config import CONVENTIONAL_RE, TASK_PREFIX
from devx.i18n import _
MASTER_TASK_ID_RE = re.compile(r"^DEVX-\d+:")
MASTER_TASK_ID_RE = re.compile(rf"^{TASK_PREFIX}-\d+:")
def first_line(text: str) -> str:
@@ -51,8 +56,9 @@ def main(commit_msg_file: str, branch: str | None) -> None:
raise click.ClickException(
_(
"Oops! Master branch commits must start with a task ID.\n"
" Expected: DEVX-N: <conventional commit message>\n"
" Expected: {prefix}-N: <conventional commit message>\n"
" Got: {subject}",
prefix=TASK_PREFIX,
subject=subject,
)
)
@@ -61,8 +67,9 @@ def main(commit_msg_file: str, branch: str | None) -> None:
raise click.ClickException(
_(
"Oops! Master branch commit must follow conventional format after task ID.\n"
" Expected: DEVX-N: <type>: <description>\n"
" Expected: {prefix}-N: <type>: <description>\n"
" Got: {subject}",
prefix=TASK_PREFIX,
subject=subject,
)
)
@@ -71,8 +78,9 @@ def main(commit_msg_file: str, branch: str | None) -> None:
if MASTER_TASK_ID_RE.match(subject):
raise click.ClickException(
_(
"Oops! Do not include task ID (DEVX-N) in feature branch commits.\n"
" The task ID will be added automatically on merge via CI."
"Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n"
" The task ID will be added automatically on merge via CI.",
prefix=TASK_PREFIX,
)
)
+28
View File
@@ -151,6 +151,27 @@ def ci_validate_commit_msg(args: tuple[str, ...]) -> None:
_run_module("devx.ci.validate_commit_msg", list(args))
@ci.command("distribute-files")
@click.argument("args", nargs=-1)
def ci_distribute_files(args: tuple[str, ...]) -> None:
"""Distribute files across parallel runners (round-robin)."""
_run_module("devx.ci.distribute_files", list(args))
@ci.command("merge-junit")
@click.argument("args", nargs=-1)
def ci_merge_junit(args: tuple[str, ...]) -> None:
"""Merge multiple JUnit XML reports into a single report."""
_run_module("devx.ci.merge_junit", list(args))
@ci.command("integration-guard")
@click.argument("args", nargs=-1)
def ci_integration_guard(args: tuple[str, ...]) -> None:
"""Run pytest with cross-runner failure detection and JUnit output."""
_run_module("devx.ci.integration_guard", list(args))
@cli.group()
def tools() -> None:
"""Development tool commands."""
@@ -177,6 +198,13 @@ def tools_generate_badges(args: tuple[str, ...]) -> None:
_run_module("devx.tools.generate_badges", list(args))
@tools.command("generate-cliff-config")
@click.argument("args", nargs=-1)
def tools_generate_cliff_config(args: tuple[str, ...]) -> None:
"""Generate a cliff.toml configuration file for the project."""
_run_module("devx.tools.generate_cliff_config", list(args))
@tools.command("install-checkmake")
@click.argument("args", nargs=-1)
def tools_install_checkmake(args: tuple[str, ...]) -> None:
+154 -6
View File
@@ -25,10 +25,11 @@ from pathlib import Path
import click
from devx.i18n import _
from devx.molecule.platforms import PLATFORMS
from devx.molecule.platforms import PLATFORMS, load_platforms
DEFAULT_MAX_RUNNERS = 3
MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule")
DEFAULT_ROLES_ROOT = Path("ansible/roles")
@dataclass(frozen=True)
@@ -40,7 +41,8 @@ class TestPair:
def encode(self) -> str:
"""Serialize to a pipe-delimited string for CI consumption."""
return f"{self.scenario}|{self.platform['name']}|{self.platform['image']}|{self.platform['command']}"
cmd = self.platform["command"].replace(" ", "__SPACE__")
return f"{self.scenario}|{self.platform['name']}|{self.platform['image']}|{cmd}"
@staticmethod
def decode(encoded: str) -> TestPair:
@@ -48,7 +50,31 @@ class TestPair:
parts = encoded.split("|")
return TestPair(
scenario=parts[0],
platform={"name": parts[1], "image": parts[2], "command": parts[3]},
platform={"name": parts[1], "image": parts[2], "command": parts[3].replace("__SPACE__", " ")},
)
@dataclass(frozen=True)
class MultiRoleTestPair:
"""A (role, scenario, platform) combination for multi-role projects."""
role: str
scenario: str
platform: dict[str, str]
def encode(self) -> str:
"""Serialize to a pipe-delimited string: ``role|scenario|platform_name|image|command``."""
cmd = self.platform["command"].replace(" ", "__SPACE__")
return f"{self.role}|{self.scenario}|{self.platform['name']}|{self.platform['image']}|{cmd}"
@staticmethod
def decode(encoded: str) -> MultiRoleTestPair:
"""Deserialize from a pipe-delimited string."""
parts = encoded.split("|")
return MultiRoleTestPair(
role=parts[0],
scenario=parts[1],
platform={"name": parts[2], "image": parts[3], "command": parts[4].replace("__SPACE__", " ")},
)
@@ -62,6 +88,33 @@ def discover_scenarios(root: Path | None = None) -> list[str]:
return sorted(scenarios)
def discover_multi_role_scenarios(roles_root: Path | None = None) -> list[tuple[str, str]]:
"""Discover (role, scenario) pairs across all roles under *roles_root*.
Scans ``roles_root/*/molecule/*/`` for scenario directories, skipping
``common`` and directories starting with ``_``. Returns a sorted list of
``(role_name, scenario_name)`` tuples.
"""
if roles_root is None:
roles_root = DEFAULT_ROLES_ROOT
if not roles_root.is_dir():
raise click.ClickException(_("Roles directory not found: {path}", path=str(roles_root)))
pairs: list[tuple[str, str]] = []
for role_dir in sorted(roles_root.iterdir()):
if not role_dir.is_dir():
continue
mol_dir = role_dir / "molecule"
if not mol_dir.is_dir():
continue
for scenario_dir in mol_dir.iterdir():
if not scenario_dir.is_dir():
continue
if scenario_dir.name.startswith("_") or scenario_dir.name == "common":
continue
pairs.append((role_dir.name, scenario_dir.name))
return pairs
def build_pairs(scenarios: list[str], platforms: list[dict[str, str]] | None = None) -> list[TestPair]:
"""Build the full cross-product of scenarios and platforms."""
if platforms is None:
@@ -69,6 +122,36 @@ def build_pairs(scenarios: list[str], platforms: list[dict[str, str]] | None = N
return [TestPair(s, p) for s in scenarios for p in platforms]
def build_multi_role_pairs(
role_scenarios: list[tuple[str, str]],
platforms: list[dict[str, str]] | None = None,
) -> list[MultiRoleTestPair]:
"""Build the full cross-product of (role, scenario) pairs and platforms."""
if platforms is None:
platforms = PLATFORMS
return [MultiRoleTestPair(r, s, p) for r, s in role_scenarios for p in platforms]
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
groups: list[list[MultiRoleTestPair]] = [[] for _ in range(max_runners)]
for i, pair in enumerate(pairs):
groups[i % max_runners].append(pair)
return groups
def multi_role_pairs_for_runner(
pairs: list[MultiRoleTestPair], runner_index: int, max_runners: int
) -> list[MultiRoleTestPair]:
"""Return the subset of multi-role pairs assigned to *runner_index* (0-based)."""
groups = distribute_multi_role(pairs, max_runners)
if runner_index < 0 or runner_index >= len(groups):
raise click.ClickException(
_("Runner index {index} out of range (0..{max})", index=runner_index, max=max_runners - 1)
)
return groups[runner_index]
def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]:
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
groups: list[list[TestPair]] = [[] for _ in range(max_runners)]
@@ -142,6 +225,26 @@ def _write_github_env(key: str, value: str) -> None:
default=False,
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.",
)
@click.option(
"--molecule-root",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=None,
help="Custom molecule directory (single-role mode). Default: ansible/roles/gitea-runner/molecule.",
)
@click.option(
"--roles-root",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=None,
help="Roles directory for multi-role discovery (scans */molecule/*/). "
"Use this for projects with multiple Ansible roles. Default: disabled (single-role mode).",
)
@click.option(
"--platforms-file",
type=click.Path(exists=True, file_okay=True, path_type=Path),
default=None,
help="JSON file with custom platform list (each entry: name, image, command). "
"Overrides the default platform matrix. Useful for projects with custom test images.",
)
def cli(
runner_index: int | None,
max_runners: int,
@@ -149,17 +252,58 @@ def cli(
list_platforms: bool,
github_env: bool,
skip_if_excess: bool,
molecule_root: Path | None,
roles_root: Path | None,
platforms_file: Path | None,
) -> None:
scenarios = discover_scenarios()
platforms = load_platforms(platforms_file)
# Multi-role mode: discover (role, scenario) pairs across all roles
if roles_root is not None:
role_scenarios = discover_multi_role_scenarios(roles_root)
if list_all:
for role, scenario in role_scenarios:
click.echo(f"{role}|{scenario}")
return
if list_platforms:
for p in platforms:
click.echo(f"{p['name']}|{p['image']}|{p['command']}")
return
pairs_mr = build_multi_role_pairs(role_scenarios, platforms)
if runner_index is None:
groups = distribute_multi_role(pairs_mr, max_runners)
for i, group in enumerate(groups):
labels = " ".join(p.encode() for p in group) if group else "(none)"
click.echo(f"Runner {i}: {labels}")
return
if skip_if_excess and github_env and runner_index > max_runners:
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
_write_github_env("TEST_PAIRS", "")
_write_github_env("SKIP", "true")
return
if runner_index < 1:
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
zero_based = runner_index - 1
assigned = multi_role_pairs_for_runner(pairs_mr, zero_based, max_runners)
encoded = " ".join(p.encode() for p in assigned)
if github_env:
_write_github_env("TEST_PAIRS", encoded)
_write_github_env("SKIP", "false")
click.echo(f"Assigned pairs: {encoded}")
return
click.echo(encoded)
return
# Single-role mode (default or --molecule-root)
scenarios = discover_scenarios(molecule_root)
if list_all:
for s in scenarios:
click.echo(s)
return
if list_platforms:
for p in PLATFORMS:
for p in platforms:
click.echo(f"{p['name']}|{p['image']}|{p['command']}")
return
pairs = build_pairs(scenarios)
pairs = build_pairs(scenarios, platforms)
if runner_index is None:
groups = distribute(pairs, max_runners)
for i, group in enumerate(groups):
@@ -174,6 +318,10 @@ def cli(
_write_github_env("SKIP", "true")
return
# Validate runner index is in range
if runner_index < 1:
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
# Convert 1-based CLI index to 0-based internal index
zero_based = runner_index - 1
assigned = pairs_for_runner(pairs, zero_based, max_runners)
+134 -10
View File
@@ -1,7 +1,11 @@
#!/usr/bin/env python3
"""Run molecule tests sequentially while polling Gitea for other runner failures.
Each pair is encoded as ``scenario|platform_name|platform_image|platform_command``.
Each pair is encoded as one of:
- **Single-role (4-part):** ``scenario|platform_name|platform_image|platform_command``
- **Multi-role (5-part):** ``role|scenario|platform_name|platform_image|platform_command``
Pairs are executed one at a time (molecule scenarios share temp directories and
Docker networks, so parallel execution within a single runner is unsafe).
@@ -9,8 +13,17 @@ A background thread polls the Gitea API. If any other molecule matrix runner
reports failure, the current molecule subprocess is killed and this runner
exits early with code 1.
Usage:
python3 -m devx.molecule.molecule_ci_guard <pair1> <pair2> ...
JUnit XML is generated when ``--junit-output`` is provided, recording each
pair as a testcase with pass/fail status and elapsed time.
Usage::
# Single-role (grm-style)
python3 -m devx.molecule.molecule_ci_guard pair1 pair2 ...
# Multi-role (infra-style)
python3 -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2 ...
# With JUnit output
python3 -m devx.molecule.molecule_ci_guard --junit-output junit-results/runner-1.xml pair1 pair2 ...
Environment variables:
GITEA_URL Base URL of the Gitea instance.
@@ -30,6 +43,7 @@ import subprocess # nosec B404
import sys
import threading
import time
import xml.etree.ElementTree as ET # nosec B405
from pathlib import Path
import click
@@ -95,9 +109,25 @@ def build_molecule_cmd(scenario: str) -> list[str]:
return cmd
def parse_pair(pair: str) -> tuple[str, str, str, str, str]:
"""Parse a pair string into (role, scenario, platform_name, platform_image, platform_command).
Supports both 4-part (single-role) and 5-part (multi-role) formats.
For 4-part pairs, role is empty (caller uses default role dir).
Spaces in the command field are encoded as ``__SPACE__`` to survive
shell word-splitting when ``$TEST_PAIRS`` is expanded unquoted.
"""
parts = pair.split("|")
if len(parts) == 4:
return "", parts[0], parts[1], parts[2], parts[3].replace("__SPACE__", " ")
if len(parts) == 5:
return parts[0], parts[1], parts[2], parts[3], parts[4].replace("__SPACE__", " ")
raise click.ClickException(f"Invalid pair format: {pair!r} (expected 4 or 5 pipe-delimited parts)")
def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]:
"""Build environment for a single molecule pair."""
scenario, platform_name, platform_image, platform_command = pair.split("|")
_role, _scenario, platform_name, platform_image, platform_command = parse_pair(pair)
env = base_env.copy()
env["MOLECULE_PLATFORM_NAME"] = platform_name
env["MOLECULE_PLATFORM_IMAGE"] = platform_image
@@ -106,12 +136,75 @@ def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]:
elif "MOLECULE_PLATFORM_COMMAND" in env:
del env["MOLECULE_PLATFORM_COMMAND"]
env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
# Use a fresh MOLECULE_HOME per pair to avoid stale config cache
# from previous CI runs (causes "Instances missing" errors).
if "MOLECULE_HOME" not in env:
import tempfile
env["MOLECULE_HOME"] = tempfile.mkdtemp(prefix="molecule-ci-")
return env
def resolve_role_dir(role: str, roles_root: Path | None, repo_root: Path) -> Path:
"""Resolve the working directory for a molecule pair.
For multi-role pairs (role non-empty), uses ``roles_root/role``.
For single-role pairs, uses ``repo_root/ansible/roles/gitea-runner``.
"""
if role:
if roles_root is None:
roles_root = repo_root / "ansible" / "roles"
return roles_root / role
return repo_root / "ansible" / "roles" / "gitea-runner"
def write_junit_report(
output_path: str,
testcases: list[dict],
runner_index: int,
) -> None:
"""Write a JUnit XML report from collected test case results.
Each testcase dict has: role, scenario, time (float), passed (bool), error (str|None).
"""
suite = ET.Element(
"testsuite",
name=f"molecule-runner-{runner_index}",
tests=str(len(testcases)),
failures=str(sum(1 for tc in testcases if not tc["passed"])),
)
for tc in testcases:
classname = tc["role"] if tc["role"] else "molecule"
elem = ET.SubElement(
suite,
"testcase",
classname=classname,
name=tc["scenario"],
time=f"{tc['time']:.1f}",
)
if not tc["passed"]:
fail = ET.SubElement(elem, "failure")
fail.text = tc.get("error") or "molecule test failed"
tree = ET.ElementTree(suite)
ET.indent(tree)
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
tree.write(output_path, encoding="UTF-8", xml_declaration=True)
@click.command()
@click.argument("pairs", nargs=-1, required=True)
def cli(pairs: tuple[str, ...]) -> None:
@click.option(
"--junit-output",
default=None,
help="Path to write JUnit XML report (e.g. junit-results/runner-1.xml).",
)
@click.option(
"--roles-root",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=None,
help="Root directory for multi-role pairs (e.g. ansible/roles). Required when pairs use 5-part format.",
)
def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | None) -> None:
"""Run molecule pairs sequentially, stop if another CI runner fails."""
gitea_url = os.environ.get("GITEA_URL", "")
token = os.environ.get("REPO_TOKEN", "")
@@ -119,7 +212,7 @@ def cli(pairs: tuple[str, ...]) -> None:
job_name = os.environ.get("JOB_NAME", "molecule-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx")
owner, sep, repo = repository.partition("/")
owner, _sep, repo = repository.partition("/")
if not owner or not repo:
owner, repo = "oblachno-oss", "devx"
@@ -127,7 +220,6 @@ def cli(pairs: tuple[str, ...]) -> None:
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
repo_root = Path(__file__).resolve().parent.parent.parent.parent
role_dir = repo_root / "ansible" / "roles" / "gitea-runner"
base_env = os.environ.copy()
base_env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock")
@@ -154,21 +246,24 @@ def cli(pairs: tuple[str, ...]) -> None:
)
poller.start()
testcases: list[dict] = []
try:
for pair in pairs:
if failed_event.is_set():
sys.exit(1)
scenario = pair.split("|")[0]
platform_name = pair.split("|")[1]
role, scenario, platform_name, _img, _cmd = parse_pair(pair)
click.echo(_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name))
cmd = build_molecule_cmd(scenario)
env = build_env_for_pair(pair, base_env)
cwd = resolve_role_dir(role, roles_root, repo_root)
start = time.time()
process = subprocess.Popen( # nosec B603
cmd,
cwd=str(role_dir),
cwd=str(cwd),
env=env,
preexec_fn=os.setsid,
)
@@ -184,6 +279,18 @@ def cli(pairs: tuple[str, ...]) -> None:
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
process.wait()
elapsed = time.time() - start
testcases.append(
{
"role": role,
"scenario": scenario,
"time": elapsed,
"passed": False,
"error": "Cancelled — another runner failed",
}
)
if junit_output:
write_junit_report(junit_output, testcases, current_index)
sys.exit(1)
time.sleep(1)
except KeyboardInterrupt:
@@ -193,13 +300,30 @@ def cli(pairs: tuple[str, ...]) -> None:
sys.exit(1)
rc = process.returncode
elapsed = time.time() - start
passed = rc == 0
testcases.append(
{
"role": role,
"scenario": scenario,
"time": elapsed,
"passed": passed,
"error": f"Exit code: {rc}" if not passed else None,
}
)
if rc != 0:
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
if junit_output:
write_junit_report(junit_output, testcases, current_index)
sys.exit(rc)
click.echo(_("PASSED: {pair}", pair=pair))
click.echo(_("All molecule tests passed."))
if junit_output:
write_junit_report(junit_output, testcases, current_index)
finally:
stop_event.set()
+33 -7
View File
@@ -10,13 +10,39 @@ dev tools and CI scripts.
from __future__ import annotations
#: Supported OS platform matrix.
import json
from pathlib import Path
#: Default supported OS platform matrix.
#: Each entry maps a short name to (image, command).
#: The command must be systemd since rootless Docker requires
#: loginctl/systemctl --user.
#: Uses the project's pre-built molecule-test-base image with
#: ``sleep infinity`` (NOT systemd) to avoid cgroup v2 failures.
PLATFORMS: list[dict[str, str]] = [
{"name": "ubuntu-2204", "image": "geerlingguy/docker-ubuntu2204-ansible:latest", "command": "/lib/systemd/systemd"},
{"name": "ubuntu-2404", "image": "geerlingguy/docker-ubuntu2404-ansible:latest", "command": "/lib/systemd/systemd"},
{"name": "debian-12", "image": "geerlingguy/docker-debian12-ansible:latest", "command": "/lib/systemd/systemd"},
{"name": "archlinux", "image": "marcstraube/archlinux-ansible:latest", "command": "/usr/lib/systemd/systemd"},
{
"name": "ubuntu-2604",
"image": "git.oblachno.oblachno.fyi/oblachno/molecule-test-base:latest",
"command": "sleep infinity",
},
]
def load_platforms(platforms_file: str | Path | None = None) -> list[dict[str, str]]:
"""Load platforms from a JSON file, falling back to PLATFORMS.
Args:
platforms_file: Path to a JSON file with a list of platform dicts.
Each dict must have ``name``, ``image``, and ``command`` keys.
Returns:
List of platform dictionaries.
"""
if platforms_file is None:
return PLATFORMS
path = Path(platforms_file)
if not path.is_file():
return PLATFORMS
with path.open() as f:
data = json.load(f)
if not isinstance(data, list) or not data:
return PLATFORMS
return data
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""OpenTofu output helpers for CI/CD deployment scripts.
Provides reusable functions for extracting values from ``tofu output``
in a structured way. This eliminates duplicated ``subprocess.run``
boilerplate across deployment and smoke-test scripts.
Typical usage::
from devx.opentofu import get_tofu_output, get_tofu_vm_ip
vms = get_tofu_output("customer_vms", cwd="tofu/environments/staging",
env={"HCLOUD_TOKEN": token})
ip = get_tofu_vm_ip("customer_vms", "oblachno", cwd="tofu/environments/staging",
env={"HCLOUD_TOKEN": token})
"""
from __future__ import annotations
import json
import subprocess # nosec B404
from pathlib import Path
from typing import Any
def get_tofu_output(
output_name: str,
cwd: str | Path | None = None,
env: dict[str, str] | None = None,
) -> Any:
"""Run ``tofu output -json <output_name>`` and return parsed JSON.
Args:
output_name: The OpenTofu output name to query (e.g. ``customer_vms``).
cwd: Directory to run the command in (the tofu env directory).
env: Environment variables for the subprocess (e.g. ``{"HCLOUD_TOKEN": ...}``).
If ``None``, inherits the current environment.
Returns:
Parsed JSON value from the tofu output.
Raises:
RuntimeError: If ``tofu output`` exits with a non-zero code.
json.JSONDecodeError: If stdout is not valid JSON.
"""
result = subprocess.run( # nosec B603, B607
["tofu", "output", "-json", output_name],
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
check=False,
env=env,
)
if result.returncode != 0:
raise RuntimeError(f"tofu output failed: {result.stderr}")
return json.loads(result.stdout)
def get_tofu_vm_ip(
output_name: str,
vm_key: str,
cwd: str | Path | None = None,
env: dict[str, str] | None = None,
ip_field: str = "ipv4",
) -> str:
"""Extract a VM IPv4 address from a tofu output map.
The output is expected to be a JSON object mapping VM names to objects
containing an IP field (default ``ipv4``)::
{"staging": {"ipv4": "1.2.3.4", ...}, ...}
Args:
output_name: The tofu output name (e.g. ``customer_vms``).
vm_key: The key inside the output map (e.g. ``"staging"``).
cwd: Directory to run the command in.
env: Environment variables for the subprocess.
ip_field: The field name for the IP address (default ``ipv4``).
Returns:
The IP address string, or empty string if not found.
"""
data = get_tofu_output(output_name, cwd=cwd, env=env)
if not isinstance(data, dict):
return ""
return str(data.get(vm_key, {}).get(ip_field, ""))
def get_tofu_vm_field(
output_name: str,
vm_key: str,
field: str,
cwd: str | Path | None = None,
env: dict[str, str] | None = None,
) -> str:
"""Extract an arbitrary field from a VM entry in tofu output.
Like :func:`get_tofu_vm_ip` but for any field (e.g. ``volume_linux_device``).
Args:
output_name: The tofu output name.
vm_key: The key inside the output map.
field: The field name to extract.
cwd: Directory to run the command in.
env: Environment variables for the subprocess.
Returns:
The field value as a string, or empty string if not found.
"""
data = get_tofu_output(output_name, cwd=cwd, env=env)
if not isinstance(data, dict):
return ""
return str(data.get(vm_key, {}).get(field, ""))
+95 -11
View File
@@ -1,12 +1,21 @@
#!/usr/bin/env python3
"""Run unit tests and enforce a maximum execution-time budget.
"""Run unit tests and enforce execution-time budgets.
Checks two quality gates:
1. **Total suite time** must not exceed ``--max-seconds``.
2. **Per-test time** no individual test may exceed ``--max-single-seconds``.
Usage:
python3 -m devx.tools.check_test_speed [--max-seconds N]
python3 -m devx.tools.check_test_speed [--max-seconds N] [--max-single-seconds S]
The module runs ``make test-unit`` with ``PYTEST_ADDOPTS=--durations=0`` so
that pytest emits per-test timing lines alongside the summary. Both the
total wall-clock time and individual test durations are parsed and validated.
"""
from __future__ import annotations
import os
import re
import subprocess # nosec B404
@@ -14,18 +23,32 @@ import click
from devx.i18n import _
DEFAULT_MAX_SECONDS = 2.0
DEFAULT_MAX_SECONDS = 10.0
DEFAULT_MAX_SINGLE_SECONDS = 0.5
TEST_COMMAND = ["make", "test-unit"]
# Matches pytest summary line: "234 passed in 0.70s"
_TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
# Matches per-test duration lines from --durations=0:
# 0.51s call tests/test_foo.py::test_bar
_DURATION_LINE_RE = re.compile(r"^(\d+\.?\d*)s\s+(?:setup|call|teardown)\s+(.+)$")
def run_tests() -> tuple[str, str]:
"""Execute the unit-test suite and return (stdout, stderr)."""
"""Execute the unit-test suite and return (stdout, stderr).
Sets ``PYTEST_ADDOPTS=--durations=0`` so pytest emits per-test timings.
"""
env = os.environ.copy()
existing = env.get("PYTEST_ADDOPTS", "")
env["PYTEST_ADDOPTS"] = f"--durations=0 {existing}".strip()
result = subprocess.run( # nosec B603
TEST_COMMAND,
capture_output=True,
text=True,
check=False,
env=env,
)
return result.stdout, result.stderr
@@ -43,8 +66,23 @@ def parse_duration(output: str) -> float:
raise click.ClickException(_("Could not parse test execution time from output."))
def parse_per_test_durations(output: str) -> list[tuple[str, float]]:
"""Extract per-test timings from ``--durations=0`` output.
Returns a list of ``(test_name, seconds)`` tuples sorted by duration
(slowest first).
"""
durations: list[tuple[str, float]] = []
for line in output.splitlines():
match = _DURATION_LINE_RE.match(line.strip())
if match:
durations.append((match.group(2).strip(), float(match.group(1))))
durations.sort(key=lambda x: x[1], reverse=True)
return durations
def check_speed(duration: float, max_seconds: float) -> None:
"""Validate duration is within budget; raise on violation."""
"""Validate total duration is within budget; raise on violation."""
if duration > max_seconds:
raise click.ClickException(
_(
@@ -57,19 +95,58 @@ def check_speed(duration: float, max_seconds: float) -> None:
)
def main(max_seconds: float) -> None:
"""Run tests, parse timing, and enforce the budget."""
def check_per_test_speed(
durations: list[tuple[str, float]],
max_single_seconds: float,
) -> list[str]:
"""Return a list of violation messages for tests exceeding the per-test limit.
An empty list means all tests are within budget.
"""
violations: list[str] = []
for name, elapsed in durations:
if elapsed > max_single_seconds:
violations.append(
_(
"Test '{name}' took {elapsed:.2f}s (limit: {limit}s). "
"Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
name=name,
elapsed=elapsed,
limit=max_single_seconds,
)
)
return violations
def main(max_seconds: float, max_single_seconds: float) -> None:
"""Run tests, parse timings, and enforce both budgets."""
stdout, stderr = run_tests()
combined = stdout + "\n" + stderr
click.echo(combined, err=False)
duration = parse_duration(combined)
check_speed(duration, max_seconds)
if max_single_seconds > 0:
per_test = parse_per_test_durations(combined)
violations = check_per_test_speed(per_test, max_single_seconds)
if violations:
msg = _(
"Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
count=len(violations),
limit=max_single_seconds,
)
click.echo(f"\n{msg}", err=True)
for v in violations:
click.echo(f" - {v}", err=True)
raise click.ClickException(msg)
click.echo(
_(
"Unit tests passed in {duration:.2f}s (under {max}s limit).",
"Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
duration=duration,
max=max_seconds,
single=max_single_seconds,
)
)
@@ -80,10 +157,17 @@ def main(max_seconds: float) -> None:
type=float,
default=DEFAULT_MAX_SECONDS,
show_default=True,
help="Maximum allowed execution time in seconds.",
help="Maximum allowed total execution time in seconds.",
)
def cli(max_seconds: float) -> None:
main(max_seconds)
@click.option(
"--max-single-seconds",
type=float,
default=DEFAULT_MAX_SINGLE_SECONDS,
show_default=True,
help="Maximum allowed per-test time in seconds (0 to disable).",
)
def cli(max_seconds: float, max_single_seconds: float) -> None:
main(max_seconds, max_single_seconds)
if __name__ == "__main__": # pragma: no cover
+5 -6
View File
@@ -39,16 +39,15 @@ def _default_branch_protection_config() -> dict[str, Any]:
environment variable (comma-separated) or default to just the quality
check context.
The ``push_whitelist_usernames`` is read from ``DEVX_PUSH_WHITELIST``
(comma-separated) to allow the release bot to push directly to master.
Push whitelist is disabled the release script pushes directly to
master (release commits). Since there are no manual reviews yet,
requiring PRs for every push adds complexity without benefit.
"""
push_whitelist = os.environ.get("DEVX_PUSH_WHITELIST", "")
whitelist = [u.strip() for u in push_whitelist.split(",") if u.strip()]
return {
"branch_name": "master",
"enable_push": True,
"enable_push_whitelist": True,
"push_whitelist_usernames": whitelist,
"enable_push_whitelist": False,
"push_whitelist_usernames": [],
"enable_status_check": True,
"status_check_contexts": _default_status_checks(),
"required_approvals": 0,
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Generate a cliff.toml configuration file for a project.
Produces a git-cliff configuration with the correct task ID prefix
preprocessor, matching the format used by devx itself. Downstream
repos can use this to avoid duplicating the entire cliff.toml by hand.
Usage::
python -m devx.tools.generate_cliff_config --prefix GRM
python -m devx.tools.generate_cliff_config --prefix GRM --output cliff.toml
python -m devx.tools.generate_cliff_config --prefix GRM --force
"""
from __future__ import annotations
from pathlib import Path
import click
from devx.config import TASK_PREFIX
from devx.i18n import _
# Template uses __PREFIX__ and __PREFIX_REGEX__ as placeholders to avoid
# conflicts with Jinja2's {{ }} and {% %} syntax in the cliff.toml body.
CLIFF_TEMPLATE = """\
# git-cliff configuration for __PREFIX__
# https://git-cliff.org/docs/configuration
# Generated by: python -m devx.tools.generate_cliff_config --prefix __PREFIX__
[changelog]
header = \"\"\"
# Changelog\\n
All notable changes to this project will be documented in this file.\\n
\"\"\"
body = \"\"\"
{% if version %}\\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\\
## [unreleased]
{% endif %}\\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\\
{% if commit.breaking %}[**breaking**] {% endif %}\\
{{ commit.message | upper_first }}\\
{% endfor %}
{% endfor %}
\"\"\"
trim = true
render_always = true
[git]
conventional_commits = true
filter_unconventional = true
require_conventional = false
split_commits = false
protect_breaking_commits = false
filter_commits = false
fail_on_unmatched_commit = false
use_branch_tags = false
topo_order = false
topo_order_commits = true
sort_commits = "oldest"
recurse_submodules = false
commit_preprocessors = [
# Strip __PREFIX__-N: task ID prefix from squash-merge commits so git-cliff sees conventional commits
{ pattern = "^__PREFIX_REGEX__-\\\\d+:\\\\s+", replace = "" },
]
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->Features" },
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
{ message = "^perf", group = "<!-- 4 -->Performance" },
{ message = "^refactor", group = "<!-- 2 -->Refactor" },
# Skip infrastructure-only commits — they don't affect users
{ message = "^doc", skip = true },
{ message = "^test", skip = true },
{ message = "^style", skip = true },
{ message = "^chore", skip = true },
{ message = "^ci", skip = true },
# Skip release commits — they are release artifacts, not features
{ message = "^release:", skip = true },
{ body = ".*security", group = "<!-- 8 -->Security" },
{ message = "^revert", group = "<!-- 9 -->Revert" },
# Skip anything that doesn't match above — safe default
{ message = ".*", skip = true },
]
[bump]
features_always_bump_minor = true
breaking_always_bump_major = false
initial_tag = "0.1.0"
# Refactor commits bump patch — structural changes to src/ or pyproject.toml
# affect users even though no new feature was added.
refactor_always_bump_patch = true
"""
def _generate(prefix: str) -> str:
"""Generate cliff.toml content for the given prefix."""
prefix_regex = prefix.replace("\\", "\\\\")
return CLIFF_TEMPLATE.replace("__PREFIX__", prefix).replace("__PREFIX_REGEX__", prefix_regex)
@click.command()
@click.option(
"--prefix",
default=TASK_PREFIX,
help="Task ID prefix for commit preprocessor (default: DEVX_TASK_PREFIX env var or 'DEVX').",
)
@click.option(
"--output",
"-o",
default="cliff.toml",
type=click.Path(),
help="Output file path (default: cliff.toml).",
)
@click.option(
"--force",
is_flag=True,
help="Overwrite existing file without prompting.",
)
def main(prefix: str, output: str, force: bool) -> None:
"""Generate a cliff.toml configuration file."""
output_path = Path(output)
if output_path.exists() and not force:
raise click.ClickException(
_(
"{file} already exists. Use --force to overwrite.",
file=str(output_path),
)
)
content = _generate(prefix)
output_path.write_text(content)
click.echo(
_(
"Generated {file} with prefix '{prefix}'.",
file=str(output_path),
prefix=prefix,
)
)
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
+29 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Project setup: install Python deps, pre-commit hooks, and tea CLI login.
"""Project setup: install Python deps, Ansible collections, pre-commit hooks, and tea CLI login.
Usage::
@@ -28,7 +28,13 @@ def _run(cmd: list[str]) -> None:
def _install_python_deps(bin_dir: str, extras: str = "dev") -> None:
"""Install the project with the specified extras in editable mode."""
pip = str(Path(bin_dir) / "pip")
_run([pip, "install", "-e", f".[{extras}]"])
cmd = [pip, "install", "-e", f".[{extras}]"]
# In CI (system Python), --break-system-packages allows installing to
# system site-packages, and --ignore-installed avoids uninstall failures
# for debian-installed packages (e.g. platformdirs) that lack RECORD files.
if os.environ.get("PIP_BREAK_SYSTEM_PACKAGES") == "1":
cmd.extend(["--break-system-packages", "--ignore-installed"])
_run(cmd)
def _install_pre_commit_hooks(bin_dir: str) -> None:
@@ -38,6 +44,16 @@ def _install_pre_commit_hooks(bin_dir: str) -> None:
_run([pre_commit, "install", "--hook-type", hook_type])
def _install_ansible_collections(bin_dir: str) -> None:
"""Install required Ansible Galaxy collections if requirements exist."""
galaxy = str(Path(bin_dir) / "ansible-galaxy")
requirements = Path("ansible/requirements.yml")
if not requirements.exists():
click.echo(" ansible/requirements.yml not found — skipping collections.")
return
_run([galaxy, "collection", "install", "-r", str(requirements)])
def _configure_tea_login() -> None:
"""Configure tea CLI login from .env if REPO_TOKEN is set.
@@ -119,11 +135,18 @@ def _verify(bin_dir: str) -> None:
default=False,
help="Skip tea CLI login configuration.",
)
@click.option(
"--no-ansible-collections",
is_flag=True,
default=False,
help="Skip Ansible Galaxy collection installation.",
)
def main(
bin_dir: str,
extras: str,
no_pre_commit: bool,
no_tea_login: bool,
no_ansible_collections: bool,
) -> None:
"""Install Python deps, pre-commit hooks, and configure tea CLI."""
if not Path(bin_dir).exists():
@@ -132,6 +155,10 @@ def main(
click.echo(f"Installing Python dependencies (extras: {extras})...")
_install_python_deps(bin_dir, extras)
if not no_ansible_collections:
click.echo("Installing Ansible Galaxy collections...")
_install_ansible_collections(bin_dir)
if not no_pre_commit:
click.echo("Installing pre-commit hooks...")
_install_pre_commit_hooks(bin_dir)
+773 -203
View File
@@ -1,63 +1,185 @@
{
"\nAll documentation coverage checks passed!": {
"en": "\nAll documentation coverage checks passed!"
"\n=== Summary ===": {
"en": "\n=== Summary ===",
"bg": "\n=== Summary ===",
"de": "\n=== Summary ===",
"ru": "\n=== Summary ===",
"zh": "\n=== Summary ==="
},
"\nAnsible files changed ({count}):": {
"en": "\nAnsible files changed ({count}):"
"\nAll documentation coverage checks passed!": {
"en": "\nAll documentation coverage checks passed!",
"bg": "\nAll documentation coverage checks passed!",
"de": "\nAll documentation coverage checks passed!",
"ru": "\nAll documentation coverage checks passed!",
"zh": "\nAll documentation coverage checks passed!"
},
"\nCHANGELOG version ordering:": {
"en": "\nCHANGELOG version ordering:",
"bg": "\nCHANGELOG version ordering:",
"de": "\nCHANGELOG version ordering:",
"ru": "\nCHANGELOG version ordering:",
"zh": "\nCHANGELOG version ordering:"
},
"\nChecking CI script documentation in ci-cd-workflow.md...": {
"en": "\nChecking CI script documentation in ci-cd-workflow.md..."
"en": "\nChecking CI script documentation in ci-cd-workflow.md...",
"bg": "\nChecking CI script documentation in ci-cd-workflow.md...",
"de": "\nChecking CI script documentation in ci-cd-workflow.md...",
"ru": "\nChecking CI script documentation in ci-cd-workflow.md...",
"zh": "\nChecking CI script documentation in ci-cd-workflow.md..."
},
"\nChecking module documentation in architecture.md...": {
"en": "\nChecking module documentation in architecture.md..."
"en": "\nChecking module documentation in architecture.md...",
"bg": "\nChecking module documentation in architecture.md...",
"de": "\nChecking module documentation in architecture.md...",
"ru": "\nChecking module documentation in architecture.md...",
"zh": "\nChecking module documentation in architecture.md..."
},
"\nDoc coverage: {covered}/{total} ({pct}%)": {
"en": "\nDoc coverage: {covered}/{total} ({pct}%)"
"en": "\nDoc coverage: {covered}/{total} ({pct}%)",
"bg": "\nDoc coverage: {covered}/{total} ({pct}%)",
"de": "\nDoc coverage: {covered}/{total} ({pct}%)",
"ru": "\nDoc coverage: {covered}/{total} ({pct}%)",
"zh": "\nDoc coverage: {covered}/{total} ({pct}%)"
},
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}": {
"en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
"en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"bg": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"de": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
},
"\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": {
"en": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce."
"en": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
"bg": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
"de": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
"ru": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
"zh": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce."
},
"\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.": {
"en": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
"bg": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
"de": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
"ru": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
"zh": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report."
},
"\nIntegrity check FAILED ({count} issues):": {
"en": "\nIntegrity check FAILED ({count} issues):"
"en": "\nIntegrity check FAILED ({count} issues):",
"bg": "\nIntegrity check FAILED ({count} issues):",
"de": "\nIntegrity check FAILED ({count} issues):",
"ru": "\nIntegrity check FAILED ({count} issues):",
"zh": "\nIntegrity check FAILED ({count} issues):"
},
"\nIntegrity check passed — all {count} pages verified.": {
"en": "\nIntegrity check passed — all {count} pages verified."
"en": "\nIntegrity check passed — all {count} pages verified.",
"bg": "\nIntegrity check passed — all {count} pages verified.",
"de": "\nIntegrity check passed — all {count} pages verified.",
"ru": "\nIntegrity check passed — all {count} pages verified.",
"zh": "\nIntegrity check passed — all {count} pages verified."
},
"\nLatest tag: {tag}": {
"en": "\nLatest tag: {tag}",
"bg": "\nLatest tag: {tag}",
"de": "\nLatest tag: {tag}",
"ru": "\nLatest tag: {tag}",
"zh": "\nLatest tag: {tag}"
},
"\nMissing documentation:": {
"en": "\nMissing documentation:"
"en": "\nMissing documentation:",
"bg": "\nMissing documentation:",
"de": "\nMissing documentation:",
"ru": "\nMissing documentation:",
"zh": "\nMissing documentation:"
},
"\nResult: {status}": {
"en": "\nResult: {status}"
"en": "\nResult: {status}",
"bg": "\nResult: {status}",
"de": "\nResult: {status}",
"ru": "\nResult: {status}",
"zh": "\nResult: {status}"
},
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).": {
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
},
"\nRunning full wiki integrity check...": {
"en": "\nRunning full wiki integrity check..."
"en": "\nRunning full wiki integrity check...",
"bg": "\nRunning full wiki integrity check...",
"de": "\nRunning full wiki integrity check...",
"ru": "\nRunning full wiki integrity check...",
"zh": "\nRunning full wiki integrity check..."
},
"\nTag → Commit alignment:": {
"en": "\nTag → Commit alignment:",
"bg": "\nTag → Commit alignment:",
"de": "\nTag → Commit alignment:",
"ru": "\nTag → Commit alignment:",
"zh": "\nTag → Commit alignment:"
},
"\nUntagged release commits:": {
"en": "\nUntagged release commits:",
"bg": "\nUntagged release commits:",
"de": "\nUntagged release commits:",
"ru": "\nUntagged release commits:",
"zh": "\nUntagged release commits:"
},
"\nUser-facing changes ({count}):": {
"en": "\nUser-facing changes ({count}):"
},
"\nUser-facing files changed ({count}):": {
"en": "\nUser-facing files changed ({count}):"
"en": "\nUser-facing changes ({count}):",
"bg": "\nUser-facing changes ({count}):",
"de": "\nUser-facing changes ({count}):",
"ru": "\nUser-facing changes ({count}):",
"zh": "\nUser-facing changes ({count}):"
},
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!": {
"en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!"
"en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"bg": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"de": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"ru": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
"zh": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!"
},
"\nVerification passed — all wiki pages have correct content.": {
"en": "\nVerification passed — all wiki pages have correct content."
"en": "\nVerification passed — all wiki pages have correct content.",
"bg": "\nVerification passed — all wiki pages have correct content.",
"de": "\nVerification passed — all wiki pages have correct content.",
"ru": "\nVerification passed — all wiki pages have correct content.",
"zh": "\nVerification passed — all wiki pages have correct content."
},
"\nVerifying wiki pages have content...": {
"en": "\nVerifying wiki pages have content..."
"en": "\nVerifying wiki pages have content...",
"bg": "\nVerifying wiki pages have content...",
"de": "\nVerifying wiki pages have content...",
"ru": "\nVerifying wiki pages have content...",
"zh": "\nVerifying wiki pages have content..."
},
"\nWorkflow-only changes ({count}):": {
"en": "\nWorkflow-only changes ({count}):"
"en": "\nWorkflow-only changes ({count}):",
"bg": "\nWorkflow-only changes ({count}):",
"de": "\nWorkflow-only changes ({count}):",
"ru": "\nWorkflow-only changes ({count}):",
"zh": "\nWorkflow-only changes ({count}):"
},
"\n[dry-run] Changelog:\n{changelog}": {
"en": "\n[dry-run] Changelog:\n{changelog}"
"en": "\n[dry-run] Changelog:\n{changelog}",
"bg": "\n[dry-run] Changelog:\n{changelog}",
"de": "\n[dry-run] Changelog:\n{changelog}",
"ru": "\n[dry-run] Changelog:\n{changelog}",
"zh": "\n[dry-run] Changelog:\n{changelog}"
},
"\n{label} files changed ({count}):": {
"en": "\n{label} files changed ({count}):",
"bg": "\n{label} files changed ({count}):",
"de": "\n{label} files changed ({count}):",
"ru": "\n{label} files changed ({count}):",
"zh": "\n{label} files changed ({count}):"
},
"\n{tag} files ({count}):": {
"en": "\n{tag} files ({count}):",
"bg": "\n{tag} files ({count}):",
"de": "\n{tag} files ({count}):",
"ru": "\n{tag} files ({count}):",
"zh": "\n{tag} files ({count}):"
},
" - Auto-delete branch after merge: yes": {
"en": " - Auto-delete branch after merge: yes",
@@ -81,7 +203,11 @@
"zh": " - 阻止被拒绝的审查: 是"
},
" - Direct pushes: BLOCKED (require PR, whitelisted users can push)": {
"en": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)"
"en": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
"bg": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
"de": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
"ru": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)",
"zh": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)"
},
" - Dismiss stale approvals: yes": {
"en": " - Dismiss stale approvals: yes",
@@ -105,10 +231,18 @@
"zh": " - 必需状态检查: {checks}"
},
" Created: {title}": {
"en": " Created: {title}"
"en": " Created: {title}",
"bg": " Created: {title}",
"de": " Created: {title}",
"ru": " Created: {title}",
"zh": " Created: {title}"
},
" FAIL: {title} — content mismatch or empty!": {
"en": " FAIL: {title} — content mismatch or empty!"
"en": " FAIL: {title} — content mismatch or empty!",
"bg": " FAIL: {title} — content mismatch or empty!",
"de": " FAIL: {title} — content mismatch or empty!",
"ru": " FAIL: {title} — content mismatch or empty!",
"zh": " FAIL: {title} — content mismatch or empty!"
},
" MISSING: devx {cmd}": {
"en": " MISSING: devx {cmd}",
@@ -117,14 +251,19 @@
"ru": " ОТСУТСТВУЕТ: devx {cmd}",
"zh": " 缺失: devx {cmd}"
},
" MISSING: grm {cmd}": {
"en": " MISSING: grm {cmd}"
},
" MISSING: {module}": {
"en": " MISSING: {module}"
"en": " MISSING: {module}",
"bg": " MISSING: {module}",
"de": " MISSING: {module}",
"ru": " MISSING: {module}",
"zh": " MISSING: {module}"
},
" MISSING: {script}": {
"en": " MISSING: {script}"
"en": " MISSING: {script}",
"bg": " MISSING: {script}",
"de": " MISSING: {script}",
"ru": " MISSING: {script}",
"zh": " MISSING: {script}"
},
" OK: devx {cmd}": {
"en": " OK: devx {cmd}",
@@ -133,41 +272,89 @@
"ru": " ОК: devx {cmd}",
"zh": " 正常: devx {cmd}"
},
" OK: grm {cmd}": {
"en": " OK: grm {cmd}"
},
" OK: {module}": {
"en": " OK: {module}"
"en": " OK: {module}",
"bg": " OK: {module}",
"de": " OK: {module}",
"ru": " OK: {module}",
"zh": " OK: {module}"
},
" OK: {script}": {
"en": " OK: {script}"
"en": " OK: {script}",
"bg": " OK: {script}",
"de": " OK: {script}",
"ru": " OK: {script}",
"zh": " OK: {script}"
},
" OK: {title} ({chars} chars)": {
"en": " OK: {title} ({chars} chars)"
"en": " OK: {title} ({chars} chars)",
"bg": " OK: {title} ({chars} chars)",
"de": " OK: {title} ({chars} chars)",
"ru": " OK: {title} ({chars} chars)",
"zh": " OK: {title} ({chars} chars)"
},
" Updated: {title}": {
"en": " Updated: {title}"
"en": " Updated: {title}",
"bg": " Updated: {title}",
"de": " Updated: {title}",
"ru": " Updated: {title}",
"zh": " Updated: {title}"
},
"=== Release Alignment Verification ===\n": {
"en": "=== Release Alignment Verification ===\n",
"bg": "=== Release Alignment Verification ===\n",
"de": "=== Release Alignment Verification ===\n",
"ru": "=== Release Alignment Verification ===\n",
"zh": "=== Release Alignment Verification ===\n"
},
"API poll warning: {exc}": {
"en": "API poll warning: {exc}"
"en": "API poll warning: {exc}",
"bg": "API poll warning: {exc}",
"de": "API poll warning: {exc}",
"ru": "API poll warning: {exc}",
"zh": "API poll warning: {exc}"
},
"All molecule tests passed.": {
"en": "All molecule tests passed."
"en": "All molecule tests passed.",
"bg": "All molecule tests passed.",
"de": "All molecule tests passed.",
"ru": "All molecule tests passed.",
"zh": "All molecule tests passed."
},
"Another molecule runner failed. Stopping this runner early.": {
"en": "Another molecule runner failed. Stopping this runner early."
"en": "Another molecule runner failed. Stopping this runner early.",
"bg": "Another molecule runner failed. Stopping this runner early.",
"de": "Another molecule runner failed. Stopping this runner early.",
"ru": "Another molecule runner failed. Stopping this runner early.",
"zh": "Another molecule runner failed. Stopping this runner early."
},
"Bumping version: {current} -> v{new_version}": {
"en": "Bumping version: {current} -> v{new_version}"
"en": "Bumping version: {current} -> v{new_version}",
"bg": "Bumping version: {current} -> v{new_version}",
"de": "Bumping version: {current} -> v{new_version}",
"ru": "Bumping version: {current} -> v{new_version}",
"zh": "Bumping version: {current} -> v{new_version}"
},
"Checking CLI command documentation...": {
"en": "Checking CLI command documentation..."
"en": "Checking CLI command documentation...",
"bg": "Checking CLI command documentation...",
"de": "Checking CLI command documentation...",
"ru": "Checking CLI command documentation...",
"zh": "Checking CLI command documentation..."
},
"Command failed ({cmd}): {stderr}": {
"en": "Command failed ({cmd}): {stderr}"
"en": "Command failed ({cmd}): {stderr}",
"bg": "Command failed ({cmd}): {stderr}",
"de": "Command failed ({cmd}): {stderr}",
"ru": "Command failed ({cmd}): {stderr}",
"zh": "Command failed ({cmd}): {stderr}"
},
"Comparing {base}..{head} ({count} files changed)": {
"en": "Comparing {base}..{head} ({count} files changed)"
"en": "Comparing {base}..{head} ({count} files changed)",
"bg": "Comparing {base}..{head} ({count} files changed)",
"de": "Comparing {base}..{head} ({count} files changed)",
"ru": "Comparing {base}..{head} ({count} files changed)",
"zh": "Comparing {base}..{head} ({count} files changed)"
},
"Configuring branch protection for {branch}...": {
"en": "Configuring branch protection for {branch}...",
@@ -184,25 +371,53 @@
"zh": "正在配置仓库设置..."
},
"Could not extract conventional commit message from PR commits.": {
"en": "Could not extract conventional commit message from PR commits."
"en": "Could not extract conventional commit message from PR commits.",
"bg": "Could not extract conventional commit message from PR commits.",
"de": "Could not extract conventional commit message from PR commits.",
"ru": "Could not extract conventional commit message from PR commits.",
"zh": "Could not extract conventional commit message from PR commits."
},
"Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": {
"en": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task."
"en": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"bg": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"de": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"ru": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"zh": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task."
},
"Could not find __version__ in {file}": {
"en": "Could not find __version__ in {file}"
"en": "Could not find __version__ in {file}",
"bg": "Could not find __version__ in {file}",
"de": "Could not find __version__ in {file}",
"ru": "Could not find __version__ in {file}",
"zh": "Could not find __version__ in {file}"
},
"Could not parse test execution time from output.": {
"en": "Could not parse test execution time from output."
"en": "Could not parse test execution time from output.",
"bg": "Could not parse test execution time from output.",
"de": "Could not parse test execution time from output.",
"ru": "Could not parse test execution time from output.",
"zh": "Could not parse test execution time from output."
},
"Created issue #{issue_id}: {title}": {
"en": "Created issue #{issue_id}: {title}"
"en": "Created issue #{issue_id}: {title}",
"bg": "Created issue #{issue_id}: {title}",
"de": "Created issue #{issue_id}: {title}",
"ru": "Created issue #{issue_id}: {title}",
"zh": "Created issue #{issue_id}: {title}"
},
"Created release commit.": {
"en": "Created release commit."
"en": "Created release commit.",
"bg": "Created release commit.",
"de": "Created release commit.",
"ru": "Created release commit.",
"zh": "Created release commit."
},
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": {
"en": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently."
"en": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"ru": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"zh": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently."
},
"ERROR: REPO_TOKEN is not set.": {
"en": "ERROR: REPO_TOKEN is not set.",
@@ -211,13 +426,6 @@
"ru": "ОШИБКА: REPO_TOKEN не задан.",
"zh": "错误:未设置 REPO_TOKEN。"
},
"ERROR: VIKUNJA_TOKEN is not set.": {
"en": "ERROR: VIKUNJA_TOKEN is not set.",
"bg": "ГРЕШКА: VIKUNJA_TOKEN не е зададен.",
"de": "FEHLER: VIKUNJA_TOKEN ist nicht gesetzt.",
"ru": "ОШИБКА: VIKUNJA_TOKEN не задан.",
"zh": "错误:未设置 VIKUNJA_TOKEN。"
},
"ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.": {
"en": "ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.",
"bg": "ГРЕШКА: Името на хранилището не е указано. Използвайте --repo или задайте DEVX_REPO_NAME.",
@@ -225,23 +433,82 @@
"ru": "ОШИБКА: Имя репозитория не указано. Используйте --repo или задайте DEVX_REPO_NAME.",
"zh": "错误:未指定仓库名称。请使用 --repo 或设置 DEVX_REPO_NAME。"
},
"ERROR: Tag consistency check failed. Existing tags are misaligned:": {
"en": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
"bg": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
"de": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
"ru": "ERROR: Tag consistency check failed. Existing tags are misaligned:",
"zh": "ERROR: Tag consistency check failed. Existing tags are misaligned:"
},
"ERROR: VIKUNJA_TOKEN is not set.": {
"en": "ERROR: VIKUNJA_TOKEN is not set.",
"bg": "ГРЕШКА: VIKUNJA_TOKEN не е зададен.",
"de": "FEHLER: VIKUNJA_TOKEN ist nicht gesetzt.",
"ru": "ОШИБКА: VIKUNJA_TOKEN не задан.",
"zh": "错误:未设置 VIKUNJA_TOKEN。"
},
"ERROR: mapping.json not found at {path}": {
"en": "ERROR: mapping.json not found at {path}"
"en": "ERROR: mapping.json not found at {path}",
"bg": "ERROR: mapping.json not found at {path}",
"de": "ERROR: mapping.json not found at {path}",
"ru": "ERROR: mapping.json not found at {path}",
"zh": "ERROR: mapping.json not found at {path}"
},
"FAILED: {pair} exited with code {code}": {
"en": "FAILED: {pair} exited with code {code}"
"en": "FAILED: {pair} exited with code {code}",
"bg": "FAILED: {pair} exited with code {code}",
"de": "FAILED: {pair} exited with code {code}",
"ru": "FAILED: {pair} exited with code {code}",
"zh": "FAILED: {pair} exited with code {code}"
},
"Failed to create issue via tea: {error}": {
"en": "Failed to create issue via tea: {error}"
"en": "Failed to create issue via tea: {error}",
"bg": "Failed to create issue via tea: {error}",
"de": "Failed to create issue via tea: {error}",
"ru": "Failed to create issue via tea: {error}",
"zh": "Failed to create issue via tea: {error}"
},
"Found {count} existing wiki pages.": {
"en": "Found {count} existing wiki pages."
"en": "Found {count} existing wiki pages.",
"bg": "Found {count} existing wiki pages.",
"de": "Found {count} existing wiki pages.",
"ru": "Found {count} existing wiki pages.",
"zh": "Found {count} existing wiki pages."
},
"GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
"en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."
"en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"bg": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"de": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"ru": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"zh": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."
},
"HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping.": {
"en": "HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping."
"Generated {file} with prefix '{prefix}'.": {
"en": "Generated {file} with prefix '{prefix}'.",
"bg": "Generated {file} with prefix '{prefix}'.",
"de": "Generated {file} with prefix '{prefix}'.",
"ru": "Generated {file} with prefix '{prefix}'.",
"zh": "Generated {file} with prefix '{prefix}'."
},
"HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.": {
"en": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
"bg": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
"de": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
"ru": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
"zh": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag."
},
"HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.": {
"en": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.",
"bg": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.",
"de": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.",
"ru": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment.",
"zh": "HEAD is a release commit for v{version} but tag {tag} points to a different commit ({tag_commit} vs HEAD {head_commit}). This indicates a tag/commit misalignment."
},
"HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.": {
"en": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
"bg": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
"de": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
"ru": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
"zh": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping."
},
"HTTP error: {status} — {message}": {
"en": "HTTP error: {status} — {message}",
@@ -258,7 +525,11 @@
"zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。"
},
"Head branch is behind master. Pulling and rebasing...": {
"en": "Head branch is behind master. Pulling and rebasing..."
"en": "Head branch is behind master. Pulling and rebasing...",
"bg": "Head branch is behind master. Pulling and rebasing...",
"de": "Head branch is behind master. Pulling and rebasing...",
"ru": "Head branch is behind master. Pulling and rebasing...",
"zh": "Head branch is behind master. Pulling and rebasing..."
},
"Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": {
"en": "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}",
@@ -267,17 +538,40 @@
"ru": "Инфраструктурный коммит (без ID задачи DEVX-N), пропуск обновления Vikunja: {msg}",
"zh": "基础设施提交(无 DEVX-N 任务 ID),跳过 Vikunja 更新: {msg}"
},
"Infrastructure commit (no GRM-N task ID), skipping Vikunja update: {msg}": {
"en": "Infrastructure commit (no GRM-N task ID), skipping Vikunja update: {msg}"
},
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}": {
"en": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}"
"en": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"bg": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"de": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"ru": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"zh": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}"
},
"Lint passed.": {
"en": "Lint passed."
"en": "Lint passed.",
"bg": "Lint passed.",
"de": "Lint passed.",
"ru": "Lint passed.",
"zh": "Lint passed."
},
"Mapped file {file} is empty. Update the content or remove from mapping.json.": {
"en": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"de": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"ru": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"zh": "Mapped file {file} is empty. Update the content or remove from mapping.json."
},
"Mapped file {file} not found. Update mapping.json or create the file.": {
"en": "Mapped file {file} not found. Update mapping.json or create the file.",
"bg": "Mapped file {file} not found. Update mapping.json or create the file.",
"de": "Mapped file {file} not found. Update mapping.json or create the file.",
"ru": "Mapped file {file} not found. Update mapping.json or create the file.",
"zh": "Mapped file {file} not found. Update mapping.json or create the file."
},
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.": {
"en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually."
"en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"bg": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"de": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"ru": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"zh": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually."
},
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": {
"en": "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.",
@@ -315,7 +609,11 @@
"zh": "不错!PR #{pr_number} 已 squash 合并,标题: {merge_title}"
},
"Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.": {
"en": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered."
"en": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
"bg": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
"de": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
"ru": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
"zh": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered."
},
"Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.": {
"en": "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.",
@@ -325,22 +623,53 @@
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
},
"No changes between {base} and {head}.": {
"en": "No changes between {base} and {head}."
"en": "No changes between {base} and {head}.",
"bg": "No changes between {base} and {head}.",
"de": "No changes between {base} and {head}.",
"ru": "No changes between {base} and {head}.",
"zh": "No changes between {base} and {head}."
},
"No staged changes — version and changelog already up to date.": {
"en": "No staged changes — version and changelog already up to date."
"en": "No staged changes — version and changelog already up to date.",
"bg": "No staged changes — version and changelog already up to date.",
"de": "No staged changes — version and changelog already up to date.",
"ru": "No staged changes — version and changelog already up to date.",
"zh": "No staged changes — version and changelog already up to date."
},
"No tags found — treating all changes as user-facing.": {
"en": "No tags found — treating all changes as user-facing."
"en": "No tags found — treating all changes as user-facing.",
"bg": "No tags found — treating all changes as user-facing.",
"de": "No tags found — treating all changes as user-facing.",
"ru": "No tags found — treating all changes as user-facing.",
"zh": "No tags found — treating all changes as user-facing."
},
"No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.": {
"en": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"bg": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"de": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID."
},
"No unreleased changes found. Nothing to release.": {
"en": "No unreleased changes found. Nothing to release."
"en": "No unreleased changes found. Nothing to release.",
"bg": "No unreleased changes found. Nothing to release.",
"de": "No unreleased changes found. Nothing to release.",
"ru": "No unreleased changes found. Nothing to release.",
"zh": "No unreleased changes found. Nothing to release."
},
"No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.": {
"en": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release."
"en": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"bg": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"de": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"ru": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"zh": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release."
},
"Note: Self-approval not allowed. Posting COMMENT instead.": {
"en": "Note: Self-approval not allowed. Posting COMMENT instead."
"en": "Note: Self-approval not allowed. Posting COMMENT instead.",
"bg": "Note: Self-approval not allowed. Posting COMMENT instead.",
"de": "Note: Self-approval not allowed. Posting COMMENT instead.",
"ru": "Note: Self-approval not allowed. Posting COMMENT instead.",
"zh": "Note: Self-approval not allowed. Posting COMMENT instead."
},
"Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE": {
"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",
@@ -349,19 +678,12 @@
"ru": "Ой! Сообщение коммита должно соответствовать формату conventional commit.\n Ожидается: <type>: <description>\n Получено: {subject}\n Допустимые типы: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"zh": "哎呀!提交消息必须遵循 conventional commit 格式。\n 预期格式: <type>: <description>\n 实际: {subject}\n 允许的类型: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE"
},
"Oops! Do not include task ID (DEVX-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": {
"en": "Oops! Do not include task ID (DEVX-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
"bg": "Опа! Не включвайте идентификатор на задача (DEVX-N) в commit-и от feature клонове.\n Идентификаторът ще бъде добавен автоматично при сливане чрез CI.",
"de": "Ups! Keine Task-ID (DEVX-N) in Feature-Branch-Commits einfügen.\n Die Task-ID wird beim Merge automatisch über CI hinzugefügt.",
"ru": "Ой! Не включайте ID задачи (DEVX-N) в коммиты feature-веток.\n ID задачи будет добавлен автоматически при слиянии через CI.",
"zh": "哎呀!不要在 feature 分支的提交中包含任务 ID (DEVX-N)。\n 任务 ID 将在通过 CI 合并时自动添加。"
},
"Oops! Do not include task ID (GRM-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": {
"en": "Oops! Do not include task ID (GRM-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
"bg": "Опа! Не включвайте идентификатор на задача (GRM-N) в commit-и от feature клонове.\n Идентификаторът ще бъде добавен автоматично при сливане чрез CI.",
"de": "Ups! Keine Task-ID (GRM-N) in Feature-Branch-Commits einfügen.\n Die Task-ID wird beim Merge automatisch über CI hinzugefügt.",
"ru": "Ой! Не включайте ID задачи (GRM-N) в коммиты feature-веток.\n ID задачи будет добавлен автоматически при слиянии через CI.",
"zh": "哎呀!不要在 feature 分支的提交中包含任务 ID (GRM-N)。\n 任务 ID 将在通过 CI 合并时自动添加。"
"Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": {
"en": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
"bg": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
"de": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
"ru": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
"zh": "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n The task ID will be added automatically on merge via CI."
},
"Oops! Gitea PyPI registry publish failed:\n{stderr}": {
"en": "Oops! Gitea PyPI registry publish failed:\n{stderr}",
@@ -370,49 +692,40 @@
"ru": "Ой! Публикация в Gitea PyPI registry не удалась:\n{stderr}",
"zh": "哎呀!Gitea PyPI registry 发布失败:\n{stderr}"
},
"Oops! Master branch commit must follow conventional format after task ID.\n Expected: DEVX-N: <type>: <description>\n Got: {subject}": {
"en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: DEVX-N: <type>: <description>\n Got: {subject}",
"bg": "Опа! Commit-ът в клона master трябва да следва конвенционален формат след идентификатора.\n Очаква се: DEVX-N: <type>: <description>\n Получено: {subject}",
"de": "Ups! Master-Branch-Commit muss nach der Task-ID dem konventionellen Format folgen.\n Erwartet: DEVX-N: <type>: <description>\n Erhalten: {subject}",
"ru": "Ой! Коммит в ветку master после ID задачи должен соответствовать conventional формату.\n Ожидается: DEVX-N: <type>: <description>\n Получено: {subject}",
"zh": "哎呀!master 分支提交在任务 ID 后必须遵循 conventional commit 格式。\n 预期格式: DEVX-N: <type>: <description>\n 实际: {subject}"
"Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}": {
"en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
"bg": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
"de": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
"ru": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}",
"zh": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: {prefix}-N: <type>: <description>\n Got: {subject}"
},
"Oops! Master branch commit must follow conventional format after task ID.\n Expected: GRM-N: <type>: <description>\n Got: {subject}": {
"en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: GRM-N: <type>: <description>\n Got: {subject}",
"bg": "Опа! Commit-ът в клона master трябва да следва конвенционален формат след идентификатора.\n Очаква се: GRM-N: <type>: <description>\n Получено: {subject}",
"de": "Ups! Master-Branch-Commit muss nach der Task-ID dem konventionellen Format folgen.\n Erwartet: GRM-N: <type>: <description>\n Erhalten: {subject}",
"ru": "Ой! Коммит в ветку master после ID задачи должен соответствовать conventional формату.\n Ожидается: GRM-N: <type>: <description>\n Получено: {subject}",
"zh": "哎呀!master 分支提交在任务 ID 后必须遵循 conventional commit 格式。\n 预期格式: GRM-N: <type>: <description>\n 实际: {subject}"
},
"Oops! Master branch commits must start with a task ID.\n Expected: DEVX-N: <conventional commit message>\n Got: {subject}": {
"en": "Oops! Master branch commits must start with a task ID.\n Expected: DEVX-N: <conventional commit message>\n Got: {subject}",
"bg": "Опа! Commit-ите в клона master трябва да започват с идентификатор на задача.\n Очаква се: DEVX-N: <conventional commit message>\n Получено: {subject}",
"de": "Ups! Master-Branch-Commits müssen mit einer Task-ID beginnen.\n Erwartet: DEVX-N: <conventional commit message>\n Erhalten: {subject}",
"ru": "Ой! Коммиты в ветку master должны начинаться с ID задачи.\n Ожидается: DEVX-N: <conventional commit message>\n Получено: {subject}",
"zh": "哎呀!master 分支的提交必须以任务 ID 开头。\n 预期格式: DEVX-N: <conventional commit message>\n 实际: {subject}"
},
"Oops! Master branch commits must start with a task ID.\n Expected: GRM-N: <conventional commit message>\n Got: {subject}": {
"en": "Oops! Master branch commits must start with a task ID.\n Expected: GRM-N: <conventional commit message>\n Got: {subject}",
"bg": "Опа! Commit-ите в клона master трябва да започват с идентификатор на задача.\n Очаква се: GRM-N: <conventional commit message>\n Получено: {subject}",
"de": "Ups! Master-Branch-Commits müssen mit einer Task-ID beginnen.\n Erwartet: GRM-N: <conventional commit message>\n Erhalten: {subject}",
"ru": "Ой! Коммиты в ветку master должны начинаться с ID задачи.\n Ожидается: GRM-N: <conventional commit message>\n Получено: {subject}",
"zh": "哎呀!master 分支的提交必须以任务 ID 开头。\n 预期格式: GRM-N: <conventional commit message>\n 实际: {subject}"
"Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}": {
"en": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
"bg": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
"de": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
"ru": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
"zh": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}"
},
"Oops! No task ID found in .taskid file or branch name '{branch}'.": {
"en": "Oops! No task ID found in .taskid file or branch name '{branch}'."
"en": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
"bg": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
"de": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
"ru": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
"zh": "Oops! No task ID found in .taskid file or branch name '{branch}'."
},
"Oops! PR title must follow format 'DEVX-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
"en": "Oops! PR title must follow format 'DEVX-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"bg": "Опа! Заглавието на PR трябва да следва формата 'DEVX-N: <заглавие на задача>'.\n Очаква се: {task_id}: <заглавие на задача>\n Получено: {pr_title}",
"de": "Ups! PR-Titel muss dem Format 'DEVX-N: <Task-Titel>' folgen.\n Erwartet: {task_id}: <Task-Titel>\n Erhalten: {pr_title}",
"ru": "Ой! Заголовок PR должен соответствовать формату 'DEVX-N: <название задачи>'.\n Ожидается: {task_id}: <название задачи>\n Получено: {pr_title}",
"zh": "哎呀!PR 标题必须遵循格式 'DEVX-N: <任务标题>'\n 预期格式: {task_id}: <任务标题>\n 实际: {pr_title}"
},
"Oops! PR title must follow format 'GRM-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
"en": "Oops! PR title must follow format 'GRM-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}"
"Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
"en": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"bg": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"de": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"ru": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
"zh": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}"
},
"Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}": {
"en": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}"
"en": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
"bg": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
"de": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
"ru": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
"zh": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}"
},
"Oops! Package build failed:\n{stderr}": {
"en": "Oops! Package build failed:\n{stderr}",
@@ -429,10 +742,25 @@
"zh": "哎呀!PyPI 发布失败:\n{stderr}"
},
"PASSED: {pair}": {
"en": "PASSED: {pair}"
"en": "PASSED: {pair}",
"bg": "PASSED: {pair}",
"de": "PASSED: {pair}",
"ru": "PASSED: {pair}",
"zh": "PASSED: {pair}"
},
"PR number must be an integer, got: {pr_number}": {
"en": "PR number must be an integer, got: {pr_number}",
"bg": "PR number must be an integer, got: {pr_number}",
"de": "PR number must be an integer, got: {pr_number}",
"ru": "PR number must be an integer, got: {pr_number}",
"zh": "PR number must be an integer, got: {pr_number}"
},
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": {
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}"
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}"
},
"PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.": {
"en": "PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.",
@@ -441,13 +769,6 @@
"ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
"zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
},
"PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release.": {
"en": "PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release.",
"bg": "PYPI_TOKEN не е зададен — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.",
"de": "PYPI_TOKEN nicht gesetzt — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.",
"ru": "PYPI_TOKEN не задан — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
"zh": "未设置 PYPI_TOKEN — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
},
"Published to Gitea PyPI registry.": {
"en": "Published to Gitea PyPI registry.",
"bg": "Публикувано в Gitea PyPI registry.",
@@ -463,16 +784,39 @@
"zh": "已发布到 PyPI。"
},
"Pushed release commit to master.": {
"en": "Pushed release commit to master."
"en": "Pushed release commit to master.",
"bg": "Pushed release commit to master.",
"de": "Pushed release commit to master.",
"ru": "Pushed release commit to master.",
"zh": "Pushed release commit to master."
},
"Rebased and pushed. Retrying merge...": {
"en": "Rebased and pushed. Retrying merge..."
"en": "Rebased and pushed. Retrying merge...",
"bg": "Rebased and pushed. Retrying merge...",
"de": "Rebased and pushed. Retrying merge...",
"ru": "Rebased and pushed. Retrying merge...",
"zh": "Rebased and pushed. Retrying merge..."
},
"Release creation failed: {error}": {
"en": "Release creation failed: {error}"
"en": "Release creation failed: {error}",
"bg": "Release creation failed: {error}",
"de": "Release creation failed: {error}",
"ru": "Release creation failed: {error}",
"zh": "Release creation failed: {error}"
},
"Release must be run on master, currently on '{branch}'.": {
"en": "Release must be run on master, currently on '{branch}'."
"en": "Release must be run on master, currently on '{branch}'.",
"bg": "Release must be run on master, currently on '{branch}'.",
"de": "Release must be run on master, currently on '{branch}'.",
"ru": "Release must be run on master, currently on '{branch}'.",
"zh": "Release must be run on master, currently on '{branch}'."
},
"Repo must be in 'owner/name' format, got: {repo}": {
"en": "Repo must be in 'owner/name' format, got: {repo}",
"bg": "Repo must be in 'owner/name' format, got: {repo}",
"de": "Repo must be in 'owner/name' format, got: {repo}",
"ru": "Repo must be in 'owner/name' format, got: {repo}",
"zh": "Repo must be in 'owner/name' format, got: {repo}"
},
"Repository configuration complete.": {
"en": "Repository configuration complete.",
@@ -489,101 +833,214 @@
"zh": "Runner 索引 {index} 超出范围 (0..{max})"
},
"Running lint checks...": {
"en": "Running lint checks..."
"en": "Running lint checks...",
"bg": "Running lint checks...",
"de": "Running lint checks...",
"ru": "Running lint checks...",
"zh": "Running lint checks..."
},
"Running tests...": {
"en": "Running tests..."
"en": "Running tests...",
"bg": "Running tests...",
"de": "Running tests...",
"ru": "Running tests...",
"zh": "Running tests..."
},
"Running: {scenario} on {platform}": {
"en": "Running: {scenario} on {platform}"
"en": "Running: {scenario} on {platform}",
"bg": "Running: {scenario} on {platform}",
"de": "Running: {scenario} on {platform}",
"ru": "Running: {scenario} on {platform}",
"zh": "Running: {scenario} on {platform}"
},
"Skipping commit push — no staged changes.": {
"en": "Skipping commit push — no staged changes."
"en": "Skipping commit push — no staged changes.",
"bg": "Skipping commit push — no staged changes.",
"de": "Skipping commit push — no staged changes.",
"ru": "Skipping commit push — no staged changes.",
"zh": "Skipping commit push — no staged changes."
},
"Syncing {count} documentation pages to wiki...": {
"en": "Syncing {count} documentation pages to wiki..."
"en": "Syncing {count} documentation pages to wiki...",
"bg": "Syncing {count} documentation pages to wiki...",
"de": "Syncing {count} documentation pages to wiki...",
"ru": "Syncing {count} documentation pages to wiki...",
"zh": "Syncing {count} documentation pages to wiki..."
},
"Tag consistency check failed.": {
"en": "Tag consistency check failed.",
"bg": "Tag consistency check failed.",
"de": "Tag consistency check failed.",
"ru": "Tag consistency check failed.",
"zh": "Tag consistency check failed."
},
"Tag v{version} already existed. Publish workflow should already have been triggered.": {
"en": "Tag v{version} already existed. Publish workflow should already have been triggered."
"en": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"bg": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"de": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"ru": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"zh": "Tag v{version} already existed. Publish workflow should already have been triggered."
},
"Tag {tag} already exists, skipping creation.": {
"en": "Tag {tag} already exists, skipping creation."
"Tag {tag} already exists and points to HEAD. Skipping creation.": {
"en": "Tag {tag} already exists and points to HEAD. Skipping creation.",
"bg": "Tag {tag} already exists and points to HEAD. Skipping creation.",
"de": "Tag {tag} already exists and points to HEAD. Skipping creation.",
"ru": "Tag {tag} already exists and points to HEAD. Skipping creation.",
"zh": "Tag {tag} already exists and points to HEAD. Skipping creation."
},
"Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.": {
"en": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.",
"bg": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.",
"de": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.",
"ru": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.",
"zh": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details."
},
"Task ID: {task_id}": {
"en": "Task ID: {task_id}"
"en": "Task ID: {task_id}",
"bg": "Task ID: {task_id}",
"de": "Task ID: {task_id}",
"ru": "Task ID: {task_id}",
"zh": "Task ID: {task_id}"
},
"Tests failed — refusing to release. Fix test failures first.\n{stderr}": {
"en": "Tests failed — refusing to release. Fix test failures first.\n{stderr}"
"en": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
"bg": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
"de": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
"ru": "Tests failed — refusing to release. Fix test failures first.\n{stderr}",
"zh": "Tests failed — refusing to release. Fix test failures first.\n{stderr}"
},
"Tests passed.": {
"en": "Tests passed."
},
"Unit tests passed in {duration:.2f}s (under {max}s limit).": {
"en": "Unit tests passed in {duration:.2f}s (under {max}s limit)."
"en": "Tests passed.",
"bg": "Tests passed.",
"de": "Tests passed.",
"ru": "Tests passed.",
"zh": "Tests passed."
},
"Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.": {
"en": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures."
"en": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
"bg": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
"de": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
"ru": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
"zh": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures."
},
"Unknown check category '{check}'. Available: all, user-facing{tags}": {
"en": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"bg": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"de": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"ru": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"zh": "Unknown check category '{check}'. Available: all, user-facing{tags}"
},
"Updated version in {init}": {
"en": "Updated version in {init}"
"en": "Updated version in {init}",
"bg": "Updated version in {init}",
"de": "Updated version in {init}",
"ru": "Updated version in {init}",
"zh": "Updated version in {init}"
},
"Updated {changelog_file}": {
"en": "Updated {changelog_file}"
"en": "Updated {changelog_file}",
"bg": "Updated {changelog_file}",
"de": "Updated {changelog_file}",
"ru": "Updated {changelog_file}",
"zh": "Updated {changelog_file}"
},
"VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.": {
"en": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"bg": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"de": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"ru": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"zh": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles."
},
"Version file: {file}": {
"en": "Version file: {file}",
"bg": "Version file: {file}",
"de": "Version file: {file}",
"ru": "Version file: {file}",
"zh": "Version file: {file}"
},
"Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.": {
"en": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"bg": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"de": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"ru": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"zh": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update."
},
"WARNING: --skip-tests passed — skipping test verification.": {
"en": "WARNING: --skip-tests passed — skipping test verification."
"en": "WARNING: --skip-tests passed — skipping test verification.",
"bg": "WARNING: --skip-tests passed — skipping test verification.",
"de": "WARNING: --skip-tests passed — skipping test verification.",
"ru": "WARNING: --skip-tests passed — skipping test verification.",
"zh": "WARNING: --skip-tests passed — skipping test verification."
},
"WARNING: File {file} is empty — skipping.": {
"en": "WARNING: File {file} is empty — skipping."
},
"WARNING: File {file} not found — skipping.": {
"en": "WARNING: File {file} not found — skipping."
},
"Warning: No task ID (DEVX-N) found in commit message: {msg}. Skipping Vikunja update.": {
"en": "Warning: No task ID (DEVX-N) found in commit message: {msg}. Skipping Vikunja update.",
"bg": "Предупреждение: Не е намерен идентификатор на задача (DEVX-N) в съобщението за commit: {msg}. Пропускаме обновяването на Vikunja.",
"de": "Warnung: Keine Task-ID (DEVX-N) in Commit-Nachricht gefunden: {msg}. Vikunja-Update wird übersprungen.",
"ru": "Предупреждение: ID задачи (DEVX-N) не найден в сообщении коммита: {msg}. Пропуск обновления Vikunja.",
"zh": "警告:提交消息中未找到任务 ID (DEVX-N): {msg}。跳过 Vikunja 更新。"
},
"Warning: No task ID (GRM-N) found in commit message: {msg}. Skipping Vikunja update.": {
"en": "Warning: No task ID (GRM-N) found in commit message: {msg}. Skipping Vikunja update."
},
"Warning: VIKUNJA_TOKEN not set, skipping title match validation.": {
"en": "Warning: VIKUNJA_TOKEN not set, skipping title match validation."
},
"Warning: Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded — please update the Vikunja task manually.": {
"en": "Warning: Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded — please update the Vikunja task manually."
},
"Warning: git-cliff generated empty changelog.": {
"en": "Warning: git-cliff generated empty changelog."
"Warning: could not fetch tags from origin.": {
"en": "Warning: could not fetch tags from origin.",
"bg": "Warning: could not fetch tags from origin.",
"de": "Warning: could not fetch tags from origin.",
"ru": "Warning: could not fetch tags from origin.",
"zh": "Warning: could not fetch tags from origin."
},
"Wiki integrity check failed — {count} issue(s)": {
"en": "Wiki integrity check failed — {count} issue(s)"
"en": "Wiki integrity check failed — {count} issue(s)",
"bg": "Wiki integrity check failed — {count} issue(s)",
"de": "Wiki integrity check failed — {count} issue(s)",
"ru": "Wiki integrity check failed — {count} issue(s)",
"zh": "Wiki integrity check failed — {count} issue(s)"
},
"Wiki verification failed — {failures} page(s) empty or mismatched": {
"en": "Wiki verification failed — {failures} page(s) empty or mismatched"
"en": "Wiki verification failed — {failures} page(s) empty or mismatched",
"bg": "Wiki verification failed — {failures} page(s) empty or mismatched",
"de": "Wiki verification failed — {failures} page(s) empty or mismatched",
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched",
"zh": "Wiki verification failed — {failures} page(s) empty or mismatched"
},
"[dry-run] Would commit: release: v{version}": {
"en": "[dry-run] Would commit: release: v{version}"
"en": "[dry-run] Would commit: release: v{version}",
"bg": "[dry-run] Would commit: release: v{version}",
"de": "[dry-run] Would commit: release: v{version}",
"ru": "[dry-run] Would commit: release: v{version}",
"zh": "[dry-run] Would commit: release: v{version}"
},
"[dry-run] Would create tag: v{version}": {
"en": "[dry-run] Would create tag: v{version}"
"en": "[dry-run] Would create tag: v{version}",
"bg": "[dry-run] Would create tag: v{version}",
"de": "[dry-run] Would create tag: v{version}",
"ru": "[dry-run] Would create tag: v{version}",
"zh": "[dry-run] Would create tag: v{version}"
},
"[dry-run] Would create tag: {tag}": {
"en": "[dry-run] Would create tag: {tag}"
"en": "[dry-run] Would create tag: {tag}",
"bg": "[dry-run] Would create tag: {tag}",
"de": "[dry-run] Would create tag: {tag}",
"ru": "[dry-run] Would create tag: {tag}",
"zh": "[dry-run] Would create tag: {tag}"
},
"[dry-run] Would push commit to master": {
"en": "[dry-run] Would push commit to master"
"en": "[dry-run] Would push commit to master",
"bg": "[dry-run] Would push commit to master",
"de": "[dry-run] Would push commit to master",
"ru": "[dry-run] Would push commit to master",
"zh": "[dry-run] Would push commit to master"
},
"[dry-run] Would sync page: {title} ({chars} chars)": {
"en": "[dry-run] Would sync page: {title} ({chars} chars)"
"en": "[dry-run] Would sync page: {title} ({chars} chars)",
"bg": "[dry-run] Would sync page: {title} ({chars} chars)",
"de": "[dry-run] Would sync page: {title} ({chars} chars)",
"ru": "[dry-run] Would sync page: {title} ({chars} chars)",
"zh": "[dry-run] Would sync page: {title} ({chars} chars)"
},
"[dry-run] Would update {changelog_file}": {
"en": "[dry-run] Would update {changelog_file}"
"en": "[dry-run] Would update {changelog_file}",
"bg": "[dry-run] Would update {changelog_file}",
"de": "[dry-run] Would update {changelog_file}",
"ru": "[dry-run] Would update {changelog_file}",
"zh": "[dry-run] Would update {changelog_file}"
},
"[dry-run] Would update {init}": {
"en": "[dry-run] Would update {init}"
"en": "[dry-run] Would update {init}",
"bg": "[dry-run] Would update {init}",
"de": "[dry-run] Would update {init}",
"ru": "[dry-run] Would update {init}",
"zh": "[dry-run] Would update {init}"
},
"active": {
"en": "active",
@@ -607,10 +1064,39 @@
"zh": "失败"
},
"git command failed ({cmd}): {stderr}": {
"en": "git command failed ({cmd}): {stderr}"
"en": "git command failed ({cmd}): {stderr}",
"bg": "git command failed ({cmd}): {stderr}",
"de": "git command failed ({cmd}): {stderr}",
"ru": "git command failed ({cmd}): {stderr}",
"zh": "git command failed ({cmd}): {stderr}"
},
"git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.": {
"en": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
"bg": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
"de": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
"ru": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
"zh": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history."
},
"git-cliff returned empty version.": {
"en": "git-cliff returned empty version."
"en": "git-cliff returned empty version.",
"bg": "git-cliff returned empty version.",
"de": "git-cliff returned empty version.",
"ru": "git-cliff returned empty version.",
"zh": "git-cliff returned empty version."
},
"git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).": {
"en": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).",
"bg": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).",
"de": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).",
"ru": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).",
"zh": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1)."
},
"in_progress": {
"en": "in progress",
"bg": "в процес",
"de": "in Bearbeitung",
"ru": "в процессе",
"zh": "进行中"
},
"inactive": {
"en": "inactive",
@@ -619,12 +1105,19 @@
"ru": "неактивен",
"zh": "未激活"
},
"in_progress": {
"en": "in progress",
"bg": "в процес",
"de": "in Bearbeitung",
"ru": "в процессе",
"zh": "进行中"
"mapping.json keys and values must be strings, got {k}={v}": {
"en": "mapping.json keys and values must be strings, got {k}={v}",
"bg": "mapping.json keys and values must be strings, got {k}={v}",
"de": "mapping.json keys and values must be strings, got {k}={v}",
"ru": "mapping.json keys and values must be strings, got {k}={v}",
"zh": "mapping.json keys and values must be strings, got {k}={v}"
},
"mapping.json must be a dict of file-path -> page-title, got {type}": {
"en": "mapping.json must be a dict of file-path -> page-title, got {type}",
"bg": "mapping.json must be a dict of file-path -> page-title, got {type}",
"de": "mapping.json must be a dict of file-path -> page-title, got {type}",
"ru": "mapping.json must be a dict of file-path -> page-title, got {type}",
"zh": "mapping.json must be a dict of file-path -> page-title, got {type}"
},
"pending": {
"en": "pending",
@@ -639,5 +1132,82 @@
"de": "unbekannt",
"ru": "неизвестно",
"zh": "未知"
},
"{file} already exists. Use --force to overwrite.": {
"en": "{file} already exists. Use --force to overwrite.",
"bg": "{file} already exists. Use --force to overwrite.",
"de": "{file} already exists. Use --force to overwrite.",
"ru": "{file} already exists. Use --force to overwrite.",
"zh": "{file} already exists. Use --force to overwrite."
},
"--skip-build: skipping package build and PyPI publish.": {
"en": "--skip-build: skipping package build and PyPI publish.",
"bg": "--skip-build: skipping package build and PyPI publish.",
"de": "--skip-build: skipping package build and PyPI publish.",
"ru": "--skip-build: skipping package build and PyPI publish.",
"zh": "--skip-build: skipping package build and PyPI publish."
},
"Integration tests cancelled — another runner failed.": {
"en": "Integration tests cancelled — another runner failed.",
"bg": "Integration tests cancelled — another runner failed.",
"de": "Integration tests cancelled — another runner failed.",
"ru": "Integration tests cancelled — another runner failed.",
"zh": "Integration tests cancelled — another runner failed."
},
"Integration tests failed with exit code {code}": {
"en": "Integration tests failed with exit code {code}",
"bg": "Integration tests failed with exit code {code}",
"de": "Integration tests failed with exit code {code}",
"ru": "Integration tests failed with exit code {code}",
"zh": "Integration tests failed with exit code {code}"
},
"Integration tests passed.": {
"en": "Integration tests passed.",
"bg": "Integration tests passed.",
"de": "Integration tests passed.",
"ru": "Integration tests passed.",
"zh": "Integration tests passed."
},
"Merged {count} reports: {tests} tests, {failures} failures → {output}": {
"en": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
"bg": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
"de": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
"ru": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
"zh": "Merged {count} reports: {tests} tests, {failures} failures → {output}"
},
"No JUnit reports found matching {pattern} — skipping merge.": {
"en": "No JUnit reports found matching {pattern} — skipping merge.",
"bg": "No JUnit reports found matching {pattern} — skipping merge.",
"de": "No JUnit reports found matching {pattern} — skipping merge.",
"ru": "No JUnit reports found matching {pattern} — skipping merge.",
"zh": "No JUnit reports found matching {pattern} — skipping merge."
},
"Roles directory not found: {path}": {
"en": "Roles directory not found: {path}",
"bg": "Roles directory not found: {path}",
"de": "Roles directory not found: {path}",
"ru": "Roles directory not found: {path}",
"zh": "Roles directory not found: {path}"
},
"Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.": {
"en": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
"bg": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
"de": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
"ru": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
"zh": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit."
},
"Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.": {
"en": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
"bg": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
"de": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
"ru": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
"zh": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls."
},
"Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).": {
"en": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
"bg": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
"de": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
"ru": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
"zh": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit)."
}
}
+2 -1
View File
@@ -566,7 +566,8 @@ class TestVikunjaClient:
json={"done": True},
)
def test_http_error_raises_api_error(self) -> None:
@patch("devx.api_clients.time.sleep")
def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None:
client = VikunjaClient("https://work.example.com", "tok")
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = _mock_http_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
+49 -13
View File
@@ -77,9 +77,10 @@ class TestValidatePrTitle:
class TestValidatePrTitleMatchesVikunja:
@patch.dict("os.environ", {}, clear=True)
def test_skips_when_no_token(self) -> None:
# Should not raise — just warn
validate_pr_title_matches_vikunja("DEVX-19: test", "DEVX-19")
def test_raises_when_no_token(self) -> None:
"""Should raise ClickException when VIKUNJA_TOKEN is not set."""
with pytest.raises(click.ClickException, match="VIKUNJA_TOKEN is not set"):
validate_pr_title_matches_vikunja("DEVX-19: test", "DEVX-19")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.VikunjaClient")
@@ -192,9 +193,12 @@ class TestRunCmd:
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": ""}, clear=True)
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_full_merge_flow(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
def test_full_merge_flow(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
@@ -210,7 +214,7 @@ class TestMain:
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code == 0, result.output
mock_client.merge_pr.assert_called_once_with("7", "DEVX-19: fix: resolve timeout")
mock_client.merge_pr.assert_called_once_with(7, "DEVX-19: fix: resolve timeout")
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
def test_no_token_raises(self) -> None:
@@ -240,9 +244,12 @@ class TestMain:
assert result.exit_code != 0
assert "format" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_merge_behind_master_rebases(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
def test_merge_behind_master_rebases(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
@@ -267,9 +274,12 @@ class TestMain:
# Should have fetched, rebased, and pushed
assert mock_run.call_count == 5 # config name, config email, fetch, rebase, push
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_merge_failure_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
def test_merge_failure_raises(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
@@ -288,9 +298,12 @@ class TestMain:
assert result.exit_code != 0
assert "Merge failed" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_no_conventional_msg_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
def test_no_conventional_msg_raises(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""When no conventional commit message is found in PR commits, raises."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
@@ -308,8 +321,31 @@ class TestMain:
assert "conventional commit" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
def test_invalid_pr_number_raises(self, tmp_path, monkeypatch) -> None:
"""Non-integer PR number should raise."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
runner = CliRunner()
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: Test", "owner/repo", "not-a-number"])
assert result.exit_code != 0
assert "PR number must be an integer" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
def test_invalid_repo_format_raises(self, tmp_path, monkeypatch) -> None:
"""Repo without owner/name should raise."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
runner = CliRunner()
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: Test", "invalidrepo", "7"])
assert result.exit_code != 0
assert "owner/name" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_rebase_retry_failure_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
def test_rebase_retry_failure_raises(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""When rebase retry also fails, raises with helpful message."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
+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
@@ -8,10 +8,13 @@ from click.testing import CliRunner
from devx.tools.check_test_speed import (
DEFAULT_MAX_SECONDS,
DEFAULT_MAX_SINGLE_SECONDS,
TEST_COMMAND,
check_per_test_speed,
check_speed,
cli,
parse_duration,
parse_per_test_durations,
run_tests,
)
@@ -23,12 +26,23 @@ class TestRunTests:
stdout, stderr = run_tests()
assert stdout == "out"
assert stderr == "err"
mock_run.assert_called_once_with(
TEST_COMMAND,
capture_output=True,
text=True,
check=False,
)
mock_run.assert_called_once()
call_kwargs = mock_run.call_args
assert call_kwargs.args[0] == TEST_COMMAND
assert call_kwargs.kwargs["capture_output"] is True
assert call_kwargs.kwargs["text"] is True
assert call_kwargs.kwargs["check"] is False
env = call_kwargs.kwargs["env"]
assert "--durations=0" in env["PYTEST_ADDOPTS"]
@patch("devx.tools.check_test_speed.subprocess.run")
def test_run_tests_preserves_existing_pytest_addopts(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="out", stderr="err", returncode=0)
with patch.dict("os.environ", {"PYTEST_ADDOPTS": "-x"}, clear=False):
run_tests()
env = mock_run.call_args.kwargs["env"]
assert "--durations=0" in env["PYTEST_ADDOPTS"]
assert "-x" in env["PYTEST_ADDOPTS"]
class TestParseDuration:
@@ -48,6 +62,38 @@ class TestParseDuration:
assert "Could not parse" in str(exc.value)
class TestParsePerTestDurations:
def test_parses_call_lines(self) -> None:
output = "0.01s call tests/test_foo.py::test_bar\n"
durations = parse_per_test_durations(output)
assert len(durations) == 1
assert durations[0] == ("tests/test_foo.py::test_bar", 0.01)
def test_parses_setup_and_teardown(self) -> None:
output = (
"0.02s setup tests/test_foo.py::test_bar\n"
"0.01s call tests/test_foo.py::test_bar\n"
"0.00s teardown tests/test_foo.py::test_bar\n"
)
durations = parse_per_test_durations(output)
assert len(durations) == 3
names = [d[0] for d in durations]
assert "tests/test_foo.py::test_bar" in names
def test_sorted_slowest_first(self) -> None:
output = "0.01s call tests/test_a.py::test_slow\n0.50s call tests/test_b.py::test_fast\n"
durations = parse_per_test_durations(output)
assert durations[0][1] >= durations[1][1]
assert durations[0][1] == 0.50
def test_empty_output(self) -> None:
assert parse_per_test_durations("") == []
def test_ignores_non_duration_lines(self) -> None:
output = "Some random line\n234 passed in 0.70s\n"
assert parse_per_test_durations(output) == []
class TestCheckSpeed:
def test_under_budget_passes(self) -> None:
check_speed(1.0, 2.0) # should not raise
@@ -64,6 +110,31 @@ class TestCheckSpeed:
assert "max allowed: 2.0s" in msg
class TestCheckPerTestSpeed:
def test_no_violations_when_all_fast(self) -> None:
durations = [("test_a", 0.1), ("test_b", 0.2)]
assert check_per_test_speed(durations, 0.5) == []
def test_violation_when_test_exceeds_limit(self) -> None:
durations = [("test_slow", 0.6), ("test_fast", 0.1)]
violations = check_per_test_speed(durations, 0.5)
assert len(violations) == 1
assert "test_slow" in violations[0]
assert "0.60s" in violations[0]
def test_multiple_violations(self) -> None:
durations = [("test_a", 0.7), ("test_b", 0.6), ("test_c", 0.1)]
violations = check_per_test_speed(durations, 0.5)
assert len(violations) == 2
def test_exact_limit_passes(self) -> None:
durations = [("test_a", 0.5)]
assert check_per_test_speed(durations, 0.5) == []
def test_empty_durations(self) -> None:
assert check_per_test_speed([], 0.5) == []
def test_main_module_block() -> None:
import devx.tools.check_test_speed as cts
@@ -77,39 +148,71 @@ class TestMain:
@patch("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration")
@patch("devx.tools.check_test_speed.check_speed")
@patch("devx.tools.check_test_speed.parse_per_test_durations")
@patch("devx.tools.check_test_speed.check_per_test_speed")
def test_successful_run(
self,
mock_check_per: MagicMock,
mock_parse_per: MagicMock,
mock_check: MagicMock,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("stdout\n", "stderr\n")
mock_parse.return_value = 1.5
mock_parse_per.return_value = []
mock_check_per.return_value = []
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "1.50s" in result.output
assert "under 2.0s limit" in result.output
assert "under 10.0s limit" in result.output
mock_run.assert_called_once()
mock_parse.assert_called_once_with("stdout\n\nstderr\n")
mock_check.assert_called_once_with(1.5, DEFAULT_MAX_SECONDS)
mock_parse_per.assert_called_once()
mock_check_per.assert_called_once_with([], DEFAULT_MAX_SINGLE_SECONDS)
@patch("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration")
def test_slow_tests_exit(
def test_slow_total_exits(
self,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 3.0
mock_parse.return_value = 15.0
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 1
assert "too slow" in result.output.lower()
@patch("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration")
@patch("devx.tools.check_test_speed.check_speed")
@patch("devx.tools.check_test_speed.parse_per_test_durations")
@patch("devx.tools.check_test_speed.check_per_test_speed")
def test_per_test_violation_exits(
self,
mock_check_per: MagicMock,
mock_parse_per: MagicMock,
mock_check: MagicMock,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 3.0
mock_parse_per.return_value = [("test_slow", 0.8)]
mock_check_per.return_value = ["Test 'test_slow' took 0.80s (limit: 0.5s)."]
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 1
assert "Per-test speed check FAILED" in result.output
assert "test_slow" in result.output
@patch("devx.tools.check_test_speed.run_tests")
def test_parse_failure_exits(
self,
@@ -125,16 +228,67 @@ class TestMain:
@patch("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration")
@patch("devx.tools.check_test_speed.check_speed")
@patch("devx.tools.check_test_speed.parse_per_test_durations")
@patch("devx.tools.check_test_speed.check_per_test_speed")
def test_custom_max_seconds(
self,
mock_check_per: MagicMock,
mock_parse_per: MagicMock,
mock_check: MagicMock,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 0.5
mock_parse_per.return_value = []
mock_check_per.return_value = []
runner = CliRunner()
result = runner.invoke(cli, ["--max-seconds", "1.5"])
assert result.exit_code == 0
mock_check.assert_called_once_with(0.5, 1.5)
@patch("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration")
@patch("devx.tools.check_test_speed.check_speed")
@patch("devx.tools.check_test_speed.parse_per_test_durations")
@patch("devx.tools.check_test_speed.check_per_test_speed")
def test_disable_per_test_check(
self,
mock_check_per: MagicMock,
mock_parse_per: MagicMock,
mock_check: MagicMock,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 1.0
runner = CliRunner()
result = runner.invoke(cli, ["--max-single-seconds", "0"])
assert result.exit_code == 0
mock_parse_per.assert_not_called()
mock_check_per.assert_not_called()
@patch("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration")
@patch("devx.tools.check_test_speed.check_speed")
@patch("devx.tools.check_test_speed.parse_per_test_durations")
@patch("devx.tools.check_test_speed.check_per_test_speed")
def test_custom_max_single_seconds(
self,
mock_check_per: MagicMock,
mock_parse_per: MagicMock,
mock_check: MagicMock,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 1.0
mock_parse_per.return_value = []
mock_check_per.return_value = []
runner = CliRunner()
result = runner.invoke(cli, ["--max-single-seconds", "1.0"])
assert result.exit_code == 0
mock_check_per.assert_called_once_with([], 1.0)
+14 -19
View File
@@ -67,7 +67,7 @@ class TestCheckTranslationSet:
trans_file.write_text(json.dumps({"Used": {"en": "Used"}, "Dead": {"en": "Dead"}}))
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert any("Dead key" in w for w in result.warnings)
assert any("Dead key" in e for e in result.errors)
def test_missing_language(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src"
@@ -77,7 +77,7 @@ class TestCheckTranslationSet:
trans_file.write_text(json.dumps({"Hello": {"en": "Hello"}}))
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert any("Missing languages" in w for w in result.warnings)
assert any("Missing languages" in e for e in result.errors)
def test_missing_translations_file(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src"
@@ -108,23 +108,19 @@ class TestMain:
result = runner.invoke(check_translations.main, [])
assert result.exit_code == 0
def test_strict_fails_on_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""--strict should fail if there are missing language warnings."""
warn_result = check_translations.TranslationCheckResult(
def test_errors_fail(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Errors should cause exit code 1."""
error_result = check_translations.TranslationCheckResult(
name="devx",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
used_keys={"a"},
defined_keys={"a"},
warnings=["Dead key: 'bar'"],
)
monkeypatch.setattr(
check_translations,
"check_translation_set",
lambda name, src, trans: warn_result,
errors=["Dead key: 'bar'"],
)
monkeypatch.setattr(check_translations, "check_translation_set", lambda name, src, trans: error_result)
runner = CliRunner()
result = runner.invoke(check_translations.main, ["--strict"])
result = runner.invoke(check_translations.main, [])
assert result.exit_code == 1
def test_fails_on_errors(self, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -311,26 +307,25 @@ class TestCollectKeys:
assert "in_progress" in keys
class TestMainWarnings:
def test_passes_with_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Should pass with exit code 0 and 'PASS with warnings' message."""
warn_result = check_translations.TranslationCheckResult(
class TestMainCleanPass:
def test_passes_clean(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Should pass with exit code 0 and 'PASS:' message when no errors."""
ok_result = check_translations.TranslationCheckResult(
name="devx",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
used_keys={"a"},
defined_keys={"a"},
warnings=["Dead key: 'bar'"],
)
monkeypatch.setattr(
check_translations,
"check_translation_set",
lambda name, src, trans: warn_result,
lambda name, src, trans: ok_result,
)
runner = CliRunner()
result = runner.invoke(check_translations.main, [])
assert result.exit_code == 0
assert "PASS with warnings" in result.output
assert "PASS:" in result.output
class TestI18nProjectTranslations:
+477 -102
View File
@@ -1,4 +1,16 @@
"""Unit tests for scripts/ci/classify_changes.py."""
"""Unit tests for devx.ci.classify_changes.
Tests cover:
- Glob matching (``_glob_to_regex``, ``_matches_glob``)
- Classifier config loading from pyproject.toml
- ChangeClassifier with layered rules (overrides, patterns, default)
- Tag system (orthogonal categories)
- Backward-compatible API (is_user_facing, is_workflow_only, classify_changes)
- Git helpers (get_changed_files, get_latest_tag, run_git)
- CLI (main with --quiet, --check, --github-output)
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -9,6 +21,13 @@ from click.testing import CliRunner
import devx.ci.classify_changes as classify_changes_mod
from devx.ci.classify_changes import (
DEFAULT_INFRASTRUCTURE,
ChangeClassifier,
ClassificationResult,
ClassifierConfig,
FileClassification,
_glob_to_regex,
_matches_glob,
classify_changes,
get_changed_files,
get_latest_tag,
@@ -19,98 +38,382 @@ from devx.ci.classify_changes import (
run_git,
)
# ---------------------------------------------------------------------------
# Glob matching tests
# ---------------------------------------------------------------------------
class TestIsUserFacing:
def test_src_is_user_facing(self) -> None:
assert is_user_facing("src/devx/cli.py") is True
def test_ansible_is_user_facing(self) -> None:
assert is_user_facing("ansible/roles/gitea-runner/tasks/main.yml") is True
class TestGlobToRegex:
def test_double_star_matches_anything(self) -> None:
regex = _glob_to_regex(".gitea/**")
assert regex.match(".gitea/workflows/ci.yml")
assert regex.match(".gitea/actionlint.yaml")
assert regex.match(".gitea/a/b/c/d.yml")
assert not regex.match("tests/test_foo.py")
def test_pyproject_is_user_facing(self) -> None:
assert is_user_facing("pyproject.toml") is True
def test_double_star_in_middle(self) -> None:
"""** in the middle of a pattern matches any number of segments."""
regex = _glob_to_regex("src/**/test_*.py")
assert regex.match("src/test_foo.py")
assert regex.match("src/devx/test_cli.py")
assert regex.match("src/a/b/c/test_bar.py")
assert not regex.match("src/cli.py")
def test_workflow_is_not_user_facing(self) -> None:
assert is_user_facing(".gitea/workflows/ci.yml") is False
def test_single_star_matches_within_segment(self) -> None:
regex = _glob_to_regex("src/*/cli.py")
assert regex.match("src/devx/cli.py")
assert regex.match("src/pkg/cli.py")
assert not regex.match("src/devx/sub/cli.py")
def test_ci_scripts_are_not_user_facing(self) -> None:
assert is_user_facing("scripts/ci/release.py") is False
def test_question_mark_matches_single_char(self) -> None:
regex = _glob_to_regex("file?.py")
assert regex.match("file1.py")
assert regex.match("fileA.py")
assert not regex.match("file12.py")
def test_dev_scripts_are_not_user_facing(self) -> None:
"""All scripts under scripts/ are infrastructure (CI/CD, dev tools).
User-facing code lives in src/devx/."""
assert is_user_facing("scripts/check_test_speed.py") is False
assert is_user_facing("scripts/configure_repo.py") is False
assert is_user_facing("scripts/install_checkmake.py") is False
def test_literal_match(self) -> None:
regex = _glob_to_regex("Makefile")
assert regex.match("Makefile")
assert not regex.match("makefile")
def test_shell_scripts_are_not_user_facing(self) -> None:
assert is_user_facing("scripts/setup.sh") is False
assert is_user_facing("scripts/molecule_all.sh") is False
def test_special_chars_escaped(self) -> None:
regex = _glob_to_regex("file.test.py")
assert regex.match("file.test.py")
assert not regex.match("fileXtest.py")
def test_scripts_init_is_not_user_facing(self) -> None:
assert is_user_facing("scripts/__init__.py") is False
def test_version_file_is_not_user_facing(self) -> None:
"""__init__.py only contains __version__ — a release artifact,
not user-facing code. Version bumps alone should not trigger releases."""
assert is_user_facing("src/devx/__init__.py") is False
class TestMatchesGlob:
def test_double_star(self) -> None:
assert _matches_glob(".gitea/workflows/ci.yml", ".gitea/**")
assert _matches_glob("tests/unit/test_cli.py", "tests/**")
assert not _matches_glob("src/devx/cli.py", "tests/**")
def test_api_clients_is_not_user_facing(self) -> None:
"""api_clients.py is used only by CI/CD scripts, not by the GRM CLI."""
assert is_user_facing("src/devx/api_clients.py") is False
def test_exact_match(self) -> None:
assert _matches_glob("Makefile", "Makefile")
assert _matches_glob("src/devx/__init__.py", "src/devx/__init__.py")
assert not _matches_glob("src/devx/cli.py", "src/devx/__init__.py")
def test_docs_are_not_user_facing(self) -> None:
assert is_user_facing("docs/user/getting-started.md") is False
def test_prefix_matching(self) -> None:
assert _matches_glob(".gitea/workflows/ci.yml", ".gitea/")
assert _matches_glob("scripts/ci/release.py", "scripts/")
assert not _matches_glob("tests/test_foo.py", "scripts/")
def test_tests_are_not_user_facing(self) -> None:
assert is_user_facing("tests/unit/test_cli.py") is False
def test_single_star(self) -> None:
assert _matches_glob("src/devx/cli.py", "src/devx/*.py")
assert not _matches_glob("src/devx/sub/cli.py", "src/devx/*.py")
def test_agents_md_is_not_user_facing(self) -> None:
assert is_user_facing("AGENTS.md") is False
def test_makefile_is_not_user_facing(self) -> None:
assert is_user_facing("Makefile") is False
# ---------------------------------------------------------------------------
# ClassifierConfig tests
# ---------------------------------------------------------------------------
class TestClassifierConfig:
def test_from_pyproject_merges_with_defaults(self, tmp_path: Path) -> None:
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
"[tool.devx.classify]\n"
'infrastructure = ["scripts/**"]\n'
'infrastructure_overrides = ["src/pkg/__init__.py"]\n'
'user_facing_overrides = ["docs/important.py"]\n'
"\n"
"[tool.devx.classify.tags]\n"
'ansible = ["ansible/**"]\n'
)
config = ClassifierConfig.from_pyproject(str(pyproject))
# Project-specific path is merged with defaults
assert "scripts/**" in config.infrastructure
assert ".gitea/**" in config.infrastructure # from DEFAULT_INFRASTRUCTURE
assert "tests/**" in config.infrastructure # from DEFAULT_INFRASTRUCTURE
assert config.use_defaults is True
assert config.infrastructure_overrides == ["src/pkg/__init__.py"]
assert config.user_facing_overrides == ["docs/important.py"]
assert config.tags == {"ansible": ["ansible/**"]}
def test_from_pyproject_use_defaults_false(self, tmp_path: Path) -> None:
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx.classify]\nuse_defaults = false\ninfrastructure = [".gitea/**"]\n')
config = ClassifierConfig.from_pyproject(str(pyproject))
assert config.infrastructure == [".gitea/**"]
assert "tests/**" not in config.infrastructure # no defaults
assert config.use_defaults is False
def test_from_pyproject_missing_file_returns_defaults(self) -> None:
config = ClassifierConfig.from_pyproject("/nonexistent/pyproject.toml")
assert config.infrastructure == list(DEFAULT_INFRASTRUCTURE)
assert config.infrastructure_overrides == []
assert config.user_facing_overrides == []
assert config.tags == {}
assert config.use_defaults is True
def test_from_pyproject_missing_section_returns_defaults(self, tmp_path: Path) -> None:
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[project]\nname = "test"\n')
config = ClassifierConfig.from_pyproject(str(pyproject))
assert config.infrastructure == list(DEFAULT_INFRASTRUCTURE)
def test_from_pyproject_partial_config(self, tmp_path: Path) -> None:
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx.classify]\ninfrastructure = ["scripts/**"]\n')
config = ClassifierConfig.from_pyproject(str(pyproject))
assert "scripts/**" in config.infrastructure
assert ".gitea/**" in config.infrastructure # merged with defaults
assert config.infrastructure_overrides == []
assert config.user_facing_overrides == []
assert config.tags == {}
def test_defaults_are_empty_for_bare_constructor(self) -> None:
"""ClassifierConfig() without from_pyproject has empty lists."""
config = ClassifierConfig()
assert config.infrastructure == []
assert config.infrastructure_overrides == []
assert config.user_facing_overrides == []
assert config.tags == {}
assert config.use_defaults is True
def test_default_infrastructure_is_non_empty(self) -> None:
"""The framework ships with a curated default infrastructure list."""
assert len(DEFAULT_INFRASTRUCTURE) > 0
assert ".gitea/**" in DEFAULT_INFRASTRUCTURE
assert "tests/**" in DEFAULT_INFRASTRUCTURE
assert "docs/**" in DEFAULT_INFRASTRUCTURE
def test_default_infrastructure_covers_common_project_files(self) -> None:
"""DEFAULT_INFRASTRUCTURE must cover common project-level files
that are not part of the installed package.
This test prevents regression of the root cause of GRM-64
misclassification: 28 files (scripts/**, REVIEW_CHECKLIST.md)
were classified as user-facing because these patterns were
missing from the defaults.
"""
# Project documentation
assert "AGENTS.md" in DEFAULT_INFRASTRUCTURE
assert "README.md" in DEFAULT_INFRASTRUCTURE
assert "CHANGELOG.md" in DEFAULT_INFRASTRUCTURE
assert "TROUBLESHOOTING.md" in DEFAULT_INFRASTRUCTURE
assert "CONTRIBUTING.md" in DEFAULT_INFRASTRUCTURE
assert "CODE_OF_CONDUCT.md" in DEFAULT_INFRASTRUCTURE
assert "REVIEW_CHECKLIST.md" in DEFAULT_INFRASTRUCTURE
# Build tooling
assert "Makefile" in DEFAULT_INFRASTRUCTURE
assert "cliff.toml" in DEFAULT_INFRASTRUCTURE
assert "uv.lock" in DEFAULT_INFRASTRUCTURE
# Lint config
assert ".pre-commit-config.yaml" in DEFAULT_INFRASTRUCTURE
assert ".ruff.toml" in DEFAULT_INFRASTRUCTURE
assert ".ansible-lint" in DEFAULT_INFRASTRUCTURE
assert ".checkmake.ini" in DEFAULT_INFRASTRUCTURE
assert ".editorconfig" in DEFAULT_INFRASTRUCTURE
# Git config
assert ".gitignore" in DEFAULT_INFRASTRUCTURE
assert ".gitattributes" in DEFAULT_INFRASTRUCTURE
# Agent config
assert ".devin/**" in DEFAULT_INFRASTRUCTURE
# ---------------------------------------------------------------------------
# ChangeClassifier tests
# ---------------------------------------------------------------------------
class TestChangeClassifier:
def _make_classifier(self, **kwargs: object) -> ChangeClassifier:
"""Create a classifier with explicit config (no pyproject.toml needed)."""
config = ClassifierConfig(**kwargs) # type: ignore[arg-type]
return ChangeClassifier(config)
def test_infrastructure_pattern_matches(self) -> None:
classifier = self._make_classifier(infrastructure=[".gitea/**", "tests/**"])
fc = classifier.classify_file(".gitea/workflows/ci.yml")
assert not fc.is_user_facing
assert "infrastructure" in fc.matched_rule
def test_unknown_file_defaults_to_user_facing(self) -> None:
"""Safe default: unknown files are user-facing (require release)."""
assert is_user_facing("some/new/file.type") is True
assert is_user_facing("new_root_file.txt") is True
classifier = self._make_classifier(infrastructure=[".gitea/**"])
fc = classifier.classify_file("src/devx/cli.py")
assert fc.is_user_facing
assert fc.matched_rule is None
assert "default" in fc.reason.lower()
def test_is_workflow_only_inverse(self) -> None:
assert is_workflow_only(".gitea/workflows/ci.yml") is True
assert is_workflow_only("src/devx/cli.py") is False
assert is_workflow_only("pyproject.toml") is False
def test_infrastructure_override(self) -> None:
classifier = self._make_classifier(
infrastructure=[".gitea/**"],
infrastructure_overrides=["src/devx/__init__.py"],
)
fc = classifier.classify_file("src/devx/__init__.py")
assert not fc.is_user_facing
assert fc.matched_rule == "infrastructure_overrides"
def test_user_facing_override_beats_infrastructure(self) -> None:
"""User-facing overrides have highest priority (safety)."""
classifier = self._make_classifier(
infrastructure=["tests/**"],
user_facing_overrides=["tests/test_public_api.py"],
)
fc = classifier.classify_file("tests/test_public_api.py")
assert fc.is_user_facing
assert fc.matched_rule == "user_facing_overrides"
class TestClassifyChanges:
def test_all_user_facing(self) -> None:
files = ["src/devx/cli.py", "ansible/roles/gitea-runner/tasks/main.yml"]
result = classify_changes(files)
assert result["user_facing"] == files
assert result["workflow_only"] == []
def test_user_facing_override_beats_infrastructure_override(self) -> None:
"""User-facing overrides beat infrastructure overrides (safety first)."""
classifier = self._make_classifier(
infrastructure=[".gitea/**"],
infrastructure_overrides=["src/devx/__init__.py"],
user_facing_overrides=["src/devx/__init__.py"],
)
fc = classifier.classify_file("src/devx/__init__.py")
assert fc.is_user_facing
def test_all_workflow_only(self) -> None:
files = [".gitea/workflows/ci.yml", "docs/index.md", "AGENTS.md"]
result = classify_changes(files)
assert result["user_facing"] == []
assert result["workflow_only"] == files
def test_tags_are_computed(self) -> None:
classifier = self._make_classifier(
infrastructure=[".gitea/**"],
tags={"ansible": ["ansible/**", ".ansible-lint"], "docs": ["docs/**"]},
)
fc = classifier.classify_file("ansible/tasks/main.yml")
assert "ansible" in fc.tags
assert "docs" not in fc.tags
def test_mixed(self) -> None:
def test_tags_orthogonal_to_classification(self) -> None:
"""A file can be infrastructure AND tagged."""
classifier = self._make_classifier(
infrastructure=[".gitea/**", "docs/**"],
tags={"docs": ["docs/**"]},
)
fc = classifier.classify_file("docs/index.md")
assert not fc.is_user_facing # infrastructure
assert "docs" in fc.tags # also tagged
def test_classify_multiple_files(self) -> None:
classifier = self._make_classifier(
infrastructure=[".gitea/**", "tests/**"],
infrastructure_overrides=["src/devx/__init__.py"],
tags={"ansible": ["ansible/**"]},
)
files = [
"src/devx/cli.py",
".gitea/workflows/ci.yml",
"pyproject.toml",
"docs/index.md",
"src/devx/__init__.py",
"ansible/tasks/main.yml",
"tests/test_foo.py",
]
result = classify_changes(files)
assert "src/devx/cli.py" in result["user_facing"]
assert "pyproject.toml" in result["user_facing"]
assert ".gitea/workflows/ci.yml" in result["workflow_only"]
assert "docs/index.md" in result["workflow_only"]
result = classifier.classify(files)
assert "src/devx/cli.py" in result.user_facing
assert "ansible/tasks/main.yml" in result.user_facing
assert ".gitea/workflows/ci.yml" in result.infrastructure
assert "src/devx/__init__.py" in result.infrastructure
assert "tests/test_foo.py" in result.infrastructure
assert result.has_user_facing
assert result.has_tag("ansible")
assert "ansible/tasks/main.yml" in result.tags["ansible"]
def test_empty(self) -> None:
result = classify_changes([])
assert result == {"user_facing": [], "workflow_only": []}
def test_classify_empty(self) -> None:
classifier = self._make_classifier(infrastructure=[".gitea/**"])
result = classifier.classify([])
assert not result.has_user_facing
assert result.user_facing == []
assert result.infrastructure == []
def test_reason_is_human_readable(self) -> None:
classifier = self._make_classifier(infrastructure=[".gitea/**"])
fc = classifier.classify_file(".gitea/workflows/ci.yml")
assert ".gitea/**" in fc.reason
fc2 = classifier.classify_file("src/devx/cli.py")
assert "default" in fc2.reason.lower() or "user-facing" in fc2.reason.lower()
class TestClassificationResult:
def test_has_user_facing(self) -> None:
result = ClassificationResult(user_facing=["src/cli.py"])
assert result.has_user_facing
def test_has_user_facing_empty(self) -> None:
result = ClassificationResult()
assert not result.has_user_facing
def test_has_tag(self) -> None:
result = ClassificationResult(tags={"ansible": ["ansible/tasks/main.yml"]})
assert result.has_tag("ansible")
assert not result.has_tag("docs")
# ---------------------------------------------------------------------------
# Backward-compatible API tests
# ---------------------------------------------------------------------------
class TestBackwardCompatibleAPI:
def test_is_workflow_only_with_config(self) -> None:
"""is_workflow_only uses the config-driven classifier by default."""
with patch.object(classify_changes_mod, "_get_classifier") as mock:
classifier = MagicMock()
classifier.classify_file.return_value = FileClassification(
path=".gitea/workflows/ci.yml",
is_user_facing=False,
reason="test",
matched_rule="infrastructure: .gitea/**",
)
mock.return_value = classifier
assert is_workflow_only(".gitea/workflows/ci.yml") is True
def test_is_user_facing_with_config(self) -> None:
with patch.object(classify_changes_mod, "_get_classifier") as mock:
classifier = MagicMock()
classifier.classify_file.return_value = FileClassification(
path="src/devx/cli.py",
is_user_facing=True,
reason="test",
matched_rule=None,
)
mock.return_value = classifier
assert is_user_facing("src/devx/cli.py") is True
def test_legacy_patterns_mode(self) -> None:
"""is_workflow_only with explicit patterns uses legacy prefix matching."""
patterns = frozenset([".gitea/", "tests/"])
assert is_workflow_only(".gitea/workflows/ci.yml", patterns) is True
assert is_workflow_only("tests/test_foo.py", patterns) is True
assert is_workflow_only("src/devx/cli.py", patterns) is False
def test_classify_changes_with_config(self) -> None:
with patch.object(classify_changes_mod, "_get_classifier") as mock:
classifier = MagicMock()
classifier.classify.return_value = ClassificationResult(
user_facing=["src/devx/cli.py"],
infrastructure=[".gitea/workflows/ci.yml"],
)
mock.return_value = classifier
result = classify_changes(["src/devx/cli.py", ".gitea/workflows/ci.yml"])
assert "src/devx/cli.py" in result["user_facing"]
assert ".gitea/workflows/ci.yml" in result["workflow_only"]
def test_classify_changes_legacy_mode(self) -> None:
patterns = frozenset([".gitea/", "tests/"])
result = classify_changes([".gitea/ci.yml", "src/cli.py"], patterns)
assert ".gitea/ci.yml" in result["workflow_only"]
assert "src/cli.py" in result["user_facing"]
def test_has_user_facing_changes_with_config(self) -> None:
with (
patch.object(classify_changes_mod, "get_changed_files", return_value=["src/devx/cli.py"]),
patch.object(classify_changes_mod, "_get_classifier") as mock,
):
classifier = MagicMock()
classifier.classify.return_value = ClassificationResult(
user_facing=["src/devx/cli.py"],
)
mock.return_value = classifier
assert has_user_facing_changes("v0.1.0", "HEAD") is True
def test_has_user_facing_changes_legacy(self) -> None:
with patch.object(classify_changes_mod, "get_changed_files", return_value=[".gitea/ci.yml"]):
patterns = frozenset([".gitea/"])
assert has_user_facing_changes("v0.1.0", "HEAD", patterns) is False
# ---------------------------------------------------------------------------
# Git helper tests
# ---------------------------------------------------------------------------
class TestGetChangedFiles:
@@ -127,23 +430,6 @@ class TestGetChangedFiles:
assert result == []
class TestHasUserFacingChanges:
@patch("devx.ci.classify_changes.get_changed_files")
def test_true_when_user_facing(self, mock_get: MagicMock) -> None:
mock_get.return_value = ["src/devx/cli.py", "docs/index.md"]
assert has_user_facing_changes("v0.1.0", "HEAD") is True
@patch("devx.ci.classify_changes.get_changed_files")
def test_false_when_workflow_only(self, mock_get: MagicMock) -> None:
mock_get.return_value = [".gitea/workflows/ci.yml", "docs/index.md"]
assert has_user_facing_changes("v0.1.0", "HEAD") is False
@patch("devx.ci.classify_changes.get_changed_files")
def test_false_when_no_changes(self, mock_get: MagicMock) -> None:
mock_get.return_value = []
assert has_user_facing_changes("v0.1.0", "HEAD") is False
class TestGetLatestTag:
@patch("subprocess.run")
def test_returns_tag(self, mock_run: MagicMock) -> None:
@@ -170,6 +456,11 @@ class TestRunGit:
run_git(["git", "bad-command"])
# ---------------------------------------------------------------------------
# CLI tests
# ---------------------------------------------------------------------------
class TestMain:
@patch("devx.ci.classify_changes.get_latest_tag", return_value="")
def test_no_tags_outputs_true(self, mock_tag: MagicMock) -> None:
@@ -204,9 +495,28 @@ class TestMain:
assert result.exit_code == 0
assert "release needed" in result.output
@patch("devx.ci.classify_changes._get_classifier")
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_default_mode_displays_tags(
self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock
) -> None:
"""Default mode shows tag files when tags are configured."""
mock_changes.return_value = ["src/devx/cli.py", "ansible/tasks/main.yml"]
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={"ansible": ["ansible/**"]},
)
)
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "Ansible files" in result.output
assert "ansible/tasks/main.yml" in result.output
@patch("devx.ci.classify_changes.get_latest_tag", return_value="")
def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None:
"""Non-quiet mode with no tags prints user-facing message."""
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
@@ -215,7 +525,6 @@ class TestMain:
@patch("devx.ci.classify_changes.get_changed_files", return_value=[])
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_no_changes_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""Non-quiet mode with no changes prints message."""
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
@@ -224,7 +533,6 @@ class TestMain:
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_quiet_user_facing(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""Quiet mode with user-facing changes outputs true."""
mock_changes.return_value = ["src/devx/cli.py"]
runner = CliRunner()
result = runner.invoke(main, ["--quiet"])
@@ -234,7 +542,6 @@ class TestMain:
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_quiet_workflow_only(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""Quiet mode with workflow-only changes outputs false."""
mock_changes.return_value = [".gitea/workflows/ci.yml"]
runner = CliRunner()
result = runner.invoke(main, ["--quiet"])
@@ -244,28 +551,39 @@ class TestMain:
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_with_explicit_base(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""Explicit --base overrides latest tag."""
mock_changes.return_value = ["src/devx/cli.py"]
runner = CliRunner()
result = runner.invoke(main, ["--base", "v0.2.0", "--head", "HEAD"])
assert result.exit_code == 0
assert "release needed" in result.output
@patch("devx.ci.classify_changes._get_classifier")
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_ansible_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check ansible with Ansible changes outputs true."""
def test_check_ansible_true(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None:
mock_changes.return_value = ["ansible/tasks/main.yml", ".gitea/workflows/ci.yml"]
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={"ansible": ["ansible/**"]},
)
)
runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
assert result.exit_code == 0
assert "true" in result.output
@patch("devx.ci.classify_changes._get_classifier")
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_ansible_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check ansible with no Ansible changes outputs false."""
def test_check_ansible_false(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None:
mock_changes.return_value = ["src/devx/cli.py", ".gitea/workflows/ci.yml"]
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={"ansible": ["ansible/**"]},
)
)
runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
assert result.exit_code == 0
@@ -274,7 +592,6 @@ class TestMain:
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_user_facing_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check user-facing with user-facing changes outputs true."""
mock_changes.return_value = ["src/devx/cli.py", ".gitea/workflows/ci.yml"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
@@ -284,27 +601,47 @@ class TestMain:
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_user_facing_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check user-facing with only workflow changes outputs false."""
mock_changes.return_value = [".gitea/workflows/ci.yml", "tests/test_foo.py"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
assert result.exit_code == 0
assert "false" in result.output
@patch("devx.ci.classify_changes._get_classifier")
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_ansible_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check ansible in non-quiet mode prints file list."""
def test_check_ansible_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None:
mock_changes.return_value = ["ansible/tasks/main.yml"]
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={"ansible": ["ansible/**"]},
)
)
runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible"])
assert result.exit_code == 0
assert "Ansible changes detected" in result.output
@patch("devx.ci.classify_changes._get_classifier")
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_unknown_tag_raises(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None:
mock_changes.return_value = ["src/devx/cli.py"]
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={"ansible": ["ansible/**"]},
)
)
runner = CliRunner()
result = runner.invoke(main, ["--check", "nonexistent"])
assert result.exit_code != 0
assert "Unknown check category" in result.output
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_user_facing_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check user-facing in non-quiet mode prints file list."""
mock_changes.return_value = ["src/devx/cli.py"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "user-facing"])
@@ -313,7 +650,17 @@ class TestMain:
class TestGithubOutput:
def test_writes_outputs(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def _make_classifier_with_ansible(self) -> ChangeClassifier:
return ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**", "AGENTS.md"],
tags={"ansible": ["ansible/**"]},
)
)
@patch("devx.ci.classify_changes._get_classifier")
def test_writes_outputs(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(
@@ -326,7 +673,9 @@ class TestGithubOutput:
assert "ansible-changed=true" in content
assert "user-facing-changed=true" in content
def test_no_changes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@patch("devx.ci.classify_changes._get_classifier")
def test_no_changes(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
@@ -337,7 +686,9 @@ class TestGithubOutput:
assert "ansible-changed=false" in content
assert "user-facing-changed=false" in content
def test_no_tags(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@patch("devx.ci.classify_changes._get_classifier")
def test_no_tags(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(classify_changes_mod, "get_latest_tag", return_value=""):
@@ -355,7 +706,9 @@ class TestGithubOutput:
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
assert result.exit_code != 0
def test_workflow_only(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@patch("devx.ci.classify_changes._get_classifier")
def test_workflow_only(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(
@@ -367,3 +720,25 @@ class TestGithubOutput:
content = gh_file.read_text()
assert "ansible-changed=false" in content
assert "user-facing-changed=false" in content
@patch("devx.ci.classify_changes._get_classifier")
def test_no_tags_outputs_all_tags_true(
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""When no tags exist, only user-facing-changed is written."""
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={},
)
)
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(classify_changes_mod, "get_latest_tag", return_value=""):
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "user-facing-changed=true" in content
# No tag outputs since no tags are configured
assert "ansible-changed" not in content
+30
View File
@@ -166,6 +166,13 @@ class TestToolsCommands:
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.generate_badges", [])
@patch("devx.cli._run_module")
def test_tools_generate_cliff_config(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "generate-cliff-config"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.generate_cliff_config", [])
@patch("devx.cli._run_module")
def test_tools_install_checkmake(self, mock_run: MagicMock) -> None:
runner = CliRunner()
@@ -218,6 +225,29 @@ class TestMoleculeCommands:
mock_run.assert_called_once_with("devx.molecule.molecule_all", [])
class TestNewCiCommands:
@patch("devx.cli._run_module")
def test_ci_distribute_files(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "distribute-files", "--", "--pattern", "*.py"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.distribute_files", ["--pattern", "*.py"])
@patch("devx.cli._run_module")
def test_ci_merge_junit(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "merge-junit", "--", "--output", "merged.xml"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.merge_junit", ["--output", "merged.xml"])
@patch("devx.cli._run_module")
def test_ci_integration_guard(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "integration-guard", "--", "-v"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.integration_guard", ["-v"])
class TestRunModule:
@patch("importlib.import_module")
def test_run_module_success(self, mock_import: MagicMock) -> None:
+1 -11
View File
@@ -31,7 +31,7 @@ class TestDefaultConfigs:
config = _default_branch_protection_config()
assert config["branch_name"] == "master"
assert config["enable_push"] is True
assert config["enable_push_whitelist"] is True
assert config["enable_push_whitelist"] is False
assert config["required_approvals"] == 0
assert isinstance(config["status_check_contexts"], list)
assert "CI / quality (pull_request)" in config["status_check_contexts"]
@@ -45,16 +45,6 @@ class TestDefaultConfigs:
config = _default_branch_protection_config()
assert config["status_check_contexts"] == ["check1", "check2", "check3"]
def test_push_whitelist_from_env(self) -> None:
with patch.dict("os.environ", {"DEVX_PUSH_WHITELIST": "emil, alice"}):
config = _default_branch_protection_config()
assert config["push_whitelist_usernames"] == ["emil", "alice"]
def test_push_whitelist_empty_by_default(self) -> None:
with patch.dict("os.environ", {}, clear=True):
config = _default_branch_protection_config()
assert config["push_whitelist_usernames"] == []
class TestConfigureRepo:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
+29
View File
@@ -120,6 +120,35 @@ class TestQueryRunners:
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 0
@patch("devx.ci.discover_runners.requests.get")
def test_query_runners_403_no_warning(self, mock_get: MagicMock, capsys: pytest.CaptureFixture[str]) -> None:
"""403 on instance-level runners should not produce a warning (expected without admin scope)."""
responses = [
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(status_code=403, json=lambda: {"message": "forbidden"}),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
captured = capsys.readouterr()
assert "instance-level" not in captured.err
@patch("devx.ci.discover_runners.requests.get")
def test_instance_level_non_403_warns(self, mock_get: MagicMock, capsys: pytest.CaptureFixture[str]) -> None:
"""Non-200, non-403 status on instance-level runners should produce a warning."""
responses = [
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(status_code=500, json=lambda: {"message": "server error"}),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 2
captured = capsys.readouterr()
assert "instance-level" in captured.err
assert "500" in captured.err
class TestGetRunnerCount:
@patch("devx.ci.discover_runners.query_runners", return_value=5)
+154
View File
@@ -0,0 +1,154 @@
"""Unit tests for devx.ci.distribute_files."""
from pathlib import Path
import pytest
from click.testing import CliRunner
from devx.ci.distribute_files import (
DEFAULT_MAX_RUNNERS,
discover_files,
distribute,
files_for_runner,
main,
)
class TestDiscoverFiles:
def test_discovers_sorted(self, tmp_path: Path) -> None:
(tmp_path / "test_b.py").write_text("")
(tmp_path / "test_a.py").write_text("")
result = discover_files(str(tmp_path / "test_*.py"))
assert len(result) == 2
assert result[0].endswith("test_a.py")
assert result[1].endswith("test_b.py")
def test_no_matches(self, tmp_path: Path) -> None:
assert discover_files(str(tmp_path / "nonexistent-*.py")) == []
class TestDistribute:
def test_even_split(self) -> None:
files = [f"test_{i}.py" for i in range(6)]
groups = distribute(files, 3)
assert len(groups) == 3
assert all(len(g) == 2 for g in groups)
def test_uneven_split(self) -> None:
files = [f"test_{i}.py" for i in range(5)]
groups = distribute(files, 3)
assert len(groups[0]) == 2
assert len(groups[1]) == 2
assert len(groups[2]) == 1
def test_more_runners_than_files(self) -> None:
files = ["test_a.py"]
groups = distribute(files, 5)
assert len(groups) == 5
assert len(groups[0]) == 1
assert all(len(g) == 0 for g in groups[1:])
def test_empty(self) -> None:
assert distribute([], 3) == [[], [], []]
class TestFilesForRunner:
def test_returns_correct_subset(self) -> None:
files = [f"test_{i}.py" for i in range(6)]
assert len(files_for_runner(files, 0, 3)) == 2
assert len(files_for_runner(files, 1, 3)) == 2
assert len(files_for_runner(files, 2, 3)) == 2
def test_out_of_range_raises(self) -> None:
with pytest.raises(Exception, match="out of range"):
files_for_runner(["a.py"], 5, 3)
class TestCli:
def test_no_runner_index_prints_groups(self, tmp_path: Path) -> None:
for i in range(3):
(tmp_path / f"test_{i}.py").write_text("")
runner = CliRunner()
result = runner.invoke(main, ["--pattern", str(tmp_path / "test_*.py"), "--max-runners", "3"])
assert result.exit_code == 0
assert "Runner 0:" in result.output
assert "Runner 1:" in result.output
assert "Runner 2:" in result.output
def test_runner_index_prints_assigned(self, tmp_path: Path) -> None:
for i in range(3):
(tmp_path / f"test_{i}.py").write_text("")
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "1", "--max-runners", "3"],
)
assert result.exit_code == 0
assert "test_0.py" in result.output
def test_github_env_writes_files(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
for i in range(2):
(tmp_path / f"test_{i}.py").write_text("")
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "1", "--max-runners", "2", "--github-env"],
)
assert result.exit_code == 0
content = gh_file.read_text()
assert "ASSIGNED_FILES=" in content
assert "SKIP=false" in content
def test_skip_if_excess(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
(tmp_path / "test.py").write_text("")
runner = CliRunner()
result = runner.invoke(
main,
[
"--pattern",
str(tmp_path / "test_*.py"),
"--runner-index",
"5",
"--max-runners",
"2",
"--github-env",
"--skip-if-excess",
],
)
assert result.exit_code == 0
content = gh_file.read_text()
assert "ASSIGNED_FILES=\n" in content
assert "SKIP=true" in content
def test_runner_index_zero_raises(self, tmp_path: Path) -> None:
(tmp_path / "test.py").write_text("")
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "0", "--max-runners", "3"],
)
assert result.exit_code != 0
def test_no_env_var_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_ENV", raising=False)
(tmp_path / "test.py").write_text("")
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "1", "--max-runners", "3", "--github-env"],
)
assert result.exit_code != 0
def test_default_max_runners() -> None:
assert DEFAULT_MAX_RUNNERS == 3
def test_main_module_block() -> None:
import devx.ci.distribute_files as mod
assert hasattr(mod, "main")
+233 -5
View File
@@ -8,13 +8,19 @@ import pytest
from click.testing import CliRunner
from devx.molecule.distribute_molecule import (
DEFAULT_ROLES_ROOT,
MOLECULE_ROOT,
PLATFORMS,
MultiRoleTestPair,
TestPair,
build_multi_role_pairs,
build_pairs,
cli,
discover_multi_role_scenarios,
discover_scenarios,
distribute,
distribute_multi_role,
multi_role_pairs_for_runner,
pairs_for_runner,
)
@@ -157,10 +163,7 @@ class TestCli:
runner = CliRunner()
result = runner.invoke(cli, ["--list-platforms"])
assert result.exit_code == 0
assert "ubuntu-2204" in result.output
assert "ubuntu-2404" in result.output
assert "debian-12" in result.output
assert "archlinux" in result.output
assert "ubuntu-2604" in result.output
def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None:
from click.testing import CliRunner
@@ -192,7 +195,7 @@ class TestCli:
assert result.exit_code == 0
# Output should contain encoded pairs with platform info
assert "alpha|" in result.output
assert "ubuntu-2204" in result.output
assert "ubuntu-2604" in result.output
class TestGithubEnv:
@@ -240,6 +243,16 @@ class TestGithubEnv:
assert result.exit_code != 0
class TestRunnerIndexValidation:
def test_runner_index_zero_raises(self) -> None:
"""Runner index < 1 should raise."""
with patch("devx.molecule.distribute_molecule.discover_scenarios", return_value=["dummy"]):
runner = CliRunner()
result = runner.invoke(cli, ["--runner-index", "0", "--max-runners", "3"])
assert result.exit_code != 0
assert "out of range" in result.output
def test_main_module_block() -> None:
import devx.molecule.distribute_molecule as dm
@@ -249,3 +262,218 @@ def test_main_module_block() -> None:
namespace = dict(dm.__dict__)
exec(compile(source, dm.__file__, "exec"), namespace)
assert callable(namespace["cli"])
class TestDiscoverMultiRole:
def test_discovers_role_scenario_pairs(self, tmp_path: Path) -> None:
roles = tmp_path / "roles"
for scenario in ["default", "binary"]:
(roles / "gitea-runner" / "molecule" / scenario).mkdir(parents=True)
(roles / "gitea-runner" / "molecule" / "common").mkdir(parents=True)
(roles / "gitea-runner" / "molecule" / "_shared").mkdir(parents=True)
(roles / "docker-base" / "molecule" / "default").mkdir(parents=True)
(roles / "no-molecule").mkdir(parents=True)
result = discover_multi_role_scenarios(roles)
assert ("docker-base", "default") in result
assert ("gitea-runner", "default") in result
assert ("gitea-runner", "binary") in result
assert ("gitea-runner", "common") not in result
assert ("gitea-runner", "_shared") not in result
assert len(result) == 3
def test_raises_when_dir_missing(self, tmp_path: Path) -> None:
with pytest.raises(click.ClickException) as exc:
discover_multi_role_scenarios(tmp_path / "nonexistent")
assert "not found" in str(exc.value)
def test_default_roles_root_raises_when_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Calling with no args uses DEFAULT_ROLES_ROOT which doesn't exist in tests."""
with pytest.raises(click.ClickException):
discover_multi_role_scenarios()
def test_default_roles_root_constant(self) -> None:
assert Path("ansible/roles") == DEFAULT_ROLES_ROOT
class TestMultiRoleTestPair:
def test_encode_roundtrip(self) -> None:
pair = MultiRoleTestPair(
"docker-base", "default", {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": ""}
)
encoded = pair.encode()
assert encoded == "docker-base|default|ubuntu-2204|ubuntu:22.04|"
decoded = MultiRoleTestPair.decode(encoded)
assert decoded.role == "docker-base"
assert decoded.scenario == "default"
assert decoded.platform["name"] == "ubuntu-2204"
class TestBuildMultiRolePairs:
def test_cross_product(self) -> None:
role_scenarios = [("role-a", "default"), ("role-b", "binary")]
platforms = [{"name": "p1", "image": "i1", "command": ""}]
pairs = build_multi_role_pairs(role_scenarios, platforms)
assert len(pairs) == 2
assert pairs[0].role == "role-a"
assert pairs[1].role == "role-b"
def test_default_platforms(self) -> None:
pairs = build_multi_role_pairs([("r", "s")])
assert len(pairs) == len(PLATFORMS)
class TestDistributeMultiRole:
def test_even_split(self) -> None:
pairs = [MultiRoleTestPair(f"r{i}", "s", {"name": "p", "image": "i", "command": ""}) for i in range(6)]
groups = distribute_multi_role(pairs, 3)
assert all(len(g) == 2 for g in groups)
def test_out_of_range_raises(self) -> None:
pairs = [MultiRoleTestPair("r", "s", {"name": "p", "image": "i", "command": ""})]
with pytest.raises(click.ClickException):
multi_role_pairs_for_runner(pairs, 5, 3)
class TestCliMultiRole:
def test_roles_root_list(self, tmp_path: Path) -> None:
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
(roles / "role-b" / "molecule" / "binary").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(cli, ["--roles-root", str(roles), "--list"])
assert result.exit_code == 0
assert "role-a|default" in result.output
assert "role-b|binary" in result.output
def test_roles_root_runner_index(self, tmp_path: Path) -> None:
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(cli, ["--roles-root", str(roles), "--runner-index", "1", "--max-runners", "3"])
assert result.exit_code == 0
assert "role-a|default|" in result.output
def test_roles_root_github_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(
cli,
["--roles-root", str(roles), "--runner-index", "1", "--max-runners", "3", "--github-env"],
)
assert result.exit_code == 0
content = gh_file.read_text()
assert "TEST_PAIRS=" in content
assert "SKIP=false" in content
def test_roles_root_skip_if_excess(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(
cli,
[
"--roles-root",
str(roles),
"--runner-index",
"5",
"--max-runners",
"2",
"--github-env",
"--skip-if-excess",
],
)
assert result.exit_code == 0
content = gh_file.read_text()
assert "SKIP=true" in content
def test_molecule_root_option(self, tmp_path: Path) -> None:
root = tmp_path / "custom-molecule"
(root / "alpha").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(cli, ["--molecule-root", str(root), "--list"])
assert result.exit_code == 0
assert "alpha" in result.output
def test_roles_root_list_platforms(self, tmp_path: Path) -> None:
"""--roles-root --list-platforms prints platforms."""
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(cli, ["--roles-root", str(roles), "--list-platforms"])
assert result.exit_code == 0
assert "ubuntu-2604" in result.output
def test_roles_root_no_runner_index_prints_groups(self, tmp_path: Path) -> None:
"""--roles-root without --runner-index prints all groups."""
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
(roles / "role-b" / "molecule" / "binary").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(cli, ["--roles-root", str(roles), "--max-runners", "2"])
assert result.exit_code == 0
assert "Runner 0:" in result.output
assert "Runner 1:" in result.output
def test_platforms_file_overrides_default(self, tmp_path: Path) -> None:
"""--platforms-file loads custom platforms from JSON."""
import json
from click.testing import CliRunner
from devx.molecule.distribute_molecule import cli
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
platforms_file = tmp_path / "platforms.json"
custom = [{"name": "custom-os", "image": "custom:latest", "command": "sleep infinity"}]
platforms_file.write_text(json.dumps(custom))
runner = CliRunner()
result = runner.invoke(
cli, ["--roles-root", str(roles), "--platforms-file", str(platforms_file), "--list-platforms"]
)
assert result.exit_code == 0
assert "custom-os" in result.output
assert "custom:latest" in result.output
def test_roles_root_skips_non_dir_role(self, tmp_path: Path) -> None:
"""Non-directory entries in roles root are skipped."""
roles = tmp_path / "roles"
roles.mkdir(parents=True)
(roles / "README.md").write_text("not a role")
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
result = discover_multi_role_scenarios(roles)
assert ("role-a", "default") in result
assert len(result) == 1
def test_roles_root_skips_non_dir_scenario(self, tmp_path: Path) -> None:
"""Non-directory entries in molecule dir are skipped."""
roles = tmp_path / "roles"
(roles / "role-a" / "molecule").mkdir(parents=True)
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
(roles / "role-a" / "molecule" / "file.txt").write_text("not a scenario")
result = discover_multi_role_scenarios(roles)
assert ("role-a", "default") in result
assert len(result) == 1
def test_roles_root_skips_role_without_molecule(self, tmp_path: Path) -> None:
"""Roles without a molecule/ directory are skipped."""
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
(roles / "no-molecule").mkdir(parents=True)
result = discover_multi_role_scenarios(roles)
assert ("role-a", "default") in result
assert len(result) == 1
def test_roles_root_runner_index_zero_raises(self, tmp_path: Path) -> None:
"""--roles-root --runner-index 0 should raise."""
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(cli, ["--roles-root", str(roles), "--runner-index", "0", "--max-runners", "3"])
assert result.exit_code != 0
assert "out of range" in result.output
+124
View File
@@ -0,0 +1,124 @@
"""Tests for devx.tools.generate_cliff_config."""
from __future__ import annotations
import tomllib
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from devx.tools.generate_cliff_config import main
class TestGenerateCliffConfig:
"""Tests for the generate_cliff_config tool."""
@pytest.fixture
def runner(self) -> CliRunner:
return CliRunner()
def test_generate_to_new_file(self, runner: CliRunner, tmp_path: Path) -> None:
"""Generate cliff.toml to a new file."""
output = tmp_path / "cliff.toml"
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
assert result.exit_code == 0
assert output.exists()
content = output.read_text()
assert "git-cliff configuration for GRM" in content
assert 'pattern = "^GRM-\\\\d+:\\\\s+"' in content
def test_generate_with_default_prefix(self, runner: CliRunner, tmp_path: Path) -> None:
"""Generate with default prefix (DEVX_TASK_PREFIX or 'DEVX')."""
output = tmp_path / "cliff.toml"
with patch("devx.tools.generate_cliff_config.TASK_PREFIX", "DEVX"):
result = runner.invoke(main, ["--output", str(output)])
assert result.exit_code == 0
content = output.read_text()
assert "git-cliff configuration for DEVX" in content
def test_existing_file_without_force(self, runner: CliRunner, tmp_path: Path) -> None:
"""Refuse to overwrite existing file without --force."""
output = tmp_path / "cliff.toml"
output.write_text("# existing")
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
assert result.exit_code != 0
assert "already exists" in result.output
assert output.read_text() == "# existing"
def test_existing_file_with_force(self, runner: CliRunner, tmp_path: Path) -> None:
"""Overwrite existing file with --force."""
output = tmp_path / "cliff.toml"
output.write_text("# existing")
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output), "--force"])
assert result.exit_code == 0
content = output.read_text()
assert "git-cliff configuration for GRM" in content
assert "# existing" not in content
def test_generated_config_is_valid_toml(self, runner: CliRunner, tmp_path: Path) -> None:
"""Generated config must be valid TOML."""
output = tmp_path / "cliff.toml"
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
assert result.exit_code == 0
with open(output, "rb") as f:
data = tomllib.load(f)
assert "changelog" in data
assert "git" in data
assert "bump" in data
assert data["bump"]["initial_tag"] == "0.1.0"
assert data["bump"]["features_always_bump_minor"] is True
def test_generated_config_has_correct_preprocessor(self, runner: CliRunner, tmp_path: Path) -> None:
"""Preprocessor pattern must match the given prefix."""
output = tmp_path / "cliff.toml"
result = runner.invoke(main, ["--prefix", "INFRA", "--output", str(output)])
assert result.exit_code == 0
with open(output, "rb") as f:
data = tomllib.load(f)
preprocessors = data["git"]["commit_preprocessors"]
assert len(preprocessors) == 1
pattern = preprocessors[0]["pattern"]
assert "INFRA" in pattern
def test_generated_config_has_commit_parsers(self, runner: CliRunner, tmp_path: Path) -> None:
"""Generated config must have all standard commit parsers."""
output = tmp_path / "cliff.toml"
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
assert result.exit_code == 0
with open(output, "rb") as f:
data = tomllib.load(f)
parsers = data["git"]["commit_parsers"]
# Should have feat, fix, perf, refactor, doc, test, style, chore, ci, release, security, revert, catch-all
messages = [p["message"] for p in parsers if "message" in p]
assert "^feat" in messages
assert "^fix" in messages
assert "^perf" in messages
assert "^refactor" in messages
assert "^release:" in messages
assert "^revert" in messages
assert ".*" in messages # catch-all
def test_default_output_path(self, runner: CliRunner, tmp_path: Path) -> None:
"""Default output path is cliff.toml in current directory."""
output = tmp_path / "cliff.toml"
# Change to tmp_path so default cliff.toml is created there
import os
old_cwd = os.getcwd()
os.chdir(tmp_path)
try:
result = runner.invoke(main, ["--prefix", "GRM"])
assert result.exit_code == 0
assert output.exists()
finally:
os.chdir(old_cwd)
def test_success_message(self, runner: CliRunner, tmp_path: Path) -> None:
"""Success message includes file and prefix."""
output = tmp_path / "cliff.toml"
result = runner.invoke(main, ["--prefix", "GRM", "--output", str(output)])
assert result.exit_code == 0
assert "Generated" in result.output
assert "GRM" in result.output
+311
View File
@@ -0,0 +1,311 @@
"""Unit tests for devx.ci.integration_guard."""
from __future__ import annotations
import os
import subprocess # nosec B404
import time
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.ci.integration_guard import cli
class TestCli:
def test_all_pass(self) -> None:
with (
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["--", "tests/integration/test_foo.py"])
assert result.exit_code == 0
assert "Integration tests passed" in result.output
def test_failure_exits_nonzero(self) -> None:
with (
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 1
proc.returncode = 1
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["--", "tests/integration/test_foo.py"])
assert result.exit_code == 1
assert "failed" in result.output
def test_junit_output_passed_to_pytest(self) -> None:
with (
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
["--junit-output", "junit-results/runner-1.xml", "--", "test_foo.py"],
)
assert result.exit_code == 0
call_args = mock_popen.call_args[0][0]
assert "--junitxml" in call_args
assert "junit-results/runner-1.xml" in call_args
def test_pytest_args_passed_through(self) -> None:
with (
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
["--", "-x", "-v", "--tb=short", "test_a.py", "test_b.py"],
)
assert result.exit_code == 0
call_args = mock_popen.call_args[0][0]
assert "-x" in call_args
assert "-v" in call_args
assert "test_a.py" in call_args
assert "test_b.py" in call_args
def test_keyboard_interrupt_kills_process(self) -> None:
with (
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("time.sleep", side_effect=KeyboardInterrupt),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
):
mock_getpgid.return_value = 123
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["--", "test_foo.py"])
assert result.exit_code == 1
mock_killpg.assert_called()
def test_exits_when_other_runner_fails(self) -> None:
real_sleep = time.sleep
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "integration-tests (1)", "conclusion": "running"}]
return [
{"name": "integration-tests (0)", "conclusion": "running"},
{"name": "integration-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/infra",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["--", "test_foo.py"])
assert result.exit_code == 1
mock_killpg.assert_called()
assert "cancelled" in result.output.lower()
def test_process_lookup_error_suppressed(self) -> None:
real_sleep = time.sleep
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "integration-tests (1)", "conclusion": "running"}]
return [
{"name": "integration-tests (0)", "conclusion": "running"},
{"name": "integration-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/infra",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg", side_effect=ProcessLookupError("no such process")),
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["--", "test_foo.py"])
assert result.exit_code == 1
def test_timeout_expired_kills_with_sigkill(self) -> None:
real_sleep = time.sleep
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "integration-tests (1)", "conclusion": "running"}]
return [
{"name": "integration-tests (0)", "conclusion": "running"},
{"name": "integration-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/infra",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
proc.poll.return_value = None
proc.wait.side_effect = [subprocess.TimeoutExpired("cmd", 10)]
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["--", "test_foo.py"])
assert result.exit_code == 1
# SIGKILL should have been called (second killpg call)
assert mock_killpg.call_count >= 2
def test_no_env_vars_runs_without_polling(self) -> None:
with (
patch.dict(os.environ, {"PATH": os.environ.get("PATH", "")}, clear=True),
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["--", "test_foo.py"])
assert result.exit_code == 0
assert "without cross-runner cancellation" in result.output
def test_partial_env_vars_runs_without_polling(self) -> None:
"""Only GITEA_URL set (missing REPO_TOKEN and RUN_ID) — should skip polling."""
with (
patch.dict(
os.environ,
{"GITEA_URL": "https://gitea.example", "PATH": os.environ.get("PATH", "")},
clear=True,
),
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["--", "test_foo.py"])
assert result.exit_code == 0
assert "without cross-runner cancellation" in result.output
def test_invalid_repository_falls_back_to_default(self) -> None:
"""GITEA_REPOSITORY without '/' falls back to oblachno-oss/devx."""
with (
patch.dict(
os.environ,
{"GITEA_REPOSITORY": "invalid", "PATH": os.environ.get("PATH", "")},
clear=True,
),
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["--", "test_foo.py"])
assert result.exit_code == 0
def test_main_module_block() -> None:
import devx.ci.integration_guard as ig
with open(ig.__file__) as f:
source = f.read()
source = source.replace('if __name__ == "__main__":\n cli()\n', "")
namespace = dict(ig.__dict__)
exec(compile(source, ig.__file__, "exec"), namespace)
assert callable(namespace["cli"])
+91
View File
@@ -0,0 +1,91 @@
"""Unit tests for devx.ci.merge_junit."""
from pathlib import Path
from xml.etree import ElementTree as ET
import pytest
from click.testing import CliRunner
from devx.ci.merge_junit import main, merge_files
def _write_suite(path: Path, name: str, tests: int, failures: int) -> None:
suite = ET.Element("testsuite", name=name, tests=str(tests), failures=str(failures))
for i in range(tests):
tc = ET.SubElement(suite, "testcase", classname="cls", name=f"test{i}", time="0.1")
if i < failures:
ET.SubElement(tc, "failure", message="fail")
tree = ET.ElementTree(suite)
tree.write(path, encoding="UTF-8", xml_declaration=True)
class TestMergeFiles:
def test_merges_multiple_suites(self, tmp_path: Path) -> None:
_write_suite(tmp_path / "runner-1.xml", "r1", tests=3, failures=1)
_write_suite(tmp_path / "runner-2.xml", "r2", tests=2, failures=0)
merged, total_tests, total_failures = merge_files(str(tmp_path / "runner-*.xml"))
assert total_tests == 5
assert total_failures == 1
assert merged.tag == "testsuites"
assert len(merged) == 2
def test_no_files_returns_empty(self, tmp_path: Path) -> None:
merged, total_tests, total_failures = merge_files(str(tmp_path / "nonexistent-*.xml"))
assert total_tests == 0
assert total_failures == 0
assert merged.tag == "testsuites"
assert len(merged) == 0
def test_handles_testsuites_wrapper_root(self, tmp_path: Path) -> None:
wrapper = ET.Element("testsuites")
suite = ET.SubElement(wrapper, "testsuite", name="r1", tests="4", failures="2")
ET.SubElement(suite, "testcase", classname="c", name="t", time="0.1")
tree = ET.ElementTree(wrapper)
tree.write(tmp_path / "runner-1.xml", encoding="UTF-8", xml_declaration=True)
merged, total_tests, total_failures = merge_files(str(tmp_path / "runner-*.xml"))
assert total_tests == 4
assert total_failures == 2
class TestCli:
def test_writes_merged_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_write_suite(tmp_path / "runner-1.xml", "r1", tests=2, failures=0)
_write_suite(tmp_path / "runner-2.xml", "r2", tests=3, failures=0)
out = tmp_path / "merged.xml"
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "runner-*.xml"), "--output", str(out)],
)
assert result.exit_code == 0
assert out.exists()
tree = ET.parse(out)
root = tree.getroot()
assert root.get("tests") == "5"
assert root.get("failures") == "0"
def test_exits_nonzero_on_failures(self, tmp_path: Path) -> None:
_write_suite(tmp_path / "runner-1.xml", "r1", tests=2, failures=1)
out = tmp_path / "merged.xml"
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "runner-*.xml"), "--output", str(out)],
)
assert result.exit_code != 0
assert "failures" in result.output
def test_no_files_exits_zero(self, tmp_path: Path) -> None:
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "nonexistent-*.xml"), "--output", str(tmp_path / "out.xml")],
)
assert result.exit_code == 0
assert "No JUnit" in result.output or "skipping" in result.output
def test_main_module_block() -> None:
import devx.ci.merge_junit as mod
assert hasattr(mod, "main")
+252
View File
@@ -5,8 +5,11 @@ from __future__ import annotations
import os
import subprocess # nosec B404
import time
import xml.etree.ElementTree as ET
from pathlib import Path
from unittest.mock import MagicMock, patch
import click
import pytest
import requests
@@ -16,7 +19,10 @@ from devx.molecule.molecule_ci_guard import (
build_molecule_cmd,
cli,
get_running_jobs,
parse_pair,
poll_for_other_failures,
resolve_role_dir,
write_junit_report,
)
@@ -168,6 +174,15 @@ class TestCli:
assert result.exit_code == 0
assert "All molecule tests passed" in result.output
def test_invalid_pair_format_raises(self) -> None:
"""Pair with fewer than 2 parts should raise."""
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(cli, ["invalid_no_pipe"])
assert result.exit_code != 0
assert "Invalid pair format" in result.output
def test_failure_exits_nonzero(self) -> None:
from click.testing import CliRunner
@@ -426,3 +441,240 @@ def test_main_module_block() -> None:
namespace = dict(mg.__dict__)
exec(compile(source, mg.__file__, "exec"), namespace)
assert callable(namespace["cli"])
class TestParsePair:
def test_single_role_4_part(self) -> None:
role, scenario, name, image, cmd = parse_pair("default|ubuntu-2204|ubuntu:22.04|")
assert role == ""
assert scenario == "default"
assert name == "ubuntu-2204"
assert image == "ubuntu:22.04"
assert cmd == ""
def test_multi_role_5_part(self) -> None:
role, scenario, name, image, cmd = parse_pair("gitea-runner|default|ubuntu-2204|ubuntu:22.04|")
assert role == "gitea-runner"
assert scenario == "default"
assert name == "ubuntu-2204"
assert image == "ubuntu:22.04"
assert cmd == ""
def test_multi_role_with_command(self) -> None:
role, scenario, name, image, cmd = parse_pair(
"docker-base|lifecycle|archlinux|archlinux:latest|/usr/lib/systemd/systemd"
)
assert role == "docker-base"
assert scenario == "lifecycle"
assert cmd == "/usr/lib/systemd/systemd"
def test_invalid_pair_raises(self) -> None:
with pytest.raises(click.ClickException, match="Invalid pair format"):
parse_pair("only|two|parts")
def test_too_many_parts_raises(self) -> None:
with pytest.raises(click.ClickException, match="Invalid pair format"):
parse_pair("a|b|c|d|e|f")
class TestResolveRoleDir:
def test_multi_role_with_roles_root(self, tmp_path: Path) -> None:
roles_root = tmp_path / "ansible" / "roles"
roles_root.mkdir(parents=True)
result = resolve_role_dir("gitea-runner", roles_root, tmp_path)
assert result == roles_root / "gitea-runner"
def test_multi_role_default_roles_root(self, tmp_path: Path) -> None:
result = resolve_role_dir("docker-base", None, tmp_path)
assert result == tmp_path / "ansible" / "roles" / "docker-base"
def test_single_role_uses_default(self, tmp_path: Path) -> None:
result = resolve_role_dir("", None, tmp_path)
assert result == tmp_path / "ansible" / "roles" / "gitea-runner"
class TestWriteJunitReport:
def test_writes_report_with_passing_tests(self, tmp_path: Path) -> None:
output = str(tmp_path / "junit-results" / "runner-1.xml")
testcases = [
{"role": "gitea-runner", "scenario": "default", "time": 5.2, "passed": True, "error": None},
{"role": "docker-base", "scenario": "lifecycle", "time": 3.1, "passed": True, "error": None},
]
write_junit_report(output, testcases, 1)
tree = ET.parse(output)
root = tree.getroot()
assert root.get("tests") == "2"
assert root.get("failures") == "0"
assert len(root) == 2
def test_writes_report_with_failures(self, tmp_path: Path) -> None:
output = str(tmp_path / "runner-2.xml")
testcases = [
{"role": "", "scenario": "default", "time": 1.0, "passed": False, "error": "Exit code: 1"},
]
write_junit_report(output, testcases, 2)
tree = ET.parse(output)
root = tree.getroot()
assert root.get("tests") == "1"
assert root.get("failures") == "1"
failure = root[0][0]
assert failure.tag == "failure"
assert failure.text == "Exit code: 1"
def test_creates_parent_directory(self, tmp_path: Path) -> None:
output = str(tmp_path / "deep" / "nested" / "dir" / "runner.xml")
write_junit_report(output, [], 0)
assert Path(output).exists()
class TestCliMultiRole:
def test_multi_role_pair_passes(self, tmp_path: Path) -> None:
from click.testing import CliRunner
roles_root = tmp_path / "ansible" / "roles"
(roles_root / "gitea-runner").mkdir(parents=True)
with (
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
["--roles-root", str(roles_root), "gitea-runner|default|ubuntu-2204|ubuntu:22.04|"],
)
assert result.exit_code == 0
assert "All molecule tests passed" in result.output
def test_junit_output_written(self, tmp_path: Path) -> None:
from click.testing import CliRunner
roles_root = tmp_path / "ansible" / "roles"
(roles_root / "gitea-runner").mkdir(parents=True)
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
with (
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
[
"--roles-root",
str(roles_root),
"--junit-output",
junit_path,
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
],
)
assert result.exit_code == 0
assert Path(junit_path).exists()
def test_junit_output_on_failure(self, tmp_path: Path) -> None:
from click.testing import CliRunner
roles_root = tmp_path / "ansible" / "roles"
(roles_root / "gitea-runner").mkdir(parents=True)
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
with (
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 1
proc.returncode = 1
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
[
"--roles-root",
str(roles_root),
"--junit-output",
junit_path,
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
],
)
assert result.exit_code == 1
assert Path(junit_path).exists()
tree = ET.parse(junit_path)
assert tree.getroot().get("failures") == "1"
def test_junit_output_on_cancellation(self, tmp_path: Path) -> None:
"""JUnit report is written when a runner is cancelled by another runner's failure."""
from click.testing import CliRunner
real_sleep = time.sleep
roles_root = tmp_path / "ansible" / "roles"
(roles_root / "gitea-runner").mkdir(parents=True)
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
return [
{"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/infra",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg"),
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
[
"--roles-root",
str(roles_root),
"--junit-output",
junit_path,
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
],
)
assert result.exit_code == 1
assert Path(junit_path).exists()
tree = ET.parse(junit_path)
root = tree.getroot()
assert root.get("failures") == "1"
# The failure message should mention cancellation
failure = root[0][0]
assert "Cancelled" in (failure.text or "")
+103 -1
View File
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.ci.notify_failure import main
from devx.ci.notify_failure import _configure_tea_login, main
from devx.gitea_cli import TeaCLIError
@@ -114,3 +114,105 @@ class TestNotifyFailure:
)
assert result.exit_code != 0
assert "REPO_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("devx.ci.notify_failure.shutil.which", return_value=None)
@patch("devx.ci.notify_failure.TeaCLI")
def test_auto_login_no_tea_skips(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
"""--auto-login with tea not installed skips login and still creates issue."""
mock_tea = MagicMock()
mock_tea.list_labels.return_value = []
mock_tea.create_issue.return_value = {"index": 60, "title": "test"}
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"],
)
assert result.exit_code == 0
assert "issue #60" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
@patch("devx.ci.notify_failure.TeaCLI")
def test_auto_login_no_token_skips_login(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
"""--auto-login with no REPO_TOKEN skips login but raises before creating issue."""
mock_tea = MagicMock()
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"],
)
assert result.exit_code != 0
assert "REPO_TOKEN" in result.output
class TestConfigureTeaLogin:
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
def test_no_token_skips(self, mock_which: MagicMock) -> None:
"""_configure_tea_login with no token prints skip message and returns."""
_configure_tea_login()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("devx.ci.notify_failure.shutil.which", return_value=None)
def test_no_tea_skips(self, mock_which: MagicMock) -> None:
"""_configure_tea_login with no tea binary prints skip message and returns."""
_configure_tea_login()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
@patch("devx.ci.notify_failure.subprocess.run")
@patch("devx.ci.notify_failure.TeaCLI")
def test_auto_login_configures_tea(
self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock
) -> None:
"""--auto-login calls tea login add and default."""
mock_run = MagicMock()
mock_run.returncode = 0
mock_run.stdout = ""
mock_subprocess.return_value = mock_run
mock_tea = MagicMock()
mock_tea.list_labels.return_value = []
mock_tea.create_issue.return_value = {"index": 61, "title": "test"}
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"],
)
assert result.exit_code == 0
assert "issue #61" in result.output
# tea login add was called
assert mock_subprocess.call_count >= 2
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
@patch("devx.ci.notify_failure.subprocess.run")
@patch("devx.ci.notify_failure.TeaCLI")
def test_auto_login_skips_if_already_configured(
self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock
) -> None:
"""--auto-login skips tea login add if login already exists."""
mock_list = MagicMock()
mock_list.returncode = 0
mock_list.stdout = "devx https://git.example.com"
mock_subprocess.return_value = mock_list
mock_tea = MagicMock()
mock_tea.list_labels.return_value = []
mock_tea.create_issue.return_value = {"index": 62, "title": "test"}
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"],
)
assert result.exit_code == 0
assert "already configured" in result.output
+192
View File
@@ -0,0 +1,192 @@
"""Unit tests for devx.opentofu."""
from __future__ import annotations
import json
from pathlib import Path
from subprocess import CompletedProcess
from unittest.mock import MagicMock, patch
import pytest
from devx.opentofu import get_tofu_output, get_tofu_vm_field, get_tofu_vm_ip
class TestGetTofuOutput:
@patch("devx.opentofu.subprocess.run")
def test_returns_parsed_json(self, mock_run: MagicMock) -> None:
payload = {"staging": {"ipv4": "1.2.3.4"}}
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "customer_vms"],
returncode=0,
stdout=json.dumps(payload),
stderr="",
)
result = get_tofu_output("customer_vms", cwd="/tmp/tofu/staging")
assert result == payload
mock_run.assert_called_once()
call_kwargs = mock_run.call_args
assert call_kwargs.args[0] == ["tofu", "output", "-json", "customer_vms"]
assert call_kwargs.kwargs["cwd"] == "/tmp/tofu/staging"
assert call_kwargs.kwargs["env"] is None
@patch("devx.opentofu.subprocess.run")
def test_with_env(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "obs"],
returncode=0,
stdout='{"staging": {"ipv4": "5.6.7.8"}}',
stderr="",
)
env = {"HCLOUD_TOKEN": "secret"}
result = get_tofu_output("obs", cwd=Path("/tmp"), env=env)
assert result == {"staging": {"ipv4": "5.6.7.8"}}
assert mock_run.call_args.kwargs["env"] == env
@patch("devx.opentofu.subprocess.run")
def test_no_cwd(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "x"],
returncode=0,
stdout='{"a": 1}',
stderr="",
)
result = get_tofu_output("x")
assert result == {"a": 1}
assert mock_run.call_args.kwargs["cwd"] is None
@patch("devx.opentofu.subprocess.run")
def test_pathlib_cwd(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "x"],
returncode=0,
stdout="{}",
stderr="",
)
get_tofu_output("x", cwd=Path("/some/path"))
assert mock_run.call_args.kwargs["cwd"] == "/some/path"
@patch("devx.opentofu.subprocess.run")
def test_failure_raises_runtime_error(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "x"],
returncode=1,
stdout="",
stderr="Error: module not found",
)
with pytest.raises(RuntimeError, match="tofu output failed"):
get_tofu_output("x", cwd="/tmp")
@patch("devx.opentofu.subprocess.run")
def test_invalid_json_raises(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "x"],
returncode=0,
stdout="not json",
stderr="",
)
with pytest.raises(json.JSONDecodeError):
get_tofu_output("x")
class TestGetTofuVmIp:
@patch("devx.opentofu.subprocess.run")
def test_returns_ipv4(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "customer_vms"],
returncode=0,
stdout=json.dumps({"oblachno": {"ipv4": "10.0.0.1"}}),
stderr="",
)
ip = get_tofu_vm_ip("customer_vms", "oblachno", cwd="/tmp")
assert ip == "10.0.0.1"
@patch("devx.opentofu.subprocess.run")
def test_missing_vm_returns_empty(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "customer_vms"],
returncode=0,
stdout=json.dumps({"other": {"ipv4": "10.0.0.2"}}),
stderr="",
)
ip = get_tofu_vm_ip("customer_vms", "missing", cwd="/tmp")
assert ip == ""
@patch("devx.opentofu.subprocess.run")
def test_missing_ip_field_returns_empty(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "customer_vms"],
returncode=0,
stdout=json.dumps({"vm1": {"name": "test"}}),
stderr="",
)
ip = get_tofu_vm_ip("customer_vms", "vm1", cwd="/tmp")
assert ip == ""
@patch("devx.opentofu.subprocess.run")
def test_custom_ip_field(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "vms"],
returncode=0,
stdout=json.dumps({"vm1": {"address": "192.168.1.1"}}),
stderr="",
)
ip = get_tofu_vm_ip("vms", "vm1", cwd="/tmp", ip_field="address")
assert ip == "192.168.1.1"
@patch("devx.opentofu.subprocess.run")
def test_non_dict_output_returns_empty(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "vms"],
returncode=0,
stdout='["not", "a", "dict"]',
stderr="",
)
ip = get_tofu_vm_ip("vms", "vm1", cwd="/tmp")
assert ip == ""
class TestGetTofuVmField:
@patch("devx.opentofu.subprocess.run")
def test_returns_field_value(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "obs"],
returncode=0,
stdout=json.dumps({"staging": {"volume_linux_device": "/dev/sda1"}}),
stderr="",
)
val = get_tofu_vm_field("obs", "staging", "volume_linux_device", cwd="/tmp")
assert val == "/dev/sda1"
@patch("devx.opentofu.subprocess.run")
def test_missing_field_returns_empty(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "obs"],
returncode=0,
stdout=json.dumps({"staging": {"ipv4": "1.2.3.4"}}),
stderr="",
)
val = get_tofu_vm_field("obs", "staging", "volume_linux_device", cwd="/tmp")
assert val == ""
@patch("devx.opentofu.subprocess.run")
def test_missing_vm_returns_empty(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "obs"],
returncode=0,
stdout=json.dumps({"prod": {"x": "y"}}),
stderr="",
)
val = get_tofu_vm_field("obs", "staging", "x", cwd="/tmp")
assert val == ""
@patch("devx.opentofu.subprocess.run")
def test_non_dict_output_returns_empty(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "obs"],
returncode=0,
stdout='"a string"',
stderr="",
)
val = get_tofu_vm_field("obs", "staging", "x", cwd="/tmp")
assert val == ""
+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:
def test_platforms_not_empty(self) -> None:
assert len(PLATFORMS) >= 4
assert len(PLATFORMS) >= 1
def test_each_platform_has_required_keys(self) -> None:
for p in PLATFORMS:
@@ -17,9 +19,40 @@ class TestPlatforms:
names = [p["name"] for p in PLATFORMS]
assert len(names) == len(set(names))
def test_platforms_use_sleep_infinity(self) -> None:
"""All default platforms must use sleep infinity, not systemd."""
for p in PLATFORMS:
assert p["command"] == "sleep infinity", f"Platform {p['name']} uses {p['command']}"
def test_known_platforms_present(self) -> None:
names = {p["name"] for p in PLATFORMS}
assert "ubuntu-2204" in names
assert "ubuntu-2404" in names
assert "debian-12" in names
assert "archlinux" in names
assert "ubuntu-2604" in names
class TestLoadPlatforms:
def test_load_platforms_default(self, tmp_path) -> None: # type: ignore[no-untyped-def]
"""load_platforms with no file returns PLATFORMS."""
result = load_platforms(None)
assert result == PLATFORMS
def test_load_platforms_from_file(self, tmp_path) -> None: # type: ignore[no-untyped-def]
"""load_platforms reads custom platforms from JSON file."""
custom = [
{"name": "custom-os", "image": "custom:latest", "command": "sleep infinity"},
]
f = tmp_path / "platforms.json"
f.write_text(json.dumps(custom))
result = load_platforms(f)
assert result == custom
def test_load_platforms_missing_file_falls_back(self, tmp_path) -> None: # type: ignore[no-untyped-def]
"""load_platforms falls back to PLATFORMS when file doesn't exist."""
result = load_platforms(tmp_path / "nonexistent.json")
assert result == PLATFORMS
def test_load_platforms_empty_list_falls_back(self, tmp_path) -> None: # type: ignore[no-untyped-def]
"""load_platforms falls back to PLATFORMS when file has empty list."""
f = tmp_path / "platforms.json"
f.write_text("[]")
result = load_platforms(f)
assert result == PLATFORMS
+11 -14
View File
@@ -132,13 +132,12 @@ class TestMain:
assert "VIKUNJA_TOKEN" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_no_task_id_non_release_warns(self) -> None:
"""Non-release commits without DEVX-N prefix should warn, not fail."""
def test_no_task_id_non_release_fails(self) -> None:
"""Non-release commits without DEVX-N prefix should fail."""
runner = CliRunner()
result = runner.invoke(main, ["fix: resolve bug"])
assert result.exit_code == 0
assert result.exit_code != 0
assert "No task ID" in result.output
assert "Skipping" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_release_commit_without_task_id_skips(self) -> None:
@@ -179,8 +178,8 @@ class TestMain:
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("devx.ci.post_merge.VikunjaClient")
def test_post_comment_failure_warns(self, mock_client_cls: MagicMock) -> None:
"""Vikunja API errors should warn, not fail — the merge already succeeded."""
def test_post_comment_failure_fails(self, mock_client_cls: MagicMock) -> None:
"""Vikunja API errors should fail — the task was not updated."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "DEVX-20"},
@@ -189,14 +188,13 @@ class TestMain:
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["DEVX-20: fix: bug"])
assert result.exit_code == 0
assert "Warning" in result.output
assert "not updated" in result.output.lower()
assert result.exit_code != 0
assert "Vikunja API error" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("devx.ci.post_merge.VikunjaClient")
def test_mark_done_failure_warns(self, mock_client_cls: MagicMock) -> None:
"""Vikunja API errors should warn, not fail — the merge already succeeded."""
def test_mark_done_failure_fails(self, mock_client_cls: MagicMock) -> None:
"""Vikunja API errors should fail — the task was not updated."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "DEVX-20"},
@@ -206,9 +204,8 @@ class TestMain:
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["DEVX-20: fix: bug"])
assert result.exit_code == 0
assert "Warning" in result.output
assert "not updated" in result.output.lower()
assert result.exit_code != 0
assert "Vikunja API error" in result.output
class TestGetGitCommitMessage:
+17
View File
@@ -300,3 +300,20 @@ class TestMain:
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1
assert "Release creation failed" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.build_package")
def test_skip_build_skips_build_and_publish(
self, mock_build: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
"""--skip-build skips build_package and PyPI publish, only creates Gitea release."""
mock_tea = MagicMock()
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
assert result.exit_code == 0
assert "skip" in result.output.lower()
mock_build.assert_not_called()
mock_tea.create_release.assert_called_once()
+64
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
@@ -223,3 +224,66 @@ class TestMain:
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
assert result.exit_code == 0
mock_update.assert_not_called()
def test_retries_success_on_second_attempt(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""With --retries 3, first attempt fails but second succeeds."""
monkeypatch.chdir(tmp_path)
badges_dir = tmp_path / ".badges"
badges_dir.mkdir()
(badges_dir / "badge1.svg").touch()
import subprocess
call_count = [0]
def side_effect(*args: Any, **kwargs: Any) -> Any:
call_count[0] += 1
# First call (git fetch) fails, rest succeed
if call_count[0] == 1:
raise subprocess.CalledProcessError(1, "git fetch")
return MagicMock(returncode=0, stdout="", stderr="")
runner = CliRunner()
with (
patch("subprocess.run", side_effect=side_effect),
patch("devx.ci.push_badges.update_readme_with_badge_sha"),
patch("time.sleep"),
):
result = runner.invoke(
push_badges.main,
["--output-dir", str(badges_dir), "--no-readme-update", "--retries", "3"],
)
assert result.exit_code == 0
def test_retries_exhausted(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""With --retries 2, all attempts fail and exit code is non-zero."""
monkeypatch.chdir(tmp_path)
import subprocess
runner = CliRunner()
with (
patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")),
patch("time.sleep"),
):
result = runner.invoke(
push_badges.main,
["--no-readme-update", "--retries", "2"],
)
assert result.exit_code != 0
assert "failed after 2" in result.output
def test_default_retries_is_one(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Without --retries, only one attempt is made (no retry on failure)."""
monkeypatch.chdir(tmp_path)
import subprocess
runner = CliRunner()
with (
patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")),
patch("time.sleep") as mock_sleep,
):
result = runner.invoke(push_badges.main, ["--no-readme-update"])
assert result.exit_code != 0
mock_sleep.assert_not_called()
+739 -17
View File
@@ -9,9 +9,16 @@ from click.testing import CliRunner
from devx.ci.release import (
commit_release_changes,
create_and_push_tag,
fetch_tags,
get_all_tags,
get_bumped_version,
get_changelog,
get_changelog_versions,
get_commit_version,
get_head_commit,
get_init_version,
get_latest_tag,
get_tag_commit,
has_unreleased_changes,
main,
run_cmd,
@@ -19,6 +26,8 @@ from devx.ci.release import (
tag_exists,
update_changelog,
update_init_version,
verify_alignment,
verify_tag_consistency,
)
@@ -44,14 +53,14 @@ class TestRunCmd:
class TestGetLatestTag:
@patch("devx.ci.release.run_cmd")
def test_returns_tag(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.1.0\n")
@patch("devx.ci._shared.subprocess.run")
def test_returns_tag(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="v0.1.0\n")
assert get_latest_tag() == "v0.1.0"
@patch("devx.ci.release.run_cmd")
def test_no_tags_returns_empty(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="")
@patch("devx.ci._shared.subprocess.run")
def test_no_tags_returns_empty(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="")
assert get_latest_tag() == ""
@@ -84,6 +93,13 @@ class TestGetBumpedVersion:
with pytest.raises(click.ClickException):
get_bumped_version()
@patch("devx.ci.release.run_cmd")
def test_invalid_version_format_raises(self, mock_run_cmd: MagicMock) -> None:
"""Non-semver version from git-cliff should raise."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="not-a-version\n", stderr="")
with pytest.raises(click.ClickException, match="invalid version format"):
get_bumped_version()
class TestGetChangelog:
@patch("devx.ci.release.run_cmd")
@@ -149,6 +165,534 @@ class TestUpdateInitVersion:
update_init_version("0.2.0")
class TestGetTagCommit:
@patch("devx.ci.release.run_cmd")
def test_returns_commit(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="abc123\n", stderr="")
assert get_tag_commit("v0.1.0") == "abc123"
@patch("devx.ci.release.run_cmd")
def test_returns_empty_on_failure(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="err")
assert get_tag_commit("v0.1.0") == ""
class TestGetHeadCommit:
@patch("devx.ci.release.run_cmd")
def test_returns_head(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="def456\n", stderr="")
assert get_head_commit() == "def456"
class TestFetchTags:
@patch("devx.ci.release.run_cmd")
def test_success(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
fetch_tags()
@patch("devx.ci.release.run_cmd")
def test_failure_warns(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="err")
# Should not raise
fetch_tags()
class TestGetAllTags:
@patch("devx.ci.release.run_cmd")
def test_returns_tags(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.3.0\nv0.2.0\nv0.1.0\n", stderr="")
tags = get_all_tags()
assert tags == ["v0.3.0", "v0.2.0", "v0.1.0"]
@patch("devx.ci.release.run_cmd")
def test_empty(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="\n", stderr="")
assert get_all_tags() == []
@patch("devx.ci.release.run_cmd")
def test_failure_returns_empty(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="err")
assert get_all_tags() == []
class TestGetCommitVersion:
@patch("devx.ci.release.run_cmd")
def test_release_commit(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="release: v0.4.4 [skip ci]\n", stderr="")
assert get_commit_version("abc123") == "0.4.4"
@patch("devx.ci.release.run_cmd")
def test_non_release_commit(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="DEVX-9 feat: add thing\n", stderr="")
assert get_commit_version("abc123") is None
class TestVerifyTagConsistency:
@patch("devx.ci.release.get_commit_version")
@patch("devx.ci.release.get_all_tags")
def test_all_consistent(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None:
mock_tags.return_value = ["v0.2.0", "v0.1.0"]
mock_cv.side_effect = ["0.2.0", "0.1.0"]
errors = verify_tag_consistency()
assert errors == []
@patch("devx.ci.release.get_commit_version")
@patch("devx.ci.release.get_all_tags")
def test_tag_on_non_release_commit(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None:
# v0.1.0 is first (exempt), v0.2.0 is non-release (should error)
mock_tags.return_value = ["v0.2.0", "v0.1.0"]
mock_cv.side_effect = [None, "0.1.0"] # v0.2.0 non-release, v0.1.0 ok
errors = verify_tag_consistency()
assert len(errors) == 1
assert "non-release commit" in errors[0]
@patch("devx.ci.release.get_commit_version")
@patch("devx.ci.release.get_all_tags")
def test_first_tag_exempt_from_release_check(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None:
"""The first (oldest) tag is allowed to point to a non-release commit."""
mock_tags.return_value = ["v0.1.0"]
mock_cv.return_value = None # non-release commit
errors = verify_tag_consistency()
assert errors == [] # no error — first tag is exempt
@patch("devx.ci.release.get_commit_version")
@patch("devx.ci.release.get_all_tags")
def test_tag_version_mismatch(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None:
mock_tags.return_value = ["v0.2.0"]
mock_cv.return_value = "0.1.0"
errors = verify_tag_consistency()
assert len(errors) == 1
assert "0.1.0" in errors[0]
assert "0.2.0" in errors[0]
@patch("devx.ci.release.get_all_tags")
def test_no_tags(self, mock_tags: MagicMock) -> None:
mock_tags.return_value = []
assert verify_tag_consistency() == []
class TestGetInitVersion:
def test_returns_version(self, tmp_path, monkeypatch) -> None:
init_file = tmp_path / "__init__.py"
init_file.write_text('__version__ = "0.4.4"\n')
monkeypatch.setattr("devx.ci.release.INIT_FILE", str(init_file))
assert get_init_version() == "0.4.4"
def test_file_not_found(self, monkeypatch) -> None:
monkeypatch.setattr("devx.ci.release.INIT_FILE", "/nonexistent/path/__init__.py")
assert get_init_version() is None
def test_no_version_string(self, tmp_path, monkeypatch) -> None:
init_file = tmp_path / "__init__.py"
init_file.write_text('"""module"""\n')
monkeypatch.setattr("devx.ci.release.INIT_FILE", str(init_file))
assert get_init_version() is None
class TestGetChangelogVersions:
def test_returns_versions(self, tmp_path, monkeypatch) -> None:
changelog = tmp_path / "CHANGELOG.md"
changelog.write_text(
"# Changelog\n\n## [0.4.4] - 2026-06-21\n\n### Features\n- new\n\n"
"## [0.4.3] - 2026-06-20\n\n### Fixes\n- fix\n\n## [0.4.2] - 2026-06-19\n"
)
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", str(changelog))
versions = get_changelog_versions()
assert versions == ["0.4.4", "0.4.3", "0.4.2"]
def test_file_not_found(self, monkeypatch) -> None:
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", "/nonexistent/CHANGELOG.md")
assert get_changelog_versions() == []
class TestVerifyAlignment:
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_all_aligned(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify alignment passes when everything is consistent."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4", "v0.4.3"]
mock_vtc.return_value = [] # no tag errors
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.4", "0.4.3"]
# run_cmd is called for untagged release commits check
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 0
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_misaligned_tags(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify alignment fails when tags are misaligned."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = [" v0.1.0 → bad"]
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.4"]
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 1
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_version_mismatch(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify alignment fails when __version__ != latest tag."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = []
mock_iv.return_value = "0.4.3" # mismatch
mock_cv.return_value = ["0.4.4"]
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 1
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_changelog_duplicates(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify alignment fails when CHANGELOG has duplicate versions."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = []
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.4", "0.4.4"] # duplicate
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 1
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_changelog_out_of_order(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify alignment fails when CHANGELOG versions are not descending."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = []
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.3", "0.4.4"] # out of order
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 1
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_changelog_latest_mismatch(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify alignment fails when CHANGELOG latest != latest tag."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = []
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.3"] # doesn't match tag
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 1
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_changelog_unreleased_section(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify passes when CHANGELOG has one unreleased section ahead of tag."""
mock_lt.return_value = "v0.6.3"
mock_tags.return_value = ["v0.6.3", "v0.6.2"]
mock_vtc.return_value = []
mock_iv.return_value = "0.6.3"
mock_cv.return_value = ["0.6.4", "0.6.3"] # 0.6.4 is unreleased
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 0
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_changelog_tag_at_wrong_position(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify fails when latest tag is deep in CHANGELOG (not at position 0 or 1)."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = []
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.5.0", "0.4.5", "0.4.4"] # tag at position 2
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 1
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_duplicate_release_commits_info(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify reports duplicate release commits as info, not error."""
mock_lt.return_value = "v0.6.1"
mock_tags.return_value = ["v0.6.1"] # tag for 0.6.1 exists
mock_vtc.return_value = []
mock_iv.return_value = "0.6.1"
mock_cv.return_value = ["0.6.1"]
# git log finds 2 release commits for v0.6.1, neither has tag pointing at it
# (the tag points to a third commit)
commits = "abc123 release: v0.6.1 [skip ci]\ndef456 release: v0.6.1 [skip ci]\n"
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout=commits, stderr=""),
MagicMock(returncode=0, stdout="", stderr=""), # no tag at abc123
MagicMock(returncode=0, stdout="", stderr=""), # no tag at def456
]
# Should return 0 — duplicates are informational, not errors
assert verify_alignment() == 0
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_many_duplicate_release_commits(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify handles >5 duplicate release commits (truncation message)."""
mock_lt.return_value = "v0.6.1"
mock_tags.return_value = ["v0.6.1"]
mock_vtc.return_value = []
mock_iv.return_value = "0.6.1"
mock_cv.return_value = ["0.6.1"]
# Generate 7 duplicate release commits for v0.6.1
commits = "\n".join(f"abc{i:03d} release: v0.6.1 [skip ci]" for i in range(7))
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout=commits + "\n", stderr=""),
] + [MagicMock(returncode=0, stdout="", stderr="") for _ in range(7)]
assert verify_alignment() == 0
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_untagged_release_commits(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify alignment fails when there are untagged release commits."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = []
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.4"]
# git log finds release commits, then tag --points-at finds nothing
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="abc123 release: v0.3.0 [skip ci]\n", stderr=""),
MagicMock(returncode=0, stdout="", stderr=""), # no tags at abc123
]
assert verify_alignment() == 1
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_no_init_version(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify alignment fails when __version__ is not found."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = []
mock_iv.return_value = None # not found
mock_cv.return_value = ["0.4.4"]
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 1
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_all_release_commits_tagged(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify passes when all release commits have tags."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = []
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.4"]
# git log finds release commit, tag --points-at finds the tag
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="abc123 release: v0.4.4 [skip ci]\n", stderr=""),
MagicMock(returncode=0, stdout="v0.4.4\n", stderr=""), # tag found
]
assert verify_alignment() == 0
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_no_release_commits_found(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify handles case with no release commits at all."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = []
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.4"]
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="")
assert verify_alignment() == 0
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_many_untagged_release_commits(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""Verify handles >10 untagged release commits (truncation message)."""
mock_lt.return_value = "v0.4.4"
mock_tags.return_value = ["v0.4.4"]
mock_vtc.return_value = []
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.4"]
# Generate 15 untagged release commits
commits = "\n".join(f"abc{i:03d} release: v0.1.{i} [skip ci]" for i in range(15))
# First call returns all commits, subsequent calls return empty (no tags)
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout=commits + "\n", stderr=""),
] + [MagicMock(returncode=0, stdout="", stderr="") for _ in range(15)]
assert verify_alignment() == 1
class TestUpdateChangelog:
def test_creates_new_file(self, tmp_path, monkeypatch) -> None:
changelog_file = tmp_path / "CHANGELOG.md"
@@ -243,9 +787,17 @@ class TestCreateAndPushTag:
assert call.args[0][0:2] != ["git", "push"]
assert call.args[0][0:2] != ["git", "tag"]
@patch("devx.ci.release.get_head_commit", return_value="abc123")
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
@patch("devx.ci.release.tag_exists", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_tag_exists_skips_creation(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
def test_tag_exists_skips_creation(
self,
mock_run_cmd: MagicMock,
mock_tag_exists: MagicMock,
mock_tag_commit: MagicMock,
mock_head_commit: MagicMock,
) -> None:
result = create_and_push_tag("0.1.0", "changelog", dry_run=False)
assert result is False
# Should not create tag, but should ensure it's pushed
@@ -253,13 +805,38 @@ class TestCreateAndPushTag:
assert ["git", "tag", "-a"] not in [c[:3] for c in calls]
assert ["git", "push", "origin", "v0.1.0"] in calls
@patch("devx.ci.release.get_head_commit", return_value="def456")
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
@patch("devx.ci.release.tag_exists", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_tag_exists_dry_run_no_push(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
def test_tag_exists_mismatch_raises(
self,
mock_run_cmd: MagicMock,
mock_tag_exists: MagicMock,
mock_tag_commit: MagicMock,
mock_head_commit: MagicMock,
) -> None:
"""Tag exists but points to different commit than HEAD → error."""
with pytest.raises(click.ClickException, match="misalignment"):
create_and_push_tag("0.1.0", "changelog", dry_run=False)
@patch("devx.ci.release.get_head_commit", return_value="abc123")
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
@patch("devx.ci.release.tag_exists", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_tag_exists_dry_run_no_push(
self,
mock_run_cmd: MagicMock,
mock_tag_exists: MagicMock,
mock_tag_commit: MagicMock,
mock_head_commit: MagicMock,
) -> None:
result = create_and_push_tag("0.1.0", "changelog", dry_run=True)
assert result is False
# No git commands at all in dry-run when tag exists
mock_run_cmd.assert_not_called()
# No push in dry-run when tag exists, but alignment check still runs
for call in mock_run_cmd.call_args_list:
assert call.args[0][0:2] != ["git", "push"]
assert call.args[0][0:2] != ["git", "tag"]
class TestRunTests:
@@ -295,6 +872,13 @@ class TestRunTests:
class TestMain:
"""Tests for the main release command.
All tests mock fetch_tags and verify_tag_consistency since these
are pre-flight checks that call git commands. Tests that need to
verify specific git call sequences mock run_cmd with side_effect.
"""
@patch.dict("os.environ", {})
@patch("devx.ci.release.run_cmd")
def test_not_on_master_exits(self, mock_run_cmd: MagicMock) -> None:
@@ -305,9 +889,13 @@ class TestMain:
assert "master" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.get_latest_tag", return_value="v0.5.0")
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.has_user_facing_changes", return_value=False)
@patch("devx.ci.release.run_cmd")
def test_dry_run_on_non_master_warns(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None:
def test_dry_run_on_non_master_warns(
self, mock_run_cmd: MagicMock, mock_uf: MagicMock, mock_vtc: MagicMock, mock_glt: MagicMock
) -> None:
"""Dry-run mode should not fail on non-master branches."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="")
runner = CliRunner()
@@ -316,14 +904,26 @@ class TestMain:
assert "Dry-run mode" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.get_head_commit", return_value="abc123")
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_release_lock_skips_when_head_is_release_commit(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None:
"""If HEAD is already a release commit, should skip to prevent duplicate releases."""
# First call: git rev-parse (master), second: git log -1 (release commit)
def test_release_lock_skips_when_head_is_release_commit_and_tag_exists(
self,
mock_run_cmd: MagicMock,
mock_uf: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
mock_tc: MagicMock,
mock_hc: MagicMock,
) -> None:
"""If HEAD is a release commit and the tag exists, skip."""
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="master\n", stderr=""),
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
MagicMock(returncode=0, stdout="v0.5.0\n", stderr=""), # tag -l finds tag
]
runner = CliRunner()
result = runner.invoke(main, [])
@@ -332,6 +932,62 @@ class TestMain:
assert "Skipping" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.get_head_commit", return_value="def456")
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_release_lock_tag_points_elsewhere(
self,
mock_run_cmd: MagicMock,
mock_uf: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
mock_tc: MagicMock,
mock_hc: MagicMock,
) -> None:
"""If HEAD is a release commit but tag points elsewhere, error."""
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="master\n", stderr=""),
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
MagicMock(returncode=0, stdout="v0.5.0\n", stderr=""), # tag -l finds tag
]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code != 0
assert "misalignment" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.get_changelog", return_value="## changelog")
@patch("devx.ci.release.create_and_push_tag", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_release_lock_recovers_when_tag_missing(
self,
mock_run_cmd: MagicMock,
mock_create_tag: MagicMock,
mock_changelog: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""If HEAD is a release commit but the tag is missing, create the tag."""
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="master\n", stderr=""),
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
MagicMock(returncode=0, stdout="", stderr=""), # tag -l finds nothing
]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "tag v0.5.0 is missing" in result.output
assert "Recovering" in result.output
mock_create_tag.assert_called_once_with("0.5.0", "## changelog", False)
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.has_unreleased_changes", return_value=False)
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@@ -342,6 +998,8 @@ class TestMain:
mock_bumped: MagicMock,
mock_has: MagicMock,
mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
@@ -350,6 +1008,7 @@ class TestMain:
assert "No unreleased changes" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.create_and_push_tag")
@patch("devx.ci.release.commit_release_changes")
@@ -360,7 +1019,7 @@ class TestMain:
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_dry_run_empty_changelog(
def test_dry_run_empty_changelog_fails(
self,
mock_run_cmd: MagicMock,
mock_has: MagicMock,
@@ -372,14 +1031,17 @@ class TestMain:
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_user: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""Empty changelog should fail, not warn."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
result = runner.invoke(main, ["--dry-run"])
assert result.exit_code == 0
assert "empty changelog" in result.output
assert result.exit_code != 0
assert "empty changelog" in result.output.lower()
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.create_and_push_tag")
@patch("devx.ci.release.commit_release_changes")
@@ -402,6 +1064,7 @@ class TestMain:
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_user: MagicMock,
mock_vtc: MagicMock,
) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
@@ -414,6 +1077,8 @@ class TestMain:
mock_tag.assert_not_called()
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.get_latest_tag", return_value="v0.3.0")
@patch("devx.ci.release.has_user_facing_changes", return_value=False)
@patch("devx.ci.release.run_cmd")
@@ -422,6 +1087,8 @@ class TestMain:
mock_run_cmd: MagicMock,
mock_user_facing: MagicMock,
mock_latest: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""Release is skipped when only workflow/infra files changed."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
@@ -432,6 +1099,8 @@ class TestMain:
assert "Skipping release" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_tests")
@patch("devx.ci.release.create_and_push_tag", return_value=True)
@@ -456,6 +1125,8 @@ class TestMain:
mock_tag: MagicMock,
mock_run_tests: MagicMock,
mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
@@ -469,6 +1140,8 @@ class TestMain:
mock_tag.assert_called_once_with("0.2.0", "changelog", False)
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_tests")
@patch("devx.ci.release.create_and_push_tag", return_value=False)
@@ -493,6 +1166,8 @@ class TestMain:
mock_tag: MagicMock,
mock_run_tests: MagicMock,
mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""When tag already exists, still update files but report existing tag."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
@@ -503,6 +1178,8 @@ class TestMain:
mock_tag.assert_called_once_with("0.1.0", "changelog", False)
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.create_and_push_tag", return_value=True)
@patch("devx.ci.release.commit_release_changes", return_value=True)
@@ -525,6 +1202,8 @@ class TestMain:
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""--skip-tests bypasses test verification."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
@@ -537,6 +1216,8 @@ class TestMain:
assert make_calls == []
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.create_and_push_tag")
@patch("devx.ci.release.commit_release_changes")
@@ -559,6 +1240,8 @@ class TestMain:
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""If tests fail, release aborts — no commit, no tag."""
# Calls: git rev-parse (master), git log -1 (release lock check),
@@ -577,6 +1260,8 @@ class TestMain:
mock_tag.assert_not_called()
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.create_and_push_tag")
@patch("devx.ci.release.commit_release_changes")
@@ -599,6 +1284,8 @@ class TestMain:
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""If lint fails, release aborts — no commit, no tag."""
# Calls: git rev-parse (master), git log -1 (release lock check),
@@ -614,3 +1301,38 @@ class TestMain:
assert "Lint failed" in result.output
mock_commit.assert_not_called()
mock_tag.assert_not_called()
@patch.dict("os.environ", {})
@patch("devx.ci.release.get_changelog_versions", return_value=[])
@patch("devx.ci.release.get_init_version", return_value="0.1.0")
@patch("devx.ci.release.get_all_tags", return_value=[])
@patch("devx.ci.release.get_latest_tag", return_value="")
@patch("devx.ci.release.run_cmd")
def test_verify_mode_no_tags(
self,
mock_run_cmd: MagicMock,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
) -> None:
"""--verify checks alignment and exits without releasing."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
runner = CliRunner()
result = runner.invoke(main, ["--verify"])
assert result.exit_code == 0
assert "Release Alignment Verification" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[" v0.1.0 → bad"])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.run_cmd")
def test_preflight_tag_consistency_fails(
self, mock_run_cmd: MagicMock, mock_ft: MagicMock, mock_vtc: MagicMock
) -> None:
"""Pre-flight tag consistency check aborts if tags are misaligned."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code != 0
assert "Tag consistency check failed" in result.output
+65 -6
View File
@@ -1,5 +1,6 @@
"""Unit tests for devx.tools.setup."""
import os
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -9,6 +10,7 @@ from click.testing import CliRunner
from devx.tools.setup import (
_configure_tea_login,
_install_ansible_collections,
_install_pre_commit_hooks,
_install_python_deps,
_run,
@@ -46,6 +48,14 @@ class TestInstallPythonDeps:
_install_python_deps(".venv/bin", "ci,lint")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci,lint]"])
@patch("devx.tools.setup._run")
def test_install_with_break_system_packages(self, mock_run: MagicMock) -> None:
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"]
)
class TestInstallPreCommitHooks:
@patch("devx.tools.setup._run")
@@ -58,6 +68,24 @@ class TestInstallPreCommitHooks:
assert "pre-push" in hook_types
class TestInstallAnsibleCollections:
@patch("devx.tools.setup._run")
def test_installs_from_requirements(self, mock_run: MagicMock, tmp_path: Path) -> None:
req = tmp_path / "ansible" / "requirements.yml"
req.parent.mkdir(parents=True)
req.write_text("collections: []")
with patch("devx.tools.setup.Path") as mock_path:
mock_path.return_value.exists.return_value = True
mock_path.return_value.__str__ = lambda _: str(req)
_install_ansible_collections(".venv/bin")
mock_run.assert_called_once()
@patch("devx.tools.setup._run")
def test_skips_when_no_requirements(self, mock_run: MagicMock) -> None:
_install_ansible_collections(".venv/bin")
mock_run.assert_not_called()
class TestConfigureTeaLogin:
@patch("devx.tools.setup.shutil.which", return_value=None)
def test_tea_not_installed(self, mock_which: MagicMock) -> None:
@@ -142,10 +170,12 @@ class TestMain:
@patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps")
def test_main_success(
self,
mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock,
mock_verify: MagicMock,
mock_tea: MagicMock,
@@ -157,6 +187,7 @@ class TestMain:
result = runner.invoke(main, ["--bin", str(bin_dir)])
assert result.exit_code == 0
mock_install_deps.assert_called_once()
mock_install_ansible.assert_called_once()
mock_install_hooks.assert_called_once()
mock_verify.assert_called_once()
mock_tea.assert_called_once()
@@ -164,10 +195,12 @@ class TestMain:
@patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps")
def test_main_no_pre_commit(
self,
mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock,
mock_verify: MagicMock,
mock_tea: MagicMock,
@@ -184,10 +217,33 @@ class TestMain:
@patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps")
def test_main_no_ansible_collections(
self,
mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock,
mock_verify: MagicMock,
mock_tea: MagicMock,
tmp_path: Path,
) -> None:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
runner = CliRunner()
result = runner.invoke(main, ["--bin", str(bin_dir), "--no-ansible-collections"])
assert result.exit_code == 0
mock_install_ansible.assert_not_called()
@patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps")
def test_main_custom_extras(
self,
mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock,
mock_verify: MagicMock,
mock_tea: MagicMock,
@@ -203,10 +259,12 @@ class TestMain:
@patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps")
def test_main_no_tea_login(
self,
mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock,
mock_verify: MagicMock,
mock_tea: MagicMock,
@@ -233,9 +291,10 @@ def test_main_module_block(tmp_path: Path) -> None:
with patch.dict("os.environ", {}, clear=True):
with patch("devx.tools.setup._install_python_deps") as mock_deps:
with patch("devx.tools.setup._install_pre_commit_hooks"):
with patch("devx.tools.setup._configure_tea_login"):
with patch("devx.tools.setup._verify"):
runner = CliRunner()
result = runner.invoke(main, ["--bin", str(bin_dir)])
assert result.exit_code == 0
mock_deps.assert_called_once()
with patch("devx.tools.setup._install_ansible_collections"):
with patch("devx.tools.setup._configure_tea_login"):
with patch("devx.tools.setup._verify"):
runner = CliRunner()
result = runner.invoke(main, ["--bin", str(bin_dir)])
assert result.exit_code == 0
mock_deps.assert_called_once()
+23 -8
View File
@@ -5,6 +5,7 @@ import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
@@ -63,6 +64,22 @@ class TestLoadMapping:
with pytest.raises(FileNotFoundError):
load_mapping()
def test_non_dict_mapping_raises(self, tmp_path: Path) -> None:
"""Non-dict mapping.json should raise."""
mapping_file = tmp_path / "mapping.json"
mapping_file.write_text('["not", "a", "dict"]')
with patch("devx.ci.sync_wiki.MAPPING_FILE", mapping_file):
with pytest.raises(click.ClickException, match="must be a dict"):
load_mapping()
def test_non_string_values_raise(self, tmp_path: Path) -> None:
"""Non-string values in mapping.json should raise."""
mapping_file = tmp_path / "mapping.json"
mapping_file.write_text('{"file.md": 123}')
with patch("devx.ci.sync_wiki.MAPPING_FILE", mapping_file):
with pytest.raises(click.ClickException, match="must be strings"):
load_mapping()
class TestReadDocContent:
def test_reads_file(self, tmp_path: Path) -> None:
@@ -332,8 +349,8 @@ class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_file_not_found_warning(self, mock_client_cls: MagicMock) -> None:
"""Test that missing doc files are skipped with a warning."""
def test_file_not_found_fails(self, mock_client_cls: MagicMock) -> None:
"""Test that missing doc files cause an error, not a warning."""
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"missing.md": "Missing"}):
@@ -341,14 +358,13 @@ class TestMain:
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}):
runner = CliRunner()
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
assert result.exit_code == 0
assert result.exit_code != 0
assert "not found" in result.output
assert "Skipped: 1" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_empty_doc_file_skipped(self, mock_client_cls: MagicMock) -> None:
"""Test that empty doc files are skipped with a warning."""
def test_empty_doc_file_fails(self, mock_client_cls: MagicMock) -> None:
"""Test that empty doc files cause an error, not a warning."""
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"empty.md": "Empty-Page"}):
@@ -356,9 +372,8 @@ class TestMain:
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}):
runner = CliRunner()
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
assert result.exit_code == 0
assert result.exit_code != 0
assert "empty" in result.output.lower()
assert "Skipped: 1" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
+83
View File
@@ -152,6 +152,89 @@ class TestMain:
assert "task ID" in result.output
class TestCustomPrefix:
"""Tests for custom task ID prefix (e.g., GRM-N instead of DEVX-N).
The prefix is configured via the DEVX_TASK_PREFIX environment variable.
This is critical for consumer projects like GRM that use their own
Vikunja project with a different identifier prefix.
"""
def _write_msg(self, content: str) -> str:
fd, path = tempfile.mkstemp()
with os.fdopen(fd, "w") as f:
f.write(content)
return path
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "GRM"})
def test_master_accepts_grm_prefix(self) -> None:
"""Master branch accepts GRM-N: prefix when DEVX_TASK_PREFIX=GRM."""
import importlib
import devx.ci.validate_commit_msg as vcm
import devx.config
importlib.reload(devx.config)
importlib.reload(vcm)
try:
msg_path = self._write_msg("GRM-66: fix: add scripts/** to infrastructure")
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
runner = CliRunner()
result = runner.invoke(vcm.main, [msg_path])
assert result.exit_code == 0
os.unlink(msg_path)
finally:
os.environ.pop("DEVX_TASK_PREFIX", None)
importlib.reload(devx.config)
importlib.reload(vcm)
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "GRM"})
def test_master_rejects_devx_prefix_when_grm_configured(self) -> None:
"""Master branch rejects DEVX-N: prefix when DEVX_TASK_PREFIX=GRM."""
import importlib
import devx.ci.validate_commit_msg as vcm
import devx.config
importlib.reload(devx.config)
importlib.reload(vcm)
try:
msg_path = self._write_msg("DEVX-8: fix: wrong prefix")
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
runner = CliRunner()
result = runner.invoke(vcm.main, [msg_path])
assert result.exit_code == 1
assert "GRM-N" in result.output
os.unlink(msg_path)
finally:
os.environ.pop("DEVX_TASK_PREFIX", None)
importlib.reload(devx.config)
importlib.reload(vcm)
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "GRM"})
def test_feature_branch_rejects_grm_prefix(self) -> None:
"""Feature branch rejects GRM-N: prefix when DEVX_TASK_PREFIX=GRM."""
import importlib
import devx.ci.validate_commit_msg as vcm
import devx.config
importlib.reload(devx.config)
importlib.reload(vcm)
try:
msg_path = self._write_msg("GRM-66: fix: should not have prefix on branch")
with patch("devx.ci.validate_commit_msg.get_branch", return_value="GRM-66-fix"):
runner = CliRunner()
result = runner.invoke(vcm.main, [msg_path])
assert result.exit_code == 1
assert "task ID" in result.output
os.unlink(msg_path)
finally:
os.environ.pop("DEVX_TASK_PREFIX", None)
importlib.reload(devx.config)
importlib.reload(vcm)
def test_main_module_block() -> None:
import tempfile