Compare commits

..
17 Commits
Author SHA1 Message Date
devx-ci-bot c12d9abc6d release: v0.26.4 [skip ci] 2026-06-28 15:01:52 +00:00
emil 40a65cb0a6 DEVX-97: fix: wrap all user-facing strings with _() for i18n completeness
Post-merge / vikunja (push) Successful in 13s
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 21s
Post-merge / release (push) Successful in 29s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / badges (push) Successful in 34s
Post-merge / publish (push) Successful in 22s
Build Images / detect-type (push) Successful in 48s
Build Images / build-and-push (push) Successful in 4m3s
Build Images / cleanup (push) Successful in 2m6s
2026-06-28 15:01:07 +00:00
gitea-actions-bot 1a89738dd4 chore: update badge URLs to commit 36f474db [skip ci] 2026-06-28 14:56:08 +00:00
devx-ci-bot 9e604ea2c7 release: v0.26.3 [skip ci] 2026-06-28 14:55:48 +00:00
emil 4bb50bed58 DEVX-97: fix: pin all dependencies to exact versions for reproducibility
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 12s
Post-merge / sync-wiki (push) Successful in 24s
Post-merge / vikunja (push) Successful in 15s
Post-merge / release (push) Successful in 32s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / badges (push) Successful in 49s
Post-merge / publish (push) Successful in 24s
Build Images / detect-type (push) Waiting to run
Build Images / build-and-push (push) Blocked by required conditions
Build Images / cleanup (push) Blocked by required conditions
2026-06-28 14:55:03 +00:00
gitea-actions-bot 5bb9dce530 chore: update badge URLs to commit c04f826e [skip ci] 2026-06-28 14:42:36 +00:00
emil 73662a3bf0 DEVX-95: chore: pin all dependency versions to concrete releases
Post-merge / detect-type (push) Successful in 18s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / release (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 24s
Post-merge / vikunja (push) Successful in 14s
Build Images / detect-type (push) Successful in 45s
Post-merge / publish (push) Has been skipped
Post-merge / configure-repo (push) Successful in 12s
Post-merge / badges (push) Successful in 34s
Build Images / build-and-push (push) Successful in 3m47s
Build Images / cleanup (push) Successful in 2m11s
2026-06-28 14:41:27 +00:00
gitea-actions-bot a1e87b1905 chore: update badge URLs to commit 991d923a [skip ci] 2026-06-28 14:38:20 +00:00
devx-ci-bot d8d0ad04a2 release: v0.26.2 [skip ci] 2026-06-28 14:38:13 +00:00
emil e836c09088 DEVX-96: fix: block admin merge override and auto-approve with review token
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 18s
Post-merge / vikunja (push) Successful in 20s
Post-merge / release (push) Successful in 25s
Post-merge / badges (push) Successful in 29s
Post-merge / publish (push) Successful in 16s
Build Images / build-and-push (push) Waiting to run
Build Images / cleanup (push) Blocked by required conditions
Build Images / detect-type (push) Successful in 45s
2026-06-28 14:37:36 +00:00
gitea-actions-bot 507436b134 chore: update badge URLs to commit 8dbdd563 [skip ci] 2026-06-28 12:15:13 +00:00
devx-ci-bot 32cec2c5ad release: v0.26.1 [skip ci] 2026-06-28 12:15:06 +00:00
emil 55c530eb00 DEVX-93: fix: force pip upgrade in setup-image to install new dependencies
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 11s
Post-merge / release (push) Successful in 25s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / sync-wiki (push) Successful in 18s
Post-merge / badges (push) Successful in 28s
Build Images / detect-type (push) Successful in 41s
Post-merge / publish (push) Successful in 15s
Build Images / build-and-push (push) Successful in 3m1s
Build Images / cleanup (push) Successful in 2m25s
2026-06-28 12:14:31 +00:00
gitea-actions-bot 3928de4507 chore: update badge URLs to commit 7dc6d2ce [skip ci] 2026-06-28 01:59:24 +00:00
devx-ci-bot 8bb1813715 release: v0.26.0 [skip ci] 2026-06-28 01:59:17 +00:00
emil f7f53941a1 DEVX-92: feat: add distribute_items CI tool for parallel VM deployment
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 12s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / release (push) Successful in 25s
Build Images / detect-type (push) Successful in 41s
Post-merge / badges (push) Successful in 28s
Post-merge / publish (push) Successful in 15s
Build Images / build-and-push (push) Successful in 2m53s
Build Images / cleanup (push) Successful in 7m33s
2026-06-28 01:58:42 +00:00
gitea-actions-bot 5edfdaa7aa chore: update badge URLs to commit 3a6bff69 [skip ci] 2026-06-28 00:18:22 +00:00
45 changed files with 1790 additions and 718 deletions
+15
View File
@@ -160,6 +160,21 @@ jobs:
token: ${{ secrets.CI_GITEA_TOKEN }} token: ${{ secrets.CI_GITEA_TOKEN }}
- name: Set up environment - name: Set up environment
run: make setup-image run: make setup-image
- name: Post approval review
env:
CI_GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.number }}
REPOSITORY: ${{ github.repository }}
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.ci.pr_review \
"$PR_NUMBER" \
"$REPOSITORY" \
--event APPROVE \
--checklist-confirmed \
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
--body "Auto-approved: all CI checks passed (quality, pr-review, release-dry-run)."
- name: Squash merge with task ID - name: Squash merge with task ID
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
+1
View File
@@ -68,6 +68,7 @@ src/devx/
│ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push 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) │ ├── notify_failure.py # Create Gitea issues on CI failures (--auto-login)
│ ├── distribute_files.py # Distribute files across parallel runners (LPT scheduling) │ ├── distribute_files.py # Distribute files across parallel runners (LPT scheduling)
│ ├── distribute_items.py # Distribute generic items (VMs, hosts) across parallel runners (LPT)
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast │ ├── integration_guard.py # Run pytest with cross-runner fail-fast
│ ├── check_translations.py # Translation completeness check │ ├── check_translations.py # Translation completeness check
│ └── doc_coverage.py # Documentation coverage check │ └── doc_coverage.py # Documentation coverage check
+30
View File
@@ -2,6 +2,36 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.26.4] - 2026-06-28
### Bug Fixes
- Wrap all user-facing strings with _() for i18n completeness
## [0.26.3] - 2026-06-28
### Bug Fixes
- Pin all dependencies to exact versions for reproducibility
## [0.26.2] - 2026-06-28
### Bug Fixes
- Block admin merge override and auto-approve with review token
## [0.26.1] - 2026-06-28
### Bug Fixes
- Force pip upgrade in setup-image to install new dependencies
## [0.26.0] - 2026-06-28
### Features
- Add distribute_items CI tool for parallel VM deployment
## [0.25.0] - 2026-06-28 ## [0.25.0] - 2026-06-28
### Features ### Features
+1 -1
View File
@@ -35,7 +35,7 @@ setup-release: $(VENV)/bin/activate .env
# an older devx.mak that doesn't yet define devx-setup-image. Consumer repos # an older devx.mak that doesn't yet define devx-setup-image. Consumer repos
# (grm, infra) can safely alias to devx-setup-image since they install devx from PyPI. # (grm, infra) can safely alias to devx-setup-image since they install devx from PyPI.
setup-image: setup-image:
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate && pip install -e . --no-deps 2>/dev/null; \ @if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate && pip install --no-cache-dir -e . 2>/dev/null; \
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
.env: .env:
+6 -6
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0fa6360db6ec095e98e907fc33f66f9e995fa738/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0fa6360db6ec095e98e907fc33f66f9e995fa738/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0fa6360db6ec095e98e907fc33f66f9e995fa738/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0fa6360db6ec095e98e907fc33f66f9e995fa738/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0fa6360db6ec095e98e907fc33f66f9e995fa738/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0fa6360db6ec095e98e907fc33f66f9e995fa738/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/python.svg)](https://www.python.org/downloads/)
## Why devx? ## Why devx?
+6 -6
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0fa6360db6ec095e98e907fc33f66f9e995fa738/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0fa6360db6ec095e98e907fc33f66f9e995fa738/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0fa6360db6ec095e98e907fc33f66f9e995fa738/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0fa6360db6ec095e98e907fc33f66f9e995fa738/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0fa6360db6ec095e98e907fc33f66f9e995fa738/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0fa6360db6ec095e98e907fc33f66f9e995fa738/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/36f474dba41df607d1e6696ff3fe47a49b9297ef/python.svg)](https://www.python.org/downloads/)
## Overview ## Overview
+30 -25
View File
@@ -13,10 +13,13 @@ classifiers = [
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
] ]
# All dependencies are pinned to exact versions for full reproducibility.
# Update pinned versions in a dedicated PR with verification.
dependencies = [ dependencies = [
"requests>=2.34.2", "requests==2.34.2",
"python-dotenv>=1.2.2", "python-dotenv==1.2.2",
"click>=8.4.1", "click==8.4.2",
"tenacity==9.1.4", # retry logic for GiteaClient/VikunjaClient
] ]
[project.scripts] [project.scripts]
@@ -28,44 +31,44 @@ version = {attr = "devx.__version__"}
[project.optional-dependencies] [project.optional-dependencies]
# Test runners (pytest + coverage + parallel execution) # Test runners (pytest + coverage + parallel execution)
ci = [ ci = [
"pytest>=9.1.0", "pytest==9.1.1",
"pytest-cov>=7.1.0", "pytest-cov==7.1.0",
"pytest-xdist>=3.8", "pytest-xdist==3.8.0",
] ]
# Lint and type-checking tools (quality job, badge generation) # Lint and type-checking tools (quality job, badge generation)
lint = [ lint = [
"ruff>=0.15.17", "ruff==0.15.20",
"pyright>=1.1.410", "pyright==1.1.411",
"bandit>=1.8.2", "bandit==1.9.4",
"pip-audit>=2.10", "pip-audit==2.10.1",
"pre-commit>=4.6.0", "pre-commit==4.6.0",
] ]
# Release tools (build + publish to PyPI/Gitea registry) # Release tools (build + publish to PyPI/Gitea registry)
release = [ release = [
"build>=1.5.0", "build==1.5.0",
"twine>=6.2.0", "twine==6.2.0",
] ]
# Molecule testing (for projects with Ansible roles) # Molecule testing (for projects with Ansible roles)
molecule = [ molecule = [
"molecule>=26.4.0", "molecule==26.4.0",
"molecule-docker>=2.1.0", "molecule-docker==2.1.0",
"ansible-lint>=26.4.0", "ansible-lint==26.4.0",
"ansible-core>=2.15,<2.17", "ansible-core==2.21.1",
] ]
# Deploy tools (for infra staging/production deployments) # Deploy tools (for infra staging/production deployments)
deploy = [ deploy = [
"ansible-core>=2.15,<2.17", "ansible-core==2.21.1",
"boto3>=1.34", "boto3==1.43.36",
"docker>=7.0", "docker==7.1.0",
"jinja2>=3.1", "jinja2==3.1.6",
"pyyaml>=6.0", "pyyaml==6.0.3",
"cryptography>=41.0", "cryptography==49.0.0",
] ]
# Full dev environment (local development) # Full dev environment (local development)
dev = [ dev = [
"devx[ci,lint,release,molecule]", "devx[ci,lint,release,molecule]",
"build>=1.3.0", "build==1.5.0",
"twine>=6.2.0", "twine==6.2.0",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -97,6 +100,8 @@ indent-style = "space"
[tool.pyright] [tool.pyright]
include = ["src"] include = ["src"]
pythonVersion = "3.12" pythonVersion = "3.12"
venvPath = "."
venv = ".venv"
strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "src/devx/api_clients.py", "src/devx/gitea_cli.py"] strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "src/devx/api_clients.py", "src/devx/gitea_cli.py"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects.""" """devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.25.0" __version__ = "0.26.4"
+72 -93
View File
@@ -4,10 +4,16 @@ from __future__ import annotations
import json import json
import logging import logging
import time
from typing import Any from typing import Any
import requests import requests
from tenacity import (
before_sleep_log,
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from devx.config import DEFAULT_TIMEOUT, MAX_RETRIES, RETRY_BACKOFF_BASE, RETRY_STATUS_CODES from devx.config import DEFAULT_TIMEOUT, MAX_RETRIES, RETRY_BACKOFF_BASE, RETRY_STATUS_CODES
from devx.exceptions import APIError from devx.exceptions import APIError
@@ -27,14 +33,69 @@ def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
return status, message return status, message
def _is_retryable(e: Exception) -> bool: class _TransientHTTPError(requests.HTTPError):
"""Check if an exception is a transient error worth retrying.""" """HTTP error with a retryable status code (wrapped for tenacity)."""
if isinstance(e, requests.ConnectionError):
return True
if isinstance(e, requests.HTTPError): class _RetryableRequestError(Exception):
status, _ = _parse_error(e) """Connection/timeout error wrapped for tenacity retry."""
return status in RETRY_STATUS_CODES
return isinstance(e, requests.Timeout)
def _execute_request(
session: requests.Session,
method: str,
url: str,
**kwargs: Any,
) -> requests.Response:
"""Execute a single HTTP request, wrapping transient errors for tenacity.
Non-retryable HTTP errors (4xx except 429) raise :class:`APIError` directly.
Retryable errors (429, 5xx, connection, timeout) raise exceptions that
tenacity will retry.
"""
try:
response = session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
response.raise_for_status()
return response
except requests.HTTPError as e:
status, message = _parse_error(e)
if status in RETRY_STATUS_CODES:
# Wrap in _TransientHTTPError so tenacity retries it
raise _TransientHTTPError(message, response=e.response) from e
raise APIError(status, message) from e
except (requests.ConnectionError, requests.Timeout) as e:
raise _RetryableRequestError(str(e)) from e
# Tenacity retry decorator shared by both clients.
# Retries on transient HTTP errors (429, 5xx) and connection/timeout errors.
_retry_decorator = retry(
stop=stop_after_attempt(MAX_RETRIES),
wait=wait_exponential(multiplier=RETRY_BACKOFF_BASE, min=RETRY_BACKOFF_BASE, max=RETRY_BACKOFF_BASE**MAX_RETRIES),
retry=retry_if_exception_type((_TransientHTTPError, _RetryableRequestError)),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
def _request_with_retry(
session: requests.Session,
url: str,
method: str,
**kwargs: Any,
) -> requests.Response:
"""Execute an HTTP request with tenacity-managed retry logic.
On exhaustion, the last exception is translated to :class:`APIError`.
"""
try:
return _retry_decorator(_execute_request)(session, method, url, **kwargs)
except _TransientHTTPError as e:
response = getattr(e, "response", None)
status = response.status_code if response is not None else 0
raise APIError(status, str(e)) from e
except _RetryableRequestError as e:
raise APIError(0, str(e)) from e
class GiteaClient: class GiteaClient:
@@ -56,49 +117,7 @@ class GiteaClient:
return f"{self._base_url}/repos/{self._owner}/{self._repo}{path}" return f"{self._base_url}/repos/{self._owner}/{self._repo}{path}"
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response: def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
url = self._url(path) return _request_with_retry(self._session, self._url(path), method, **kwargs)
last_exc: Exception | None = None
for attempt in range(MAX_RETRIES):
try:
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
response.raise_for_status()
return response
except requests.HTTPError as e:
status, message = _parse_error(e)
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
status,
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(status, message) from e
except (requests.ConnectionError, requests.Timeout) as e:
if attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(0, str(e)) from e
# Should not reach here, but just in case
if last_exc: # pragma: no cover
raise APIError(0, str(last_exc)) from last_exc
raise APIError(0, "Max retries exceeded") # pragma: no cover
# -- repo settings -- # -- repo settings --
@@ -351,47 +370,7 @@ class VikunjaClient:
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response: def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
url = f"{self._base_url}{path}" url = f"{self._base_url}{path}"
last_exc: Exception | None = None return _request_with_retry(self._session, url, method, **kwargs)
for attempt in range(MAX_RETRIES):
try:
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
response.raise_for_status()
return response
except requests.HTTPError as e:
status, message = _parse_error(e)
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
status,
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(status, message) from e
except (requests.ConnectionError, requests.Timeout) as e:
if attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(0, str(e)) from e
if last_exc: # pragma: no cover
raise APIError(0, str(last_exc)) from last_exc
raise APIError(0, "Max retries exceeded") # pragma: no cover
def list_tasks(self, **params: Any) -> list[dict[str, Any]]: def list_tasks(self, **params: Any) -> list[dict[str, Any]]:
r = self._request("GET", "/tasks", params=params) r = self._request("GET", "/tasks", params=params)
+100
View File
@@ -2,8 +2,14 @@
from __future__ import annotations from __future__ import annotations
import os
import subprocess # nosec B404 import subprocess # nosec B404
import click
from devx.config import TASK_ID_RE
from devx.i18n import _
def get_latest_tag() -> str: def get_latest_tag() -> str:
"""Get the latest git tag, or empty string if none exists.""" """Get the latest git tag, or empty string if none exists."""
@@ -16,3 +22,97 @@ def get_latest_tag() -> str:
if result.returncode != 0: if result.returncode != 0:
return "" return ""
return result.stdout.strip() return result.stdout.strip()
def run_cmd(
args: list[str],
check: bool = True,
capture: bool = True,
) -> subprocess.CompletedProcess[str]:
"""Run a command and return the completed process.
Args:
args: Command and arguments as a list.
check: If True, raise :class:`click.ClickException` on non-zero exit.
capture: If True, capture stdout/stderr. If False, inherit parent's.
"""
result = subprocess.run( # nosec B603
args,
capture_output=capture,
text=True,
check=False,
)
if check and result.returncode != 0:
raise click.ClickException(
_(
"Command failed ({cmd}): {stderr}",
cmd=" ".join(args),
stderr=result.stderr.strip() if result.stderr else result.stdout.strip(),
)
)
return result
def extract_task_id(text: str) -> str:
"""Extract the ``{PREFIX}-N`` task identifier from *text*.
Returns the matched string (e.g. ``DEVX-42``) or an empty string if
no task ID is found.
"""
match = TASK_ID_RE.search(text)
return match.group(0) if match else ""
def write_github_env(key: str, value: str) -> None:
"""Append a key=value line to the ``$GITHUB_ENV`` file.
Multi-line values use the heredoc syntax required by Gitea Actions.
Raises :class:`click.ClickException` if ``GITHUB_ENV`` is not set.
"""
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", encoding="utf-8") as f: # noqa: PTH123
if "\n" in value:
delimiter = "EOF"
f.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
else:
f.write(f"{key}={value}\n")
def write_github_output(key: str, value: str) -> None:
"""Append a key=value line to the ``$GITHUB_OUTPUT`` file.
Raises :class:`click.ClickException` if ``GITHUB_OUTPUT`` is not set.
"""
gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
def lpt_distribute[T](items: list[T], weights: list[int], max_runners: int) -> list[list[T]]:
"""Distribute *items* across *max_runners* using LPT scheduling.
Sorts items by weight (descending), then assigns each to the runner
with the least total weight. This produces a more balanced distribution
than naive round-robin when items have varying costs.
Args:
items: Items to distribute.
weights: Parallel list of integer weights (higher = heavier).
max_runners: Number of runner groups to create.
Returns:
A list of ``max_runners`` lists, each containing the items assigned
to that runner.
"""
groups: list[list[T]] = [[] for _ in range(max_runners)]
loads = [0] * max_runners
indexed = sorted(enumerate(items), key=lambda x: (-weights[x[0]], x[0]))
for orig_idx, item in indexed:
min_runner = min(range(max_runners), key=lambda r: loads[r])
groups[min_runner].append(item)
loads[min_runner] += weights[orig_idx]
return groups
+3 -19
View File
@@ -22,7 +22,6 @@ Usage:
import os import os
import re import re
import subprocess # nosec B404
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -30,11 +29,11 @@ import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.api_clients import GiteaClient, VikunjaClient from devx.api_clients import GiteaClient, VikunjaClient
from devx.ci._shared import extract_task_id as _extract_task_id
from devx.config import ( from devx.config import (
CONVENTIONAL_RE, CONVENTIONAL_RE,
DEFAULT_PER_PAGE, DEFAULT_PER_PAGE,
GITEA_API_URL, GITEA_API_URL,
TASK_ID_RE,
TASK_PREFIX, TASK_PREFIX,
VIKUNJA_API_URL, VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID, VIKUNJA_PROJECT_ID,
@@ -48,20 +47,6 @@ PR_TITLE_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+")
load_dotenv() load_dotenv()
def run_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
"""Run a command and return the completed process."""
result = subprocess.run(args, capture_output=True, text=True, check=False) # nosec B603
if check and result.returncode != 0:
raise click.ClickException(
_(
"Command failed ({cmd}): {stderr}",
cmd=" ".join(args),
stderr=result.stderr.strip() or result.stdout.strip(),
)
)
return result
def read_taskid(branch: str) -> str: def read_taskid(branch: str) -> str:
"""Read task ID from branch name. """Read task ID from branch name.
@@ -92,9 +77,8 @@ def read_taskid(branch: str) -> str:
def extract_task_id(branch: str) -> str: def extract_task_id(branch: str) -> str:
"""Extract DEVX-N task identifier from branch name (legacy fallback).""" """Extract task identifier from branch name (delegates to shared utility)."""
match = TASK_ID_RE.search(branch) return _extract_task_id(branch)
return match.group(0) if match else ""
def validate_pr_title(pr_title: str, task_id: str) -> None: def validate_pr_title(pr_title: str, task_id: str) -> None:
+2 -1
View File
@@ -53,6 +53,7 @@ from devx.config import (
VIKUNJA_API_URL, VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID, VIKUNJA_PROJECT_ID,
) )
from devx.exceptions import APIError
from devx.i18n import _ from devx.i18n import _
load_dotenv() load_dotenv()
@@ -108,7 +109,7 @@ def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None:
try: try:
pr = client.get_pr(pr_number) pr = client.get_pr(pr_number)
return str(pr.get("title", "")) return str(pr.get("title", ""))
except Exception: except APIError:
return None return None
+11 -18
View File
@@ -138,7 +138,7 @@ from typing import Any
import click import click
from devx.ci._shared import get_latest_tag from devx.ci._shared import get_latest_tag, write_github_output
from devx.i18n import _ from devx.i18n import _
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -584,15 +584,8 @@ def has_user_facing_changes(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _write_github_output(key: str, value: str) -> None: # ---------------------------------------------------------------------------
"""Append a key=value line to the $GITHUB_OUTPUT file.""" # Classification logic
gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CLI # CLI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -633,9 +626,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
force = True force = True
if force and github_output: if force and github_output:
_write_github_output("user-facing-changed", "true") write_github_output("user-facing-changed", "true")
for tag in available_tags: for tag in available_tags:
_write_github_output(f"{tag}-changed", "true") write_github_output(f"{tag}-changed", "true")
click.echo("Forced user-facing-changed=true via --force flag.") click.echo("Forced user-facing-changed=true via --force flag.")
return return
@@ -643,9 +636,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
base = get_latest_tag() base = get_latest_tag()
if not base: if not base:
if github_output: if github_output:
_write_github_output("user-facing-changed", "true") write_github_output("user-facing-changed", "true")
for tag in available_tags: for tag in available_tags:
_write_github_output(f"{tag}-changed", "true") write_github_output(f"{tag}-changed", "true")
click.echo("No tags found — treating all changes as user-facing.") click.echo("No tags found — treating all changes as user-facing.")
return return
if quiet: if quiet:
@@ -657,9 +650,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
files = get_changed_files(base, head) files = get_changed_files(base, head)
if not files: if not files:
if github_output: if github_output:
_write_github_output("user-facing-changed", "false") write_github_output("user-facing-changed", "false")
for tag in available_tags: for tag in available_tags:
_write_github_output(f"{tag}-changed", "false") write_github_output(f"{tag}-changed", "false")
click.echo(f"No changes between {base} and {head}.") click.echo(f"No changes between {base} and {head}.")
return return
if quiet: if quiet:
@@ -671,9 +664,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
result = classifier.classify(files) result = classifier.classify(files)
if github_output: if github_output:
_write_github_output("user-facing-changed", "true" if result.has_user_facing else "false") write_github_output("user-facing-changed", "true" if result.has_user_facing else "false")
for tag in available_tags: for tag in available_tags:
_write_github_output(f"{tag}-changed", "true" if result.has_tag(tag) else "false") write_github_output(f"{tag}-changed", "true" if result.has_tag(tag) else "false")
click.echo(f"User-facing files changed: {result.has_user_facing}") click.echo(f"User-facing files changed: {result.has_user_facing}")
for tag in available_tags: for tag in available_tags:
click.echo(f"{tag.capitalize()} files changed: {result.has_tag(tag)}") click.echo(f"{tag.capitalize()} files changed: {result.has_tag(tag)}")
+6 -13
View File
@@ -12,12 +12,14 @@ Usage::
from __future__ import annotations from __future__ import annotations
import os
import re import re
import subprocess # nosec B404 import subprocess # nosec B404
import click import click
from devx.ci._shared import write_github_output
from devx.i18n import _
RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+") RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+")
@@ -39,26 +41,17 @@ def is_release_commit(message: str) -> bool:
return bool(RELEASE_RE.match(message)) return bool(RELEASE_RE.match(message))
def write_github_output(key: str, value: str) -> None:
"""Append a key=value line to the $GITHUB_OUTPUT file."""
gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
@click.command() @click.command()
def main() -> None: def main() -> None:
"""Detect if the latest commit is a release commit and set GITHUB_OUTPUT.""" """Detect if the latest commit is a release commit and set GITHUB_OUTPUT."""
msg = get_commit_message() msg = get_commit_message()
click.echo(f"Commit message: {msg}") click.echo(_("Commit message: {msg}", msg=msg))
is_release = is_release_commit(msg) is_release = is_release_commit(msg)
write_github_output("is-release", "true" if is_release else "false") write_github_output("is-release", "true" if is_release else "false")
if is_release: if is_release:
click.echo("Release commit — skipping all post-merge jobs.") click.echo(_("Release commit — skipping all post-merge jobs."))
else: else:
click.echo("Regular merge commit — running all post-merge jobs.") click.echo(_("Regular merge commit — running all post-merge jobs."))
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
+15 -11
View File
@@ -30,6 +30,7 @@ import click
import requests import requests
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.i18n import _
DEFAULT_MAX_RUNNERS = 3 DEFAULT_MAX_RUNNERS = 3
@@ -55,9 +56,9 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
data = r.json() data = r.json()
total += data.get("total_count", 0) total += data.get("total_count", 0)
else: else:
click.echo(f"Warning: repo-level runners query returned HTTP {r.status_code}", err=True) click.echo(_("Warning: repo-level runners query returned HTTP {status}", status=r.status_code), err=True)
except (requests.RequestException, ValueError) as e: except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: repo-level runners query failed: {e}", err=True) click.echo(_("Warning: repo-level runners query failed: {error}", error=e), err=True)
# 2. Organization-level runners # 2. Organization-level runners
try: try:
@@ -70,9 +71,9 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
data = r.json() data = r.json()
total += data.get("total_count", 0) total += data.get("total_count", 0)
else: else:
click.echo(f"Warning: org-level runners query returned HTTP {r.status_code}", err=True) click.echo(_("Warning: org-level runners query returned HTTP {status}", status=r.status_code), err=True)
except (requests.RequestException, ValueError) as e: except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: org-level runners query failed: {e}", err=True) click.echo(_("Warning: org-level runners query failed: {error}", error=e), err=True)
# 3. Instance-level runners (requires admin scope) # 3. Instance-level runners (requires admin scope)
try: try:
@@ -85,9 +86,12 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
data = r.json() data = r.json()
total += data.get("total_count", 0) total += data.get("total_count", 0)
elif r.status_code != 403: # 403 is expected without admin scope 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) click.echo(
_("Warning: instance-level runners query returned HTTP {status}", status=r.status_code),
err=True,
)
except (requests.RequestException, ValueError) as e: except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: instance-level runners query failed: {e}", err=True) click.echo(_("Warning: instance-level runners query failed: {error}", error=e), err=True)
return total return total
@@ -162,11 +166,11 @@ def main(
gh_output = os.environ.get("GITHUB_OUTPUT") gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output: if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set") raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a") as f: # noqa: PTH123 with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
f.write(f"runner-count={count}\n") f.write(f"runner-count={count}\n")
f.write(f"runner-indices={json.dumps(indices)}\n") f.write(f"runner-indices={json.dumps(indices)}\n")
click.echo(f"Runner count: {count}") click.echo(_("Runner count: {count}", count=count))
click.echo(f"Runner indices: {indices}") click.echo(_("Runner indices: {indices}", indices=indices))
return return
if output_count: if output_count:
@@ -178,8 +182,8 @@ def main(
return return
# Default: output both as key=value pairs for CI consumption # Default: output both as key=value pairs for CI consumption
click.echo(f"count={count}") click.echo(_("count={count}", count=count))
click.echo(f"indices={json.dumps(indices)}") click.echo(_("indices={indices}", indices=json.dumps(indices)))
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
+18 -30
View File
@@ -26,6 +26,7 @@ import os
import click import click
from devx.ci._shared import lpt_distribute, write_github_env
from devx.i18n import _ from devx.i18n import _
DEFAULT_MAX_RUNNERS = 3 DEFAULT_MAX_RUNNERS = 3
@@ -54,15 +55,7 @@ def distribute(files: list[str], max_runners: int) -> list[list[str]]:
the runner with the least total weight. the runner with the least total weight.
""" """
weights = [_file_weight(f) for f in files] weights = [_file_weight(f) for f in files]
groups: list[list[str]] = [[] for _ in range(max_runners)] return lpt_distribute(files, weights, max_runners)
loads = [0] * max_runners
# Sort by weight descending, preserving original order for ties
indexed = sorted(enumerate(files), key=lambda x: (-weights[x[0]], x[0]))
for orig_idx, f in indexed:
min_runner = min(range(max_runners), key=lambda r: loads[r])
groups[min_runner].append(f)
loads[min_runner] += weights[orig_idx]
return groups
def files_for_runner(files: list[str], runner_index: int, max_runners: int) -> list[str]: def files_for_runner(files: list[str], runner_index: int, max_runners: int) -> list[str]:
@@ -75,19 +68,6 @@ def files_for_runner(files: list[str], runner_index: int, max_runners: int) -> l
return groups[runner_index] 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
if "\n" in value:
# Multi-line values require the heredoc syntax in $GITHUB_ENV.
delimiter = "EOF"
f.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
else:
f.write(f"{key}={value}\n")
@click.command() @click.command()
@click.option("--pattern", required=True, help="Glob pattern for files to distribute.") @click.option("--pattern", required=True, help="Glob pattern for files to distribute.")
@click.option( @click.option(
@@ -122,26 +102,34 @@ def main(pattern: str, runner_index: int | None, max_runners: int, github_env: b
groups = distribute(files, max_runners) groups = distribute(files, max_runners)
for i, group in enumerate(groups): for i, group in enumerate(groups):
labels = " ".join(group) if group else "(none)" labels = " ".join(group) if group else "(none)"
click.echo(f"Runner {i}: {labels}") click.echo(_("Runner {i}: {labels}", i=i, labels=labels))
return return
if skip_if_excess and github_env and runner_index > max_runners: if skip_if_excess and github_env and runner_index > max_runners:
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}") click.echo(
_write_github_env("ASSIGNED_FILES", "") _(
_write_github_env("SKIP", "true") "Skipping — runner index {runner_index} > max runners {max_runners}",
runner_index=runner_index,
max_runners=max_runners,
)
)
write_github_env("ASSIGNED_FILES", "")
write_github_env("SKIP", "true")
return return
if runner_index < 1: if runner_index < 1:
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)") raise click.ClickException(
_("Runner index {runner_index} is out of range (must be >= 1)", runner_index=runner_index)
)
zero_based = runner_index - 1 zero_based = runner_index - 1
assigned = files_for_runner(files, zero_based, max_runners) assigned = files_for_runner(files, zero_based, max_runners)
encoded = "\n".join(assigned) encoded = "\n".join(assigned)
if github_env: if github_env:
_write_github_env("ASSIGNED_FILES", encoded) write_github_env("ASSIGNED_FILES", encoded)
_write_github_env("SKIP", "false") write_github_env("SKIP", "false")
click.echo(f"Assigned {len(assigned)} files to runner {runner_index}") click.echo(_("Assigned {count} files to runner {runner_index}", count=len(assigned), runner_index=runner_index))
return return
click.echo(encoded) click.echo(encoded)
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Distribute a list of items across N parallel runners using LPT scheduling.
Generic item distribution for CI matrix jobs. Items are read from a JSON
array on stdin (or from a file via --items-file), sorted for deterministic
ordering, then assigned to *max_runners* groups using LPT (Longest
Processing Time first) scheduling.
Each item is a string (e.g. an Ansible ``--limit`` pattern like
``observability`` or ``infra-314-vm``). Optionally, items can be objects
with ``{"id": "...", "weight": N}`` to provide explicit weights.
The assigned group for *runner_index* is written to ``$GITHUB_ENV`` as
``ASSIGNED_ITEMS`` (space-delimited) for use by subsequent steps.
Usage::
echo '["observability", "infra-314-vm"]' | \\
python3 -m devx.ci.distribute_items \\
--runner-index 1 --max-runners 3 \\
--github-env --skip-if-excess
# With weights:
echo '[{"id": "observability", "weight": 5}, {"id": "customer-1", "weight": 3}]' | \\
python3 -m devx.ci.distribute_items \\
--runner-index 1 --max-runners 3 --github-env
"""
from __future__ import annotations
import json
import sys
import click
from devx.ci._shared import lpt_distribute, write_github_env
from devx.i18n import _
DEFAULT_MAX_RUNNERS = 3
DEFAULT_WEIGHT = 1
def parse_items(raw: str) -> list[str]:
"""Parse a JSON array into a list of item identifier strings.
Accepts both plain string arrays (``["a", "b"]``) and object arrays
(``[{"id": "a", "weight": 2}]``). Returns just the identifier strings.
"""
data = json.loads(raw)
if not isinstance(data, list):
raise click.ClickException(_("Items input must be a JSON array, got {type}", type=type(data).__name__))
items: list[str] = []
for entry in data:
if isinstance(entry, str):
items.append(entry)
elif isinstance(entry, dict) and "id" in entry:
items.append(str(entry["id"]))
else:
raise click.ClickException(
_("Each item must be a string or an object with 'id', got {type}", type=type(entry).__name__)
)
return items
def parse_weighted_items(raw: str) -> tuple[list[str], list[int]]:
"""Parse a JSON array into (items, weights) lists.
For plain string arrays, all items get ``DEFAULT_WEIGHT``.
For object arrays, the ``weight`` field is used (default: ``DEFAULT_WEIGHT``).
"""
data = json.loads(raw)
if not isinstance(data, list):
raise click.ClickException(_("Items input must be a JSON array, got {type}", type=type(data).__name__))
items: list[str] = []
weights: list[int] = []
for entry in data:
if isinstance(entry, str):
items.append(entry)
weights.append(DEFAULT_WEIGHT)
elif isinstance(entry, dict) and "id" in entry:
items.append(str(entry["id"]))
weights.append(int(entry.get("weight", DEFAULT_WEIGHT)))
else:
raise click.ClickException(
_("Each item must be a string or an object with 'id', got {type}", type=type(entry).__name__)
)
return items, weights
def distribute(items: list[str], weights: list[int], max_runners: int) -> list[list[str]]:
"""Split *items* into *max_runners* balanced groups using LPT scheduling.
Items are sorted by weight (descending), then assigned to the runner
with the least total weight.
"""
return lpt_distribute(items, weights, max_runners)
def items_for_runner(items: list[str], weights: list[int], runner_index: int, max_runners: int) -> list[str]:
"""Return the subset of items assigned to *runner_index* (0-based)."""
groups = distribute(items, weights, 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]
@click.command()
@click.option(
"--items-file",
type=click.Path(exists=True, file_okay=True, path_type=None),
default=None,
help="Read items from a JSON file instead of stdin.",
)
@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_ITEMS 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(
items_file: str | None,
runner_index: int | None,
max_runners: int,
github_env: bool,
skip_if_excess: bool,
) -> None:
# Read items from file or stdin
if items_file is not None:
with open(items_file, encoding="utf-8") as f: # noqa: PTH123
raw = f.read()
else:
raw = sys.stdin.read()
raw = raw.strip()
if not raw:
raw = "[]"
items, weights = parse_weighted_items(raw)
if runner_index is None:
groups = distribute(items, weights, max_runners)
for i, group in enumerate(groups):
labels = " ".join(group) if group else "(none)"
click.echo(_("Runner {i}: {labels}", i=i, labels=labels))
return
if skip_if_excess and github_env and runner_index > max_runners:
click.echo(
_(
"Skipping — runner index {runner_index} > max runners {max_runners}",
runner_index=runner_index,
max_runners=max_runners,
)
)
write_github_env("ASSIGNED_ITEMS", "")
write_github_env("SKIP", "true")
return
if runner_index < 1:
raise click.ClickException(
_("Runner index {runner_index} is out of range (must be >= 1)", runner_index=runner_index)
)
zero_based = runner_index - 1
assigned = items_for_runner(items, weights, zero_based, max_runners)
encoded = " ".join(assigned)
if github_env:
write_github_env("ASSIGNED_ITEMS", encoded)
write_github_env("SKIP", "false")
click.echo(
_(
"Assigned {count} items to runner {runner_index}: {encoded}",
count=len(assigned),
runner_index=runner_index,
encoded=encoded,
)
)
return
click.echo(encoded)
if __name__ == "__main__": # pragma: no cover
main()
+1 -1
View File
@@ -86,7 +86,7 @@ def cli(pytest_args: tuple[str, ...]) -> None:
cmd = [sys.executable, "-m", "pytest"] cmd = [sys.executable, "-m", "pytest"]
cmd.extend(pytest_args) cmd.extend(pytest_args)
click.echo(f"Running: {' '.join(cmd)}") click.echo(_("Running: {cmd}", cmd=" ".join(cmd)))
process = subprocess.Popen( # nosec B603 process = subprocess.Popen( # nosec B603
cmd, cmd,
+5 -5
View File
@@ -13,7 +13,8 @@ import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.api_clients import VikunjaClient from devx.api_clients import VikunjaClient
from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID from devx.ci._shared import extract_task_id as _extract_task_id
from devx.config import DEFAULT_PER_PAGE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.exceptions import APIError from devx.exceptions import APIError
from devx.i18n import _ from devx.i18n import _
@@ -47,10 +48,9 @@ def _get_git_commit_sha() -> str:
def extract_task_id(commit_msg: str) -> str: def extract_task_id(commit_msg: str) -> str:
"""Extract DEVX-N task identifier from the first line of commit message.""" """Extract task identifier from the first line of commit message (delegates to shared utility)."""
first_line = commit_msg.split("\n")[0] first_line = commit_msg.split("\n")[0]
match = TASK_ID_RE.search(first_line) return _extract_task_id(first_line)
return match.group(0) if match else ""
def extract_conventional_msg(commit_msg: str) -> str: def extract_conventional_msg(commit_msg: str) -> str:
@@ -61,7 +61,7 @@ def extract_conventional_msg(commit_msg: str) -> str:
- ``DEVX-N <message>`` (current, space-separated) - ``DEVX-N <message>`` (current, space-separated)
""" """
first_line = commit_msg.split("\n")[0] first_line = commit_msg.split("\n")[0]
return re.sub(r"^DEVX-\d+[:\s]\s*", "", first_line) return re.sub(rf"^{TASK_PREFIX}-\d+[:\s]\s*", "", first_line)
def resolve_task_id(client: VikunjaClient, task_id: str) -> int: def resolve_task_id(client: VikunjaClient, task_id: str) -> int:
+22 -11
View File
@@ -28,6 +28,8 @@ from typing import Any
import click import click
from devx.i18n import _
def _repo_root() -> Path: def _repo_root() -> Path:
"""Resolve repo root from GITHUB_WORKSPACE or cwd.""" """Resolve repo root from GITHUB_WORKSPACE or cwd."""
@@ -68,7 +70,7 @@ def fetch_latest_master(branch: str = "master") -> None:
""" """
_run(["git", "fetch", "origin", branch]) # nosec B607 _run(["git", "fetch", "origin", branch]) # nosec B607
_run(["git", "reset", "--hard", f"origin/{branch}"]) # nosec B607 _run(["git", "reset", "--hard", f"origin/{branch}"]) # nosec B607
click.echo(f"Synced to latest origin/{branch}") click.echo(_("Synced to latest origin/{branch}", branch=branch))
def generate_badges(output_dir: str) -> None: def generate_badges(output_dir: str) -> None:
@@ -76,8 +78,8 @@ def generate_badges(output_dir: str) -> None:
_run([sys.executable, "-m", "devx.tools.generate_badges", "--output-dir", output_dir]) _run([sys.executable, "-m", "devx.tools.generate_badges", "--output-dir", output_dir])
badges = list(Path(output_dir).glob("*.svg")) badges = list(Path(output_dir).glob("*.svg"))
if not badges: if not badges:
raise click.ClickException("No badge SVG files generated") raise click.ClickException(_("No badge SVG files generated"))
click.echo(f"Generated {len(badges)} badge files") click.echo(_("Generated {count} badge files", count=len(badges)))
def push_to_badges_branch(badges_dir: str) -> str: def push_to_badges_branch(badges_dir: str) -> str:
@@ -99,12 +101,12 @@ def push_to_badges_branch(badges_dir: str) -> str:
_run(["git", "add", "./*.svg"]) # nosec B607 _run(["git", "add", "./*.svg"]) # nosec B607
_run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) # nosec B607 _run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) # nosec B607
_run(["git", "push", "origin", "badges", "--force"]) # nosec B607 _run(["git", "push", "origin", "badges", "--force"]) # nosec B607
click.echo("Badges pushed to badges branch") click.echo(_("Badges pushed to badges branch"))
# Get the commit SHA of the badges branch # Get the commit SHA of the badges branch
result = _run_capture(["git", "rev-parse", "HEAD"]) # nosec B607 result = _run_capture(["git", "rev-parse", "HEAD"]) # nosec B607
sha = result.stdout.strip() sha = result.stdout.strip()
click.echo(f"Badges commit SHA: {sha}") click.echo(_("Badges commit SHA: {sha}", sha=sha))
return sha return sha
@@ -142,11 +144,11 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
new_content = update_badge_urls(content, badges_sha) new_content = update_badge_urls(content, badges_sha)
if new_content != content: if new_content != content:
filepath.write_text(new_content) filepath.write_text(new_content)
click.echo(f"Updated badge URLs in {filename}") click.echo(_("Updated badge URLs in {filename}", filename=filename))
updated_any = True updated_any = True
if not updated_any: if not updated_any:
click.echo("No badge URLs found to update — README already up to date") click.echo(_("No badge URLs found to update — README already up to date"))
return return
_run(["git", "add", "README.md", "docs/index.md"]) # nosec B607 _run(["git", "add", "README.md", "docs/index.md"]) # nosec B607
@@ -160,7 +162,7 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
] ]
) # nosec B607 ) # nosec B607
_run(["git", "push", "origin", "master"]) # nosec B607 _run(["git", "push", "origin", "master"]) # nosec B607
click.echo(f"Pushed README update with badge SHA {badges_sha[:8]}") click.echo(_("Pushed README update with badge SHA {sha}", sha=badges_sha[:8]))
@click.command() @click.command()
@@ -193,13 +195,22 @@ def main(output_dir: str, branch: str, no_readme_update: bool, retries: int) ->
except (subprocess.CalledProcessError, RuntimeError) as exc: except (subprocess.CalledProcessError, RuntimeError) as exc:
last_error = exc last_error = exc
if attempt < retries: if attempt < retries:
click.echo(f"Badge push attempt {attempt}/{retries} failed — retrying: {exc}") click.echo(
_(
"Badge push attempt {attempt}/{retries} failed — retrying: {error}",
attempt=attempt,
retries=retries,
error=exc,
)
)
time.sleep(10) time.sleep(10)
with contextlib.suppress(subprocess.CalledProcessError): with contextlib.suppress(subprocess.CalledProcessError):
fetch_latest_master(branch) fetch_latest_master(branch)
else: else:
click.echo(f"Badge push failed after {retries} attempts: {exc}") click.echo(_("Badge push failed after {retries} attempts: {error}", retries=retries, error=exc))
raise click.ClickException(f"Badge push failed after {retries} attempts: {last_error}") raise click.ClickException(
_("Badge push failed after {retries} attempts: {error}", retries=retries, error=last_error)
)
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
+14 -36
View File
@@ -37,13 +37,12 @@ from __future__ import annotations
import os import os
import re import re
import subprocess # nosec B404
import sys import sys
import click import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.ci._shared import get_latest_tag from devx.ci._shared import get_latest_tag, run_cmd, write_github_output
from devx.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=. from devx.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=.
from devx.i18n import _ from devx.i18n import _
@@ -54,25 +53,6 @@ CHANGELOG_FILE = "CHANGELOG.md"
CLIFF_CONFIG = "cliff.toml" CLIFF_CONFIG = "cliff.toml"
def run_cmd(args: list[str], check: bool = True, capture: bool = True) -> subprocess.CompletedProcess[str]:
"""Run a command and return the completed process."""
result = subprocess.run( # nosec B603
args,
capture_output=capture,
text=True,
check=False,
)
if check and result.returncode != 0:
raise click.ClickException(
_(
"Command failed ({cmd}): {stderr}",
cmd=" ".join(args),
stderr=result.stderr.strip() if result.stderr else result.stdout.strip(),
)
)
return result
def tag_exists(tag: str) -> bool: def tag_exists(tag: str) -> bool:
"""Check if a git tag already exists.""" """Check if a git tag already exists."""
result = run_cmd(["git", "tag", "-l", tag], check=False) result = run_cmd(["git", "tag", "-l", tag], check=False)
@@ -217,7 +197,7 @@ def has_unreleased_changes(bumped_version: str | None = None) -> bool:
def update_init_version(new_version: str) -> None: def update_init_version(new_version: str) -> None:
"""Update __version__ in __init__.py.""" """Update __version__ in __init__.py."""
with open(INIT_FILE) as f: with open(INIT_FILE, encoding="utf-8") as f:
content = f.read() content = f.read()
if not re.search(r'^__version__\s*=\s*"[^"]*"', content, flags=re.MULTILINE): if not re.search(r'^__version__\s*=\s*"[^"]*"', content, flags=re.MULTILINE):
raise click.ClickException(_("Could not find __version__ in {file}", file=INIT_FILE)) raise click.ClickException(_("Could not find __version__ in {file}", file=INIT_FILE))
@@ -228,7 +208,7 @@ def update_init_version(new_version: str) -> None:
count=1, count=1,
flags=re.MULTILINE, flags=re.MULTILINE,
) )
with open(INIT_FILE, "w") as f: with open(INIT_FILE, "w", encoding="utf-8") as f:
f.write(updated) f.write(updated)
@@ -245,10 +225,10 @@ def update_changelog(changelog: str) -> None:
changelog = changelog[section_match.start() :] changelog = changelog[section_match.start() :]
try: try:
with open(CHANGELOG_FILE) as f: with open(CHANGELOG_FILE, encoding="utf-8") as f:
existing = f.read() existing = f.read()
except FileNotFoundError: except FileNotFoundError:
with open(CHANGELOG_FILE, "w") as f: with open(CHANGELOG_FILE, "w", encoding="utf-8") as f:
f.write(changelog + "\n") f.write(changelog + "\n")
return return
@@ -261,7 +241,7 @@ def update_changelog(changelog: str) -> None:
else: else:
# No version sections found — append # No version sections found — append
updated = existing.rstrip() + "\n\n" + changelog + "\n" updated = existing.rstrip() + "\n\n" + changelog + "\n"
with open(CHANGELOG_FILE, "w") as f: with open(CHANGELOG_FILE, "w", encoding="utf-8") as f:
f.write(updated) f.write(updated)
@@ -317,18 +297,16 @@ def run_tests() -> None:
click.echo(_("Tests passed.")) click.echo(_("Tests passed."))
def _write_github_output(tag: str) -> None: def _write_release_tag(tag: str) -> None:
"""Write the release tag to GITHUB_OUTPUT for downstream jobs. """Write the release tag to GITHUB_OUTPUT for downstream jobs.
This allows a publish job (needs: release) to read the tag via This allows a publish job (needs: release) to read the tag via
``${{ needs.release.outputs.tag }}`` instead of relying on ``${{ needs.release.outputs.tag }}`` instead of relying on
tag-push event triggering a separate workflow. tag-push event triggering a separate workflow.
""" """
github_output = os.environ.get("GITHUB_OUTPUT") if not os.environ.get("GITHUB_OUTPUT"):
if not github_output:
return return
with open(github_output, "a") as f: # noqa: PTH123 write_github_output("tag", tag)
f.write(f"tag={tag}\n")
click.echo(_("Wrote tag {tag} to GITHUB_OUTPUT.", tag=tag)) click.echo(_("Wrote tag {tag} to GITHUB_OUTPUT.", tag=tag))
@@ -360,7 +338,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
if not dry_run: if not dry_run:
# Ensure the existing tag is pushed # Ensure the existing tag is pushed
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"], check=False) run_cmd(["git", "push", "origin", f"refs/tags/{tag}"], check=False)
_write_github_output(tag) _write_release_tag(tag)
return False return False
tag_msg = f"Release v{new_version}\n\n{changelog}" tag_msg = f"Release v{new_version}\n\n{changelog}"
if dry_run: if dry_run:
@@ -368,7 +346,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
return True return True
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg]) run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"]) run_cmd(["git", "push", "origin", f"refs/tags/{tag}"])
_write_github_output(tag) _write_release_tag(tag)
return True return True
@@ -380,7 +358,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
def get_init_version() -> str | None: def get_init_version() -> str | None:
"""Read __version__ from the version file.""" """Read __version__ from the version file."""
try: try:
with open(INIT_FILE) as f: with open(INIT_FILE, encoding="utf-8") as f:
content = f.read() content = f.read()
match = re.search(r'^__version__\s*=\s*"([^"]*)"', content, flags=re.MULTILINE) match = re.search(r'^__version__\s*=\s*"([^"]*)"', content, flags=re.MULTILINE)
return match.group(1) if match else None return match.group(1) if match else None
@@ -391,7 +369,7 @@ def get_init_version() -> str | None:
def get_changelog_versions() -> list[str]: def get_changelog_versions() -> list[str]:
"""Extract version numbers from CHANGELOG.md headers, in order.""" """Extract version numbers from CHANGELOG.md headers, in order."""
try: try:
with open(CHANGELOG_FILE) as f: with open(CHANGELOG_FILE, encoding="utf-8") as f:
content = f.read() content = f.read()
return re.findall(r"^## \[(\d+\.\d+\.\d+)\]", content, flags=re.MULTILINE) return re.findall(r"^## \[(\d+\.\d+\.\d+)\]", content, flags=re.MULTILINE)
except FileNotFoundError: except FileNotFoundError:
@@ -634,7 +612,7 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
tag=release_tag, tag=release_tag,
) )
) )
_write_github_output(release_tag) _write_release_tag(release_tag)
return return
# Tag is missing — recover by creating and pushing it # Tag is missing — recover by creating and pushing it
click.echo( click.echo(
+2 -2
View File
@@ -49,7 +49,7 @@ def load_mapping() -> dict[str, str]:
Validates that the mapping is a dict of string-to-string pairs. Validates that the mapping is a dict of string-to-string pairs.
""" """
with open(MAPPING_FILE) as f: with open(MAPPING_FILE, encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
if not isinstance(data, dict): if not isinstance(data, dict):
raise click.ClickException( raise click.ClickException(
@@ -64,7 +64,7 @@ def load_mapping() -> dict[str, str]:
def read_doc_content(file_path: str) -> str: def read_doc_content(file_path: str) -> str:
"""Read markdown content from a docs file.""" """Read markdown content from a docs file."""
full_path = DOCS_DIR / file_path full_path = DOCS_DIR / file_path
with open(full_path) as f: with open(full_path, encoding="utf-8") as f:
return f.read() return f.read()
+1 -1
View File
@@ -69,7 +69,7 @@ def main(commit_msg_file: str | None, branch: str | None, from_git: bool) -> Non
if commit_msg_file == "-": if commit_msg_file == "-":
msg = sys.stdin.read().strip() msg = sys.stdin.read().strip()
else: else:
with open(commit_msg_file) as f: with open(commit_msg_file, encoding="utf-8") as f:
msg = f.read().strip() msg = f.read().strip()
else: else:
raise click.ClickException(_("Provide a commit message file or use --git.")) raise click.ClickException(_("Provide a commit message file or use --git."))
+1 -1
View File
@@ -330,7 +330,7 @@ devx-setup-image:
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(DEVX_VENV); . $(DEVX_BIN)/activate; \ @if [ -d /opt/venv ]; then ln -sf /opt/venv $(DEVX_VENV); . $(DEVX_BIN)/activate; \
_U="$${CI_GITEA_USERNAME:-emil}"; \ _U="$${CI_GITEA_USERNAME:-emil}"; \
if [ -n "$$CI_GITEA_TOKEN" ]; then export PIP_EXTRA_INDEX_URL="https://$$_U:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \ if [ -n "$$CI_GITEA_TOKEN" ]; then export PIP_EXTRA_INDEX_URL="https://$$_U:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
pip install -e .$(if $(EXTRAS),[$(EXTRAS)],); \ pip install --no-cache-dir -e .$(if $(EXTRAS),[$(EXTRAS)],); \
echo "[devx-setup-image] Linked /opt/venv$(if $(EXTRAS), with [$(EXTRAS)],)."; \ echo "[devx-setup-image] Linked /opt/venv$(if $(EXTRAS), with [$(EXTRAS)],)."; \
else echo "[devx-setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi else echo "[devx-setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
+1
View File
@@ -0,0 +1 @@
"""Molecule testing helpers for Ansible projects."""
+1 -1
View File
@@ -156,7 +156,7 @@ def main(
gh_output = os.environ.get("GITHUB_OUTPUT") gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output: if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set") raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a") as f: # noqa: PTH123 with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
f.write(f"runner-count={count}\n") f.write(f"runner-count={count}\n")
f.write(f"runner-indices={json.dumps(indices)}\n") f.write(f"runner-indices={json.dumps(indices)}\n")
click.echo(f"Runner count: {count}") click.echo(f"Runner count: {count}")
+5 -24
View File
@@ -25,6 +25,7 @@ from pathlib import Path
import click import click
from devx.ci._shared import lpt_distribute, write_github_env
from devx.i18n import _ from devx.i18n import _
from devx.molecule.platforms import PLATFORMS, load_platforms from devx.molecule.platforms import PLATFORMS, load_platforms
@@ -216,22 +217,8 @@ def _scenario_weight(scenario: str, role: str | None = None) -> int:
def _lpt_distribute[T](items: list[T], weights: list[int], max_runners: int) -> list[list[T]]: def _lpt_distribute[T](items: list[T], weights: list[int], max_runners: int) -> list[list[T]]:
"""Distribute *items* across *max_runners* using LPT (Longest Processing Time first). """Distribute *items* across *max_runners* using LPT (delegates to shared utility)."""
return lpt_distribute(items, weights, max_runners)
Sorts items by weight (descending), then assigns each to the runner
with the least total weight. This produces a more balanced distribution
than naive round-robin when items have varying costs.
"""
groups: list[list[T]] = [[] for _ in range(max_runners)]
loads = [0] * max_runners
# Sort by weight descending, preserving original order for ties
indexed = sorted(enumerate(items), key=lambda x: (-weights[x[0]], x[0]))
for orig_idx, item in indexed:
# Find the runner with the minimum load
min_runner = min(range(max_runners), key=lambda r: loads[r])
groups[min_runner].append(item)
loads[min_runner] += weights[orig_idx]
return groups
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]: def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
@@ -283,14 +270,8 @@ def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int)
def _write_github_env(key: str, value: str) -> None: def _write_github_env(key: str, value: str) -> None:
"""Append a key=value line to the $GITHUB_ENV file.""" """Append a key=value line to the $GITHUB_ENV file (delegates to shared utility)."""
import os write_github_env(key, value)
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.command()
+1 -1
View File
@@ -41,7 +41,7 @@ def load_platforms(platforms_file: str | Path | None = None) -> list[dict[str, s
path = Path(platforms_file) path = Path(platforms_file)
if not path.is_file(): if not path.is_file():
return PLATFORMS return PLATFORMS
with path.open() as f: with path.open(encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
if not isinstance(data, list) or not data: if not isinstance(data, list) or not data:
return PLATFORMS return PLATFORMS
+2 -2
View File
@@ -168,7 +168,7 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
click.echo(_("Docker daemon failed to start")) click.echo(_("Docker daemon failed to start"))
click.echo("--- dockerd log ---") click.echo("--- dockerd log ---")
try: try:
with open(log_file.name) as f: with open(log_file.name, encoding="utf-8") as f:
log_content = f.read() log_content = f.read()
click.echo(log_content[-3000:] if len(log_content) > 3000 else log_content) click.echo(log_content[-3000:] if len(log_content) > 3000 else log_content)
except OSError as e: except OSError as e:
@@ -191,7 +191,7 @@ def main(timeout: int) -> None:
# Export DOCKER_HOST to GITHUB_ENV for subsequent CI steps # Export DOCKER_HOST to GITHUB_ENV for subsequent CI steps
github_env = os.environ.get("GITHUB_ENV") github_env = os.environ.get("GITHUB_ENV")
if github_env and os.environ.get("DOCKER_HOST"): if github_env and os.environ.get("DOCKER_HOST"):
with open(github_env, "a") as f: with open(github_env, "a", encoding="utf-8") as f:
f.write(f"DOCKER_HOST={os.environ['DOCKER_HOST']}\n") f.write(f"DOCKER_HOST={os.environ['DOCKER_HOST']}\n")
click.echo(f"Exported DOCKER_HOST={os.environ['DOCKER_HOST']} to GITHUB_ENV") click.echo(f"Exported DOCKER_HOST={os.environ['DOCKER_HOST']} to GITHUB_ENV")
sys.exit(0) sys.exit(0)
+24
View File
@@ -0,0 +1,24 @@
"""Shared utilities for tools modules."""
from __future__ import annotations
import platform
import click
def arch_string() -> str:
"""Return the architecture string used by release assets.
Maps ``platform.machine()`` to the common release asset naming:
``amd64`` for x86_64, ``arm64`` for aarch64.
Raises:
click.ClickException: If the architecture is not supported.
"""
machine = platform.machine().lower()
if machine in {"x86_64", "amd64"}:
return "amd64"
if machine in {"aarch64", "arm64"}:
return "arm64"
raise click.ClickException(f"Unsupported architecture: {machine}")
+1 -1
View File
@@ -88,7 +88,7 @@ def load_manifest(path: str | Path) -> list[ImageSpec]:
p = Path(path) p = Path(path)
if not p.is_file(): if not p.is_file():
raise click.ClickException(_("Manifest file not found: {path}", path=p)) raise click.ClickException(_("Manifest file not found: {path}", path=p))
with p.open() as f: # noqa: PTH123 with p.open(encoding="utf-8") as f: # noqa: PTH123
data = json.load(f) data = json.load(f)
if not isinstance(data, list): if not isinstance(data, list):
raise click.ClickException(_("Manifest must be a JSON list")) raise click.ClickException(_("Manifest must be a JSON list"))
+19 -10
View File
@@ -200,9 +200,9 @@ def main(
total_kept = 0 total_kept = 0
total_failed = 0 total_failed = 0
for name in names: for name in names:
click.echo(f"\n{'=' * 60}") click.echo(_("\n{separator}", separator="=" * 60))
click.echo(f"Package: {owner}/{name}") click.echo(_("Package: {owner}/{name}", owner=owner, name=name))
click.echo(f"{'=' * 60}") click.echo(_("{separator}", separator="=" * 60))
try: try:
versions = list_package_versions(base_url, owner, name, token) versions = list_package_versions(base_url, owner, name, token)
except requests.RequestException as exc: except requests.RequestException as exc:
@@ -217,17 +217,19 @@ def main(
click.echo(_("No versions found.")) click.echo(_("No versions found."))
continue continue
click.echo(f"Found {len(versions)} version(s):") click.echo(_("Found {count} version(s):", count=len(versions)))
for v in sort_versions_by_date(versions): for v in sort_versions_by_date(versions):
click.echo(f" {v.get('version', '?')} (created: {v.get('created_at', '?')})") click.echo(
_(" {version} (created: {created})", version=v.get("version", "?"), created=v.get("created_at", "?"))
)
to_delete = select_for_deletion(versions, keep) to_delete = select_for_deletion(versions, keep)
kept_count = len(versions) - len(to_delete) kept_count = len(versions) - len(to_delete)
click.echo(f"\nKeeping {kept_count}, would delete {len(to_delete)}") click.echo(_("\nKeeping {kept}, would delete {count}", kept=kept_count, count=len(to_delete)))
if dry_run: if dry_run:
for v in to_delete: for v in to_delete:
click.echo(f" [dry-run] Would delete: {v.get('version', '?')}") click.echo(_(" [dry-run] Would delete: {version}", version=v.get("version", "?")))
total_kept += kept_count total_kept += kept_count
continue continue
@@ -236,17 +238,24 @@ def main(
for v in to_delete: for v in to_delete:
version = str(v.get("version", "")) version = str(v.get("version", ""))
if delete_package_version(base_url, owner, name, version, token): if delete_package_version(base_url, owner, name, version, token):
click.echo(f" Deleted: {version}") click.echo(_(" Deleted: {version}", version=version))
deleted_count += 1 deleted_count += 1
else: else:
click.echo(f" FAILED to delete: {version}", err=True) click.echo(_(" FAILED to delete: {version}", version=version), err=True)
failed_count += 1 failed_count += 1
total_deleted += deleted_count total_deleted += deleted_count
total_kept += kept_count total_kept += kept_count
total_failed += failed_count total_failed += failed_count
click.echo(f"\nDone. Deleted {total_deleted}, kept {total_kept}, failed {total_failed}.") click.echo(
_(
"\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
deleted=total_deleted,
kept=total_kept,
failed=total_failed,
)
)
if total_failed > 0: if total_failed > 0:
raise click.ClickException(_("Failed to delete {count} image version(s)", count=total_failed)) raise click.ClickException(_("Failed to delete {count} image version(s)", count=total_failed))
+8 -1
View File
@@ -50,11 +50,17 @@ def _default_branch_protection_config() -> dict[str, Any]:
"push_whitelist_usernames": [], "push_whitelist_usernames": [],
"enable_status_check": True, "enable_status_check": True,
"status_check_contexts": _default_status_checks(), "status_check_contexts": _default_status_checks(),
"required_approvals": 0, "required_approvals": 1,
"dismiss_stale_approvals": True, "dismiss_stale_approvals": True,
"block_on_outdated_branch": True, "block_on_outdated_branch": True,
"block_on_rejected_reviews": True, "block_on_rejected_reviews": True,
"block_on_official_review_requests": True, "block_on_official_review_requests": True,
# Prevent admins from force-merging PRs that don't meet branch
# protection requirements (e.g. missing approvals). Without this,
# an admin token can bypass the approval gate via force_merge=true,
# allowing merges that failed the auto-merge CI job to reach master
# and trigger the post-merge release pipeline.
"block_admin_merge_override": True,
} }
@@ -123,6 +129,7 @@ def configure_repo(
click.echo(_(" - Dismiss stale approvals: yes")) click.echo(_(" - Dismiss stale approvals: yes"))
click.echo(_(" - Block outdated branches: yes")) click.echo(_(" - Block outdated branches: yes"))
click.echo(_(" - Block rejected reviews: yes")) click.echo(_(" - Block rejected reviews: yes"))
click.echo(_(" - Block admin merge override: yes"))
checks = ", ".join(cast(list[str], bp_config["status_check_contexts"])) checks = ", ".join(cast(list[str], bp_config["status_check_contexts"]))
click.echo(_(" - Required status checks: {checks}", checks=checks)) click.echo(_(" - Required status checks: {checks}", checks=checks))
+40 -28
View File
@@ -27,6 +27,8 @@ from pathlib import Path
import click import click
from devx.i18n import _
# Coverage regex matches "TOTAL ... NN%" or "TOTAL ... NN.NN%" # Coverage regex matches "TOTAL ... NN%" or "TOTAL ... NN.NN%"
_COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%") _COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%")
_PASSED_RE = re.compile(r"(\d+) passed") _PASSED_RE = re.compile(r"(\d+) passed")
@@ -197,17 +199,19 @@ def read_version(repo_root: Path) -> str:
""" """
pkg = detect_package_name(repo_root) pkg = detect_package_name(repo_root)
if pkg is None: if pkg is None:
click.echo(" WARNING: No Python package found under src/ — version badge will show 'unknown'") click.echo(_(" WARNING: No Python package found under src/ — version badge will show 'unknown'"))
return "unknown" return "unknown"
init_file = repo_root / "src" / pkg / "__init__.py" init_file = repo_root / "src" / pkg / "__init__.py"
if not init_file.exists(): if not init_file.exists():
click.echo(f" WARNING: {init_file} not found — version badge will show 'unknown'") click.echo(_(" WARNING: {init_file} not found — version badge will show 'unknown'", init_file=init_file))
return "unknown" return "unknown"
content = init_file.read_text() content = init_file.read_text()
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content) match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
if match: if match:
return match.group(1) return match.group(1)
click.echo(f" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'") click.echo(
_(" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'", init_file=init_file)
)
return "unknown" return "unknown"
@@ -278,11 +282,11 @@ def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], d
""" """
cov_target = detect_coverage_target(repo_root) cov_target = detect_coverage_target(repo_root)
if cov_target is None: if cov_target is None:
click.echo(" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)") click.echo(_(" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)"))
return make_badge("coverage", "unknown", "lightgrey"), make_badge("tests", "unknown", "lightgrey") return make_badge("coverage", "unknown", "lightgrey"), make_badge("tests", "unknown", "lightgrey")
testpaths = detect_testpaths(repo_root) testpaths = detect_testpaths(repo_root)
click.echo(f" Test paths: {testpaths or '(pytest defaults)'}") click.echo(_(" Test paths: {testpaths}", testpaths=testpaths or "(pytest defaults)"))
cmd = [ cmd = [
sys.executable, sys.executable,
@@ -302,18 +306,18 @@ def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], d
if coverage is not None: if coverage is not None:
cov_badge = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage)) cov_badge = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
else: else:
click.echo(f" WARNING: Could not extract coverage from pytest output (rc={rc})") click.echo(_(" WARNING: Could not extract coverage from pytest output (rc={rc})", rc=rc))
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}") click.echo(_(" pytest stdout (last 300 chars): {stdout}", stdout=stdout.strip()[-300:]))
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}") click.echo(_(" pytest stderr (last 300 chars): {stderr}", stderr=stderr.strip()[-300:]))
cov_badge = make_badge("coverage", "unknown", "red") cov_badge = make_badge("coverage", "unknown", "red")
test_count = extract_test_count(combined) test_count = extract_test_count(combined)
if test_count is not None: if test_count is not None:
tests_badge = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red") tests_badge = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
else: else:
click.echo(f" WARNING: Could not extract test count from pytest output (rc={rc})") click.echo(_(" WARNING: Could not extract test count from pytest output (rc={rc})", rc=rc))
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}") click.echo(_(" pytest stdout (last 300 chars): {stdout}", stdout=stdout.strip()[-300:]))
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}") click.echo(_(" pytest stderr (last 300 chars): {stderr}", stderr=stderr.strip()[-300:]))
tests_badge = make_badge("tests", "unknown", "red") tests_badge = make_badge("tests", "unknown", "red")
return cov_badge, tests_badge return cov_badge, tests_badge
@@ -328,8 +332,8 @@ def collect_doc_coverage(repo_root: Path) -> dict[str, str | int]:
doc_pct = extract_doc_coverage(stdout) doc_pct = extract_doc_coverage(stdout)
if doc_pct is not None: if doc_pct is not None:
return make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct)) return make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
click.echo(f" WARNING: Could not extract doc coverage (rc={rc})") click.echo(_(" WARNING: Could not extract doc coverage (rc={rc})", rc=rc))
click.echo(f" stderr: {stderr.strip()[:200]}") click.echo(_(" stderr: {stderr}", stderr=stderr.strip()[:200]))
return make_badge("docs", "unknown", "red") return make_badge("docs", "unknown", "red")
@@ -348,7 +352,7 @@ def collect_quality(repo_root: Path) -> dict[str, str | int]:
([sys.executable, "-m", "pyright"], "pyright"), ([sys.executable, "-m", "pyright"], "pyright"),
([sys.executable, "-m", "bandit", "-r", "src/"], "bandit"), ([sys.executable, "-m", "bandit", "-r", "src/"], "bandit"),
]: ]:
rc, _, stderr = run_command(cmd, cwd=repo_root) rc, _stdout, stderr = run_command(cmd, cwd=repo_root)
if rc == 0: if rc == 0:
results.append(True) results.append(True)
tool_names.append(f"{name}: pass") tool_names.append(f"{name}: pass")
@@ -356,16 +360,16 @@ def collect_quality(repo_root: Path) -> dict[str, str | int]:
results.append(False) results.append(False)
# Distinguish "tool not installed" from "tool found issues" # Distinguish "tool not installed" from "tool found issues"
if "No module named" in stderr or "not found" in stderr.lower(): if "No module named" in stderr or "not found" in stderr.lower():
click.echo(f" WARNING: {name} not installed — skipping (counted as pass)") click.echo(_(" WARNING: {name} not installed — skipping (counted as pass)", name=name))
results[-1] = True results[-1] = True
tool_names.append(f"{name}: not installed (skipped)") tool_names.append(f"{name}: not installed (skipped)")
else: else:
tool_names.append(f"{name}: FAIL") tool_names.append(f"{name}: FAIL")
click.echo(f" WARNING: {name} failed (rc={rc})") click.echo(_(" WARNING: {name} failed (rc={rc})", name=name, rc=rc))
click.echo(f" stderr: {stderr.strip()[:200]}") click.echo(_(" stderr: {stderr}", stderr=stderr.strip()[:200]))
all_pass = all(results) all_pass = all(results)
click.echo(f" Quality checks: {', '.join(tool_names)}") click.echo(_(" Quality checks: {checks}", checks=", ".join(tool_names)))
return make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red") return make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
@@ -377,28 +381,28 @@ def generate_badges(output_dir: Path, repo_root: Path | None = None) -> dict[str
repo_root: Repository root (auto-detected if None). repo_root: Repository root (auto-detected if None).
""" """
root = repo_root or resolve_repo_root() root = repo_root or resolve_repo_root()
click.echo(f" Repo root: {root}") click.echo(_(" Repo root: {root}", root=root))
pkg = detect_package_name(root) pkg = detect_package_name(root)
click.echo(f" Package: {pkg or 'none'}") click.echo(_(" Package: {pkg}", pkg=pkg or "none"))
badges: dict[str, dict[str, str | int]] = {} badges: dict[str, dict[str, str | int]] = {}
# 1. Code coverage + test count (single pytest-cov run) # 1. Code coverage + test count (single pytest-cov run)
click.echo(" Collecting coverage and tests...") click.echo(_(" Collecting coverage and tests..."))
cov_badge, tests_badge = collect_coverage_and_tests(root) cov_badge, tests_badge = collect_coverage_and_tests(root)
badges["coverage"] = cov_badge badges["coverage"] = cov_badge
badges["tests"] = tests_badge badges["tests"] = tests_badge
# 2. Documentation coverage # 2. Documentation coverage
click.echo(" Collecting doc coverage...") click.echo(_(" Collecting doc coverage..."))
badges["docs"] = collect_doc_coverage(root) badges["docs"] = collect_doc_coverage(root)
# 3. Code quality (ruff + pyright + bandit) # 3. Code quality (ruff + pyright + bandit)
click.echo(" Collecting code quality...") click.echo(_(" Collecting code quality..."))
badges["quality"] = collect_quality(root) badges["quality"] = collect_quality(root)
# 4. Version # 4. Version
click.echo(" Collecting version...") click.echo(_(" Collecting version..."))
version = read_version(root) version = read_version(root)
badges["version"] = make_badge("version", f"v{version}", "blue") badges["version"] = make_badge("version", f"v{version}", "blue")
@@ -411,7 +415,7 @@ def generate_badges(output_dir: Path, repo_root: Path | None = None) -> dict[str
svg = render_svg(str(badge["label"]), str(badge["message"]), str(badge["color"])) svg = render_svg(str(badge["label"]), str(badge["message"]), str(badge["color"]))
path = output_dir / f"{name}.svg" path = output_dir / f"{name}.svg"
path.write_text(svg) path.write_text(svg)
click.echo(f" Generated: {path}") click.echo(_(" Generated: {path}", path=path))
return badges return badges
@@ -431,11 +435,19 @@ def cli(output_dir: str, repo_root: str | None) -> None:
"""Generate self-contained SVG badge files from project metrics.""" """Generate self-contained SVG badge files from project metrics."""
out = Path(output_dir) out = Path(output_dir)
root = Path(repo_root) if repo_root else None root = Path(repo_root) if repo_root else None
click.echo(f"Generating badges in {out}...") click.echo(_("Generating badges in {out}...", out=out))
badges = generate_badges(out, repo_root=root) badges = generate_badges(out, repo_root=root)
click.echo(f"\nGenerated {len(badges)} badges:") click.echo(_("\nGenerated {count} badges:", count=len(badges)))
for name, badge in badges.items(): for name, badge in badges.items():
click.echo(f" {name}: {badge['label']}={badge['message']} ({badge['color']})") click.echo(
_(
" {name}: {label}={message} ({color})",
name=name,
label=badge["label"],
message=badge["message"],
color=badge["color"],
)
)
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
+7 -13
View File
@@ -7,7 +7,6 @@ pre-built Linux binary from the official GitHub releases.
from __future__ import annotations from __future__ import annotations
import platform
import shutil import shutil
import subprocess # nosec B404 import subprocess # nosec B404
import urllib.request import urllib.request
@@ -15,6 +14,8 @@ from pathlib import Path
import click import click
from devx.tools._shared import arch_string
CHECKMAKE_VERSION = "0.3.2" CHECKMAKE_VERSION = "0.3.2"
RELEASE_URL_TEMPLATE = ( RELEASE_URL_TEMPLATE = (
"https://github.com/checkmake/checkmake/releases/download/" "https://github.com/checkmake/checkmake/releases/download/"
@@ -23,18 +24,11 @@ RELEASE_URL_TEMPLATE = (
TARGET_PATH = Path("/usr/local/bin/checkmake") TARGET_PATH = Path("/usr/local/bin/checkmake")
def _arch() -> str:
"""Return the architecture string used by checkmake releases."""
machine = platform.machine().lower()
if machine in {"x86_64", "amd64"}:
return "amd64"
if machine in {"aarch64", "arm64"}:
return "arm64"
raise click.ClickException(f"Unsupported architecture: {machine}")
def _install_with_go() -> bool: def _install_with_go() -> bool:
"""Install checkmake using go install if Go is available.""" """Install checkmake using go install if Go is available.
Returns True if the installation succeeded, False if Go is not installed.
"""
go_bin = shutil.which("go") go_bin = shutil.which("go")
if go_bin is None: if go_bin is None:
return False return False
@@ -51,7 +45,7 @@ def _install_with_go() -> bool:
def _download_binary() -> None: def _download_binary() -> None:
"""Download the prebuilt checkmake binary for the current architecture.""" """Download the prebuilt checkmake binary for the current architecture."""
url = RELEASE_URL_TEMPLATE.format(arch=_arch()) url = RELEASE_URL_TEMPLATE.format(arch=arch_string())
urllib.request.urlretrieve(url, TARGET_PATH) # nosec B310 urllib.request.urlretrieve(url, TARGET_PATH) # nosec B310
TARGET_PATH.chmod(0o755) TARGET_PATH.chmod(0o755)
+4 -7
View File
@@ -44,13 +44,10 @@ HADOLINT_VERSION = "2.12.0"
def _arch() -> str: def _arch() -> str:
"""Return the architecture string used by release assets.""" """Return the architecture string used by release assets (delegates to shared utility)."""
machine = platform.machine().lower() from devx.tools._shared import arch_string
if machine in {"x86_64", "amd64"}:
return "amd64" return arch_string()
if machine in {"aarch64", "arm64"}:
return "arm64"
raise click.ClickException(f"Unsupported architecture: {machine}")
def _ensure_target_dir() -> Path: def _ensure_target_dir() -> Path:
+823 -295
View File
@@ -55,6 +55,14 @@
"ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}", "ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}" "zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
}, },
"\nDone. Deleted {deleted}, kept {kept}, failed {failed}.": {
"bg": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
"de": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
"en": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
"pl": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
"ru": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
"zh": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}."
},
"\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": { "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": {
"bg": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.", "bg": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
"de": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.", "de": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
@@ -71,6 +79,14 @@
"ru": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.", "ru": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
"zh": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report." "zh": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report."
}, },
"\nGenerated {count} badges:": {
"bg": "\nGenerated {count} badges:",
"de": "\nGenerated {count} badges:",
"en": "\nGenerated {count} badges:",
"pl": "\nGenerated {count} badges:",
"ru": "\nGenerated {count} badges:",
"zh": "\nGenerated {count} badges:"
},
"\nIntegrity check FAILED ({count} issues):": { "\nIntegrity check FAILED ({count} issues):": {
"bg": "\nIntegrity check FAILED ({count} issues):", "bg": "\nIntegrity check FAILED ({count} issues):",
"de": "\nIntegrity check FAILED ({count} issues):", "de": "\nIntegrity check FAILED ({count} issues):",
@@ -87,6 +103,14 @@
"ru": "\nIntegrity check passed — all {count} pages verified.", "ru": "\nIntegrity check passed — all {count} pages verified.",
"zh": "\nIntegrity check passed — all {count} pages verified." "zh": "\nIntegrity check passed — all {count} pages verified."
}, },
"\nKeeping {kept}, would delete {count}": {
"bg": "\nKeeping {kept}, would delete {count}",
"de": "\nKeeping {kept}, would delete {count}",
"en": "\nKeeping {kept}, would delete {count}",
"pl": "\nKeeping {kept}, would delete {count}",
"ru": "\nKeeping {kept}, would delete {count}",
"zh": "\nKeeping {kept}, would delete {count}"
},
"\nLatest tag: {tag}": { "\nLatest tag: {tag}": {
"bg": "\nLatest tag: {tag}", "bg": "\nLatest tag: {tag}",
"de": "\nLatest tag: {tag}", "de": "\nLatest tag: {tag}",
@@ -119,6 +143,14 @@
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).", "ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)." "zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
}, },
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.": {
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"pl": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'."
},
"\nRunning full wiki integrity check...": { "\nRunning full wiki integrity check...": {
"bg": "\nRunning full wiki integrity check...", "bg": "\nRunning full wiki integrity check...",
"de": "\nRunning full wiki integrity check...", "de": "\nRunning full wiki integrity check...",
@@ -183,6 +215,14 @@
"ru": "\nWorkflow-only changes ({count}):", "ru": "\nWorkflow-only changes ({count}):",
"zh": "\nWorkflow-only changes ({count}):" "zh": "\nWorkflow-only changes ({count}):"
}, },
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.": {
"en": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"bg": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"de": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"pl": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"ru": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"zh": "\n[check_test_coverage] Fix: add the missing test file(s) before committing."
},
"\n[dry-run] Changelog:\n{changelog}": { "\n[dry-run] Changelog:\n{changelog}": {
"bg": "\n[dry-run] Changelog:\n{changelog}", "bg": "\n[dry-run] Changelog:\n{changelog}",
"de": "\n[dry-run] Changelog:\n{changelog}", "de": "\n[dry-run] Changelog:\n{changelog}",
@@ -199,6 +239,14 @@
"ru": "\n{label} files changed ({count}):", "ru": "\n{label} files changed ({count}):",
"zh": "\n{label} files changed ({count}):" "zh": "\n{label} files changed ({count}):"
}, },
"\n{separator}": {
"bg": "\n{separator}",
"de": "\n{separator}",
"en": "\n{separator}",
"pl": "\n{separator}",
"ru": "\n{separator}",
"zh": "\n{separator}"
},
"\n{tag} files ({count}):": { "\n{tag} files ({count}):": {
"bg": "\n{tag} files ({count}):", "bg": "\n{tag} files ({count}):",
"de": "\n{tag} files ({count}):", "de": "\n{tag} files ({count}):",
@@ -207,6 +255,38 @@
"ru": "\n{tag} files ({count}):", "ru": "\n{tag} files ({count}):",
"zh": "\n{tag} files ({count}):" "zh": "\n{tag} files ({count}):"
}, },
" Could not fetch logs: {error}": {
"en": " Could not fetch logs: {error}",
"bg": " Could not fetch logs: {error}",
"de": " Could not fetch logs: {error}",
"pl": " Could not fetch logs: {error}",
"ru": " Could not fetch logs: {error}",
"zh": " Could not fetch logs: {error}"
},
" pytest stderr (last 300 chars): {stderr}": {
"bg": " pytest stderr (last 300 chars): {stderr}",
"de": " pytest stderr (last 300 chars): {stderr}",
"en": " pytest stderr (last 300 chars): {stderr}",
"pl": " pytest stderr (last 300 chars): {stderr}",
"ru": " pytest stderr (last 300 chars): {stderr}",
"zh": " pytest stderr (last 300 chars): {stderr}"
},
" pytest stdout (last 300 chars): {stdout}": {
"bg": " pytest stdout (last 300 chars): {stdout}",
"de": " pytest stdout (last 300 chars): {stdout}",
"en": " pytest stdout (last 300 chars): {stdout}",
"pl": " pytest stdout (last 300 chars): {stdout}",
"ru": " pytest stdout (last 300 chars): {stdout}",
"zh": " pytest stdout (last 300 chars): {stdout}"
},
" stderr: {stderr}": {
"bg": " stderr: {stderr}",
"de": " stderr: {stderr}",
"en": " stderr: {stderr}",
"pl": " stderr: {stderr}",
"ru": " stderr: {stderr}",
"zh": " stderr: {stderr}"
},
" - Auto-delete branch after merge: yes": { " - Auto-delete branch after merge: yes": {
"bg": " - Автоматично изтриване на клон след сливане: да", "bg": " - Автоматично изтриване на клон след сливане: да",
"de": " - Branch nach Merge automatisch löschen: ja", "de": " - Branch nach Merge automatisch löschen: ja",
@@ -215,6 +295,14 @@
"ru": " - Автоудаление ветки после слияния: да", "ru": " - Автоудаление ветки после слияния: да",
"zh": " - 合并后自动删除分支: 是" "zh": " - 合并后自动删除分支: 是"
}, },
" - Block admin merge override: yes": {
"bg": " - Блокиране на admin merge override: да",
"de": " - Admin-Merge-Override blockieren: ja",
"en": " - Block admin merge override: yes",
"pl": " - Blokuj admin merge override: tak",
"ru": " - Блокировать admin merge override: да",
"zh": " - 阻止管理员合并覆盖:是"
},
" - Block outdated branches: yes": { " - Block outdated branches: yes": {
"bg": " - Блокиране на остарели клонове: да", "bg": " - Блокиране на остарели клонове: да",
"de": " - Veraltete Branches blockieren: ja", "de": " - Veraltete Branches blockieren: ja",
@@ -263,6 +351,38 @@
"ru": " - Требуемые проверки статуса: {checks}", "ru": " - Требуемые проверки статуса: {checks}",
"zh": " - 必需状态检查: {checks}" "zh": " - 必需状态检查: {checks}"
}, },
" Collecting code quality...": {
"bg": " Collecting code quality...",
"de": " Collecting code quality...",
"en": " Collecting code quality...",
"pl": " Collecting code quality...",
"ru": " Collecting code quality...",
"zh": " Collecting code quality..."
},
" Collecting coverage and tests...": {
"bg": " Collecting coverage and tests...",
"de": " Collecting coverage and tests...",
"en": " Collecting coverage and tests...",
"pl": " Collecting coverage and tests...",
"ru": " Collecting coverage and tests...",
"zh": " Collecting coverage and tests..."
},
" Collecting doc coverage...": {
"bg": " Collecting doc coverage...",
"de": " Collecting doc coverage...",
"en": " Collecting doc coverage...",
"pl": " Collecting doc coverage...",
"ru": " Collecting doc coverage...",
"zh": " Collecting doc coverage..."
},
" Collecting version...": {
"bg": " Collecting version...",
"de": " Collecting version...",
"en": " Collecting version...",
"pl": " Collecting version...",
"ru": " Collecting version...",
"zh": " Collecting version..."
},
" Created: {title}": { " Created: {title}": {
"bg": " Created: {title}", "bg": " Created: {title}",
"de": " Created: {title}", "de": " Created: {title}",
@@ -271,6 +391,14 @@
"ru": " Created: {title}", "ru": " Created: {title}",
"zh": " Created: {title}" "zh": " Created: {title}"
}, },
" Deleted: {version}": {
"bg": " Deleted: {version}",
"de": " Deleted: {version}",
"en": " Deleted: {version}",
"pl": " Deleted: {version}",
"ru": " Deleted: {version}",
"zh": " Deleted: {version}"
},
" FAIL: {title} — content mismatch or empty!": { " FAIL: {title} — content mismatch or empty!": {
"bg": " FAIL: {title} — content mismatch or empty!", "bg": " FAIL: {title} — content mismatch or empty!",
"de": " FAIL: {title} — content mismatch or empty!", "de": " FAIL: {title} — content mismatch or empty!",
@@ -279,6 +407,22 @@
"ru": " FAIL: {title} — content mismatch or empty!", "ru": " FAIL: {title} — content mismatch or empty!",
"zh": " FAIL: {title} — content mismatch or empty!" "zh": " FAIL: {title} — content mismatch or empty!"
}, },
" FAILED to delete: {version}": {
"bg": " FAILED to delete: {version}",
"de": " FAILED to delete: {version}",
"en": " FAILED to delete: {version}",
"pl": " FAILED to delete: {version}",
"ru": " FAILED to delete: {version}",
"zh": " FAILED to delete: {version}"
},
" Generated: {path}": {
"bg": " Generated: {path}",
"de": " Generated: {path}",
"en": " Generated: {path}",
"pl": " Generated: {path}",
"ru": " Generated: {path}",
"zh": " Generated: {path}"
},
" MISSING: devx {cmd}": { " MISSING: devx {cmd}": {
"bg": " ЛИПСВА: devx {cmd}", "bg": " ЛИПСВА: devx {cmd}",
"de": " FEHLT: devx {cmd}", "de": " FEHLT: devx {cmd}",
@@ -335,6 +479,38 @@
"ru": " OK: {title} ({chars} chars)", "ru": " OK: {title} ({chars} chars)",
"zh": " OK: {title} ({chars} chars)" "zh": " OK: {title} ({chars} chars)"
}, },
" Package: {pkg}": {
"bg": " Package: {pkg}",
"de": " Package: {pkg}",
"en": " Package: {pkg}",
"pl": " Package: {pkg}",
"ru": " Package: {pkg}",
"zh": " Package: {pkg}"
},
" Quality checks: {checks}": {
"bg": " Quality checks: {checks}",
"de": " Quality checks: {checks}",
"en": " Quality checks: {checks}",
"pl": " Quality checks: {checks}",
"ru": " Quality checks: {checks}",
"zh": " Quality checks: {checks}"
},
" Repo root: {root}": {
"bg": " Repo root: {root}",
"de": " Repo root: {root}",
"en": " Repo root: {root}",
"pl": " Repo root: {root}",
"ru": " Repo root: {root}",
"zh": " Repo root: {root}"
},
" Test paths: {testpaths}": {
"bg": " Test paths: {testpaths}",
"de": " Test paths: {testpaths}",
"en": " Test paths: {testpaths}",
"pl": " Test paths: {testpaths}",
"ru": " Test paths: {testpaths}",
"zh": " Test paths: {testpaths}"
},
" Updated: {title}": { " Updated: {title}": {
"bg": " Updated: {title}", "bg": " Updated: {title}",
"de": " Updated: {title}", "de": " Updated: {title}",
@@ -343,6 +519,118 @@
"ru": " Updated: {title}", "ru": " Updated: {title}",
"zh": " Updated: {title}" "zh": " Updated: {title}"
}, },
" WARNING: Could not extract coverage from pytest output (rc={rc})": {
"bg": " WARNING: Could not extract coverage from pytest output (rc={rc})",
"de": " WARNING: Could not extract coverage from pytest output (rc={rc})",
"en": " WARNING: Could not extract coverage from pytest output (rc={rc})",
"pl": " WARNING: Could not extract coverage from pytest output (rc={rc})",
"ru": " WARNING: Could not extract coverage from pytest output (rc={rc})",
"zh": " WARNING: Could not extract coverage from pytest output (rc={rc})"
},
" WARNING: Could not extract doc coverage (rc={rc})": {
"bg": " WARNING: Could not extract doc coverage (rc={rc})",
"de": " WARNING: Could not extract doc coverage (rc={rc})",
"en": " WARNING: Could not extract doc coverage (rc={rc})",
"pl": " WARNING: Could not extract doc coverage (rc={rc})",
"ru": " WARNING: Could not extract doc coverage (rc={rc})",
"zh": " WARNING: Could not extract doc coverage (rc={rc})"
},
" WARNING: Could not extract test count from pytest output (rc={rc})": {
"bg": " WARNING: Could not extract test count from pytest output (rc={rc})",
"de": " WARNING: Could not extract test count from pytest output (rc={rc})",
"en": " WARNING: Could not extract test count from pytest output (rc={rc})",
"pl": " WARNING: Could not extract test count from pytest output (rc={rc})",
"ru": " WARNING: Could not extract test count from pytest output (rc={rc})",
"zh": " WARNING: Could not extract test count from pytest output (rc={rc})"
},
" WARNING: No Python package found under src/ — version badge will show 'unknown'": {
"bg": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
"de": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
"en": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
"pl": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
"ru": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
"zh": " WARNING: No Python package found under src/ — version badge will show 'unknown'"
},
" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'": {
"bg": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
"de": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
"en": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
"pl": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
"ru": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
"zh": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'"
},
" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)": {
"bg": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
"de": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
"en": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
"pl": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
"ru": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
"zh": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)"
},
" WARNING: {init_file} not found — version badge will show 'unknown'": {
"bg": " WARNING: {init_file} not found — version badge will show 'unknown'",
"de": " WARNING: {init_file} not found — version badge will show 'unknown'",
"en": " WARNING: {init_file} not found — version badge will show 'unknown'",
"pl": " WARNING: {init_file} not found — version badge will show 'unknown'",
"ru": " WARNING: {init_file} not found — version badge will show 'unknown'",
"zh": " WARNING: {init_file} not found — version badge will show 'unknown'"
},
" WARNING: {name} failed (rc={rc})": {
"bg": " WARNING: {name} failed (rc={rc})",
"de": " WARNING: {name} failed (rc={rc})",
"en": " WARNING: {name} failed (rc={rc})",
"pl": " WARNING: {name} failed (rc={rc})",
"ru": " WARNING: {name} failed (rc={rc})",
"zh": " WARNING: {name} failed (rc={rc})"
},
" WARNING: {name} not installed — skipping (counted as pass)": {
"bg": " WARNING: {name} not installed — skipping (counted as pass)",
"de": " WARNING: {name} not installed — skipping (counted as pass)",
"en": " WARNING: {name} not installed — skipping (counted as pass)",
"pl": " WARNING: {name} not installed — skipping (counted as pass)",
"ru": " WARNING: {name} not installed — skipping (counted as pass)",
"zh": " WARNING: {name} not installed — skipping (counted as pass)"
},
" [dry-run] Would delete: {version}": {
"bg": " [dry-run] Would delete: {version}",
"de": " [dry-run] Would delete: {version}",
"en": " [dry-run] Would delete: {version}",
"pl": " [dry-run] Would delete: {version}",
"ru": " [dry-run] Would delete: {version}",
"zh": " [dry-run] Would delete: {version}"
},
" {name}: {label}={message} ({color})": {
"bg": " {name}: {label}={message} ({color})",
"de": " {name}: {label}={message} ({color})",
"en": " {name}: {label}={message} ({color})",
"pl": " {name}: {label}={message} ({color})",
"ru": " {name}: {label}={message} ({color})",
"zh": " {name}: {label}={message} ({color})"
},
" {version} (created: {created})": {
"bg": " {version} (created: {created})",
"de": " {version} (created: {created})",
"en": " {version} (created: {created})",
"pl": " {version} (created: {created})",
"ru": " {version} (created: {created})",
"zh": " {version} (created: {created})"
},
"--checklist-categories must list at least 8 of 13 categories. Got {count}.": {
"en": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"bg": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"de": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"pl": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"ru": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"zh": "--checklist-categories must list at least 8 of 13 categories. Got {count}."
},
"--checklist-confirmed is required for APPROVE events.": {
"en": "--checklist-confirmed is required for APPROVE events.",
"bg": "--checklist-confirmed is required for APPROVE events.",
"de": "--checklist-confirmed is required for APPROVE events.",
"pl": "--checklist-confirmed is required for APPROVE events.",
"ru": "--checklist-confirmed is required for APPROVE events.",
"zh": "--checklist-confirmed is required for APPROVE events."
},
"--push requires --registry": { "--push requires --registry": {
"bg": "--push requires --registry", "bg": "--push requires --registry",
"de": "--push requires --registry", "de": "--push requires --registry",
@@ -375,6 +663,14 @@
"ru": "API poll warning: {exc}", "ru": "API poll warning: {exc}",
"zh": "API poll warning: {exc}" "zh": "API poll warning: {exc}"
}, },
"Added label '{label}' to PR #{pr}.": {
"en": "Added label '{label}' to PR #{pr}.",
"bg": "Added label '{label}' to PR #{pr}.",
"de": "Added label '{label}' to PR #{pr}.",
"pl": "Added label '{label}' to PR #{pr}.",
"ru": "Added label '{label}' to PR #{pr}.",
"zh": "Added label '{label}' to PR #{pr}."
},
"Additional directory to scan (default: scripts, tests). Can be repeated.": { "Additional directory to scan (default: scripts, tests). Can be repeated.": {
"bg": "Additional directory to scan (default: scripts, tests). Can be repeated.", "bg": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"de": "Additional directory to scan (default: scripts, tests). Can be repeated.", "de": "Additional directory to scan (default: scripts, tests). Can be repeated.",
@@ -399,6 +695,54 @@
"ru": "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." "zh": "Another molecule runner failed. Stopping this runner early."
}, },
"Assigned {count} files to runner {runner_index}": {
"bg": "Assigned {count} files to runner {runner_index}",
"de": "Assigned {count} files to runner {runner_index}",
"en": "Assigned {count} files to runner {runner_index}",
"pl": "Assigned {count} files to runner {runner_index}",
"ru": "Assigned {count} files to runner {runner_index}",
"zh": "Assigned {count} files to runner {runner_index}"
},
"Assigned {count} items to runner {runner_index}: {encoded}": {
"bg": "Assigned {count} items to runner {runner_index}: {encoded}",
"de": "Assigned {count} items to runner {runner_index}: {encoded}",
"en": "Assigned {count} items to runner {runner_index}: {encoded}",
"pl": "Assigned {count} items to runner {runner_index}: {encoded}",
"ru": "Assigned {count} items to runner {runner_index}: {encoded}",
"zh": "Assigned {count} items to runner {runner_index}: {encoded}"
},
"Badge push attempt {attempt}/{retries} failed — retrying: {error}": {
"bg": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
"de": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
"en": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
"pl": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
"ru": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
"zh": "Badge push attempt {attempt}/{retries} failed — retrying: {error}"
},
"Badge push failed after {retries} attempts: {error}": {
"bg": "Badge push failed after {retries} attempts: {error}",
"de": "Badge push failed after {retries} attempts: {error}",
"en": "Badge push failed after {retries} attempts: {error}",
"pl": "Badge push failed after {retries} attempts: {error}",
"ru": "Badge push failed after {retries} attempts: {error}",
"zh": "Badge push failed after {retries} attempts: {error}"
},
"Badges commit SHA: {sha}": {
"bg": "Badges commit SHA: {sha}",
"de": "Badges commit SHA: {sha}",
"en": "Badges commit SHA: {sha}",
"pl": "Badges commit SHA: {sha}",
"ru": "Badges commit SHA: {sha}",
"zh": "Badges commit SHA: {sha}"
},
"Badges pushed to badges branch": {
"bg": "Badges pushed to badges branch",
"de": "Badges pushed to badges branch",
"en": "Badges pushed to badges branch",
"pl": "Badges pushed to badges branch",
"ru": "Badges pushed to badges branch",
"zh": "Badges pushed to badges branch"
},
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description": { "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description": {
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание", "bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание",
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung", "de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung",
@@ -463,6 +807,54 @@
"ru": "Bumping version: {current} -> v{new_version}", "ru": "Bumping version: {current} -> v{new_version}",
"zh": "Bumping version: {current} -> v{new_version}" "zh": "Bumping version: {current} -> v{new_version}"
}, },
"CI checks did not complete within timeout.": {
"en": "CI checks did not complete within timeout.",
"bg": "CI checks did not complete within timeout.",
"de": "CI checks did not complete within timeout.",
"pl": "CI checks did not complete within timeout.",
"ru": "CI checks did not complete within timeout.",
"zh": "CI checks did not complete within timeout."
},
"CI checks failed.": {
"en": "CI checks failed.",
"bg": "CI checks failed.",
"de": "CI checks failed.",
"pl": "CI checks failed.",
"ru": "CI checks failed.",
"zh": "CI checks failed."
},
"CI_GITEA_TOKEN environment variable required": {
"bg": "CI_GITEA_TOKEN environment variable required",
"de": "CI_GITEA_TOKEN environment variable required",
"en": "CI_GITEA_TOKEN environment variable required",
"pl": "CI_GITEA_TOKEN environment variable required",
"ru": "CI_GITEA_TOKEN environment variable required",
"zh": "CI_GITEA_TOKEN environment variable required"
},
"CI_GITEA_TOKEN is not set.": {
"en": "CI_GITEA_TOKEN is not set.",
"bg": "CI_GITEA_TOKEN is not set.",
"de": "CI_GITEA_TOKEN is not set.",
"pl": "CI_GITEA_TOKEN is not set.",
"ru": "CI_GITEA_TOKEN is not set.",
"zh": "CI_GITEA_TOKEN is not set."
},
"CI_GITEA_TOKEN is not set. Required to create a PR.": {
"bg": "CI_GITEA_TOKEN не е зададен. Необходим за създаване на PR.",
"de": "CI_GITEA_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
"en": "CI_GITEA_TOKEN is not set. Required to create a PR.",
"pl": "CI_GITEA_TOKEN nie jest ustawiony. Wymagany do utworzenia PR.",
"ru": "CI_GITEA_TOKEN не установлен. Требуется для создания PR.",
"zh": "CI_GITEA_TOKEN 未设置。创建 PR 所需。"
},
"CI_GITEA_TOKEN not set — skipping login configuration.": {
"bg": "CI_GITEA_TOKEN not set — skipping login configuration.",
"de": "CI_GITEA_TOKEN not set — skipping login configuration.",
"en": "CI_GITEA_TOKEN not set — skipping login configuration.",
"pl": "CI_GITEA_TOKEN not set — skipping login configuration.",
"ru": "CI_GITEA_TOKEN not set — skipping login configuration.",
"zh": "CI_GITEA_TOKEN not set — skipping login configuration."
},
"Checking CLI command documentation...": { "Checking CLI command documentation...": {
"bg": "Checking CLI command documentation...", "bg": "Checking CLI command documentation...",
"de": "Checking CLI command documentation...", "de": "Checking CLI command documentation...",
@@ -471,6 +863,14 @@
"ru": "Checking CLI command documentation...", "ru": "Checking CLI command documentation...",
"zh": "Checking CLI command documentation..." "zh": "Checking CLI command documentation..."
}, },
"Checking status for PR #{pr_number}...": {
"en": "Checking status for PR #{pr_number}...",
"bg": "Checking status for PR #{pr_number}...",
"de": "Checking status for PR #{pr_number}...",
"pl": "Checking status for PR #{pr_number}...",
"ru": "Checking status for PR #{pr_number}...",
"zh": "Checking status for PR #{pr_number}..."
},
"Command failed ({cmd}): {stderr}": { "Command failed ({cmd}): {stderr}": {
"bg": "Command failed ({cmd}): {stderr}", "bg": "Command failed ({cmd}): {stderr}",
"de": "Command failed ({cmd}): {stderr}", "de": "Command failed ({cmd}): {stderr}",
@@ -479,6 +879,22 @@
"ru": "Command failed ({cmd}): {stderr}", "ru": "Command failed ({cmd}): {stderr}",
"zh": "Command failed ({cmd}): {stderr}" "zh": "Command failed ({cmd}): {stderr}"
}, },
"Commit message: {msg}": {
"bg": "Commit message: {msg}",
"de": "Commit message: {msg}",
"en": "Commit message: {msg}",
"pl": "Commit message: {msg}",
"ru": "Commit message: {msg}",
"zh": "Commit message: {msg}"
},
"Commit: {sha}": {
"en": "Commit: {sha}",
"bg": "Commit: {sha}",
"de": "Commit: {sha}",
"pl": "Commit: {sha}",
"ru": "Commit: {sha}",
"zh": "Commit: {sha}"
},
"Comparing {base}..{head} ({count} files changed)": { "Comparing {base}..{head} ({count} files changed)": {
"bg": "Comparing {base}..{head} ({count} files changed)", "bg": "Comparing {base}..{head} ({count} files changed)",
"de": "Comparing {base}..{head} ({count} files changed)", "de": "Comparing {base}..{head} ({count} files changed)",
@@ -495,6 +911,14 @@
"ru": "Конфигурация OK: [tool.devx] присутствует, версии devx согласованы.", "ru": "Конфигурация OK: [tool.devx] присутствует, версии devx согласованы.",
"zh": "配置正常: [tool.devx] 已存在, devx 版本一致。" "zh": "配置正常: [tool.devx] 已存在, devx 版本一致。"
}, },
"Configuration validation failed.": {
"en": "Configuration validation failed.",
"bg": "Configuration validation failed.",
"de": "Configuration validation failed.",
"pl": "Configuration validation failed.",
"ru": "Configuration validation failed.",
"zh": "Configuration validation failed."
},
"Configuring branch protection for {branch}...": { "Configuring branch protection for {branch}...": {
"bg": "Конфигуриране на защита на клона {branch}...", "bg": "Конфигуриране на защита на клона {branch}...",
"de": "Konfiguriere Branch-Schutz für {branch}...", "de": "Konfiguriere Branch-Schutz für {branch}...",
@@ -511,6 +935,14 @@
"ru": "Настройка параметров репозитория...", "ru": "Настройка параметров репозитория...",
"zh": "正在配置仓库设置..." "zh": "正在配置仓库设置..."
}, },
"Configuring tea login '{name}' for {url}...": {
"bg": "Configuring tea login '{name}' for {url}...",
"de": "Configuring tea login '{name}' for {url}...",
"en": "Configuring tea login '{name}' for {url}...",
"pl": "Configuring tea login '{name}' for {url}...",
"ru": "Configuring tea login '{name}' for {url}...",
"zh": "Configuring tea login '{name}' for {url}..."
},
"Could not detect current branch: {error}": { "Could not detect current branch: {error}": {
"bg": "Не може да се определи текущия клон: {error}", "bg": "Не може да се определи текущия клон: {error}",
"de": "Aktueller Branch konnte nicht erkannt werden: {error}", "de": "Aktueller Branch konnte nicht erkannt werden: {error}",
@@ -519,6 +951,14 @@
"ru": "Не удалось определить текущую ветку: {error}", "ru": "Не удалось определить текущую ветку: {error}",
"zh": "无法检测当前分支: {error}" "zh": "无法检测当前分支: {error}"
}, },
"Could not determine head SHA for PR #{pr_number}.": {
"en": "Could not determine head SHA for PR #{pr_number}.",
"bg": "Could not determine head SHA for PR #{pr_number}.",
"de": "Could not determine head SHA for PR #{pr_number}.",
"pl": "Could not determine head SHA for PR #{pr_number}.",
"ru": "Could not determine head SHA for PR #{pr_number}.",
"zh": "Could not determine head SHA for PR #{pr_number}."
},
"Could not extract conventional commit message from PR commits.": { "Could not extract conventional commit message from PR commits.": {
"bg": "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.", "de": "Could not extract conventional commit message from PR commits.",
@@ -687,6 +1127,14 @@
"ru": "ERROR: mapping.json not found at {path}", "ru": "ERROR: mapping.json not found at {path}",
"zh": "ERROR: mapping.json not found at {path}" "zh": "ERROR: mapping.json not found at {path}"
}, },
"Each item must be a string or an object with 'id', got {type}": {
"bg": "Всеки елемент трябва да е низ или обект с 'id', получено {type}",
"de": "Jedes Element muss ein String oder ein Objekt mit 'id' sein, erhalten {type}",
"en": "Each item must be a string or an object with 'id', got {type}",
"pl": "Każdy element musi być ciągiem lub obiektem z 'id', otrzymano {type}",
"ru": "Каждый элемент должен быть строкой или объектом с 'id', получено {type}",
"zh": "每个元素必须是字符串或带有 'id' 的对象,得到 {type}"
},
"FAILED: {count} undocumented dependency/ies": { "FAILED: {count} undocumented dependency/ies": {
"bg": "FAILED: {count} undocumented dependency/ies", "bg": "FAILED: {count} undocumented dependency/ies",
"de": "FAILED: {count} undocumented dependency/ies", "de": "FAILED: {count} undocumented dependency/ies",
@@ -719,6 +1167,14 @@
"ru": "Failed to create issue via tea: {error}", "ru": "Failed to create issue via tea: {error}",
"zh": "Failed to create issue via tea: {error}" "zh": "Failed to create issue via tea: {error}"
}, },
"Failed to delete {count} image version(s)": {
"bg": "Failed to delete {count} image version(s)",
"de": "Failed to delete {count} image version(s)",
"en": "Failed to delete {count} image version(s)",
"pl": "Failed to delete {count} image version(s)",
"ru": "Failed to delete {count} image version(s)",
"zh": "Failed to delete {count} image version(s)"
},
"Failed to list versions for {name}: {error}": { "Failed to list versions for {name}: {error}": {
"bg": "Failed to list versions for {name}: {error}", "bg": "Failed to list versions for {name}: {error}",
"de": "Failed to list versions for {name}: {error}", "de": "Failed to list versions for {name}: {error}",
@@ -727,6 +1183,14 @@
"ru": "Failed to list versions for {name}: {error}", "ru": "Failed to list versions for {name}: {error}",
"zh": "Failed to list versions for {name}: {error}" "zh": "Failed to list versions for {name}: {error}"
}, },
"Fetching logs for PR #{pr_number}...": {
"en": "Fetching logs for PR #{pr_number}...",
"bg": "Fetching logs for PR #{pr_number}...",
"de": "Fetching logs for PR #{pr_number}...",
"pl": "Fetching logs for PR #{pr_number}...",
"ru": "Fetching logs for PR #{pr_number}...",
"zh": "Fetching logs for PR #{pr_number}..."
},
"Found {count} existing wiki pages.": { "Found {count} existing wiki pages.": {
"bg": "Found {count} existing wiki pages.", "bg": "Found {count} existing wiki pages.",
"de": "Found {count} existing wiki pages.", "de": "Found {count} existing wiki pages.",
@@ -751,6 +1215,14 @@
"ru": "Found {count} stale documentation reference(s)", "ru": "Found {count} stale documentation reference(s)",
"zh": "Found {count} stale documentation reference(s)" "zh": "Found {count} stale documentation reference(s)"
}, },
"Found {count} version(s):": {
"bg": "Found {count} version(s):",
"de": "Found {count} version(s):",
"en": "Found {count} version(s):",
"pl": "Found {count} version(s):",
"ru": "Found {count} version(s):",
"zh": "Found {count} version(s):"
},
"GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.": { "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
"bg": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "bg": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"de": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "de": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
@@ -759,6 +1231,14 @@
"ru": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "ru": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"zh": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation." "zh": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."
}, },
"Generated {count} badge files": {
"bg": "Generated {count} badge files",
"de": "Generated {count} badge files",
"en": "Generated {count} badge files",
"pl": "Generated {count} badge files",
"ru": "Generated {count} badge files",
"zh": "Generated {count} badge files"
},
"Generated {file} with prefix '{prefix}'.": { "Generated {file} with prefix '{prefix}'.": {
"bg": "Generated {file} with prefix '{prefix}'.", "bg": "Generated {file} with prefix '{prefix}'.",
"de": "Generated {file} with prefix '{prefix}'.", "de": "Generated {file} with prefix '{prefix}'.",
@@ -767,6 +1247,14 @@
"ru": "Generated {file} with prefix '{prefix}'.", "ru": "Generated {file} with prefix '{prefix}'.",
"zh": "Generated {file} with prefix '{prefix}'." "zh": "Generated {file} with prefix '{prefix}'."
}, },
"Generating badges in {out}...": {
"bg": "Generating badges in {out}...",
"de": "Generating badges in {out}...",
"en": "Generating badges in {out}...",
"pl": "Generating badges in {out}...",
"ru": "Generating badges in {out}...",
"zh": "Generating badges in {out}..."
},
"Gitea PyPI registry: {tag} already published — continuing.": { "Gitea PyPI registry: {tag} already published — continuing.": {
"bg": "Gitea PyPI registry: {tag} вече е публикуван — продължава.", "bg": "Gitea PyPI registry: {tag} вече е публикуван — продължава.",
"de": "Gitea PyPI-Registry: {tag} bereits veröffentlicht — wird fortgesetzt.", "de": "Gitea PyPI-Registry: {tag} bereits veröffentlicht — wird fortgesetzt.",
@@ -895,6 +1383,38 @@
"ru": "Integration tests passed.", "ru": "Integration tests passed.",
"zh": "Integration tests passed." "zh": "Integration tests passed."
}, },
"Invalid checklist category: {cat}. Must be numbers.": {
"en": "Invalid checklist category: {cat}. Must be numbers.",
"bg": "Invalid checklist category: {cat}. Must be numbers.",
"de": "Invalid checklist category: {cat}. Must be numbers.",
"pl": "Invalid checklist category: {cat}. Must be numbers.",
"ru": "Invalid checklist category: {cat}. Must be numbers.",
"zh": "Invalid checklist category: {cat}. Must be numbers."
},
"Items input must be a JSON array, got {type}": {
"bg": "Входните данни трябва да са JSON масив, получено {type}",
"de": "Eingabe muss ein JSON-Array sein, erhalten {type}",
"en": "Items input must be a JSON array, got {type}",
"pl": "Dane wejściowe muszą być tablicą JSON, otrzymano {type}",
"ru": "Входные данные должны быть JSON-массивом, получено {type}",
"zh": "输入必须是 JSON 数组,得到 {type}"
},
"Label '{label}' already on PR #{pr}.": {
"en": "Label '{label}' already on PR #{pr}.",
"bg": "Label '{label}' already on PR #{pr}.",
"de": "Label '{label}' already on PR #{pr}.",
"pl": "Label '{label}' already on PR #{pr}.",
"ru": "Label '{label}' already on PR #{pr}.",
"zh": "Label '{label}' already on PR #{pr}."
},
"Latest run: #{run_id} (status: {status})": {
"en": "Latest run: #{run_id} (status: {status})",
"bg": "Latest run: #{run_id} (status: {status})",
"de": "Latest run: #{run_id} (status: {status})",
"pl": "Latest run: #{run_id} (status: {status})",
"ru": "Latest run: #{run_id} (status: {status})",
"zh": "Latest run: #{run_id} (status: {status})"
},
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}": { "Lint failed — refusing to release. Fix lint errors first.\n{stderr}": {
"bg": "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}", "de": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
@@ -951,6 +1471,14 @@
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.", "ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。" "zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
}, },
"Missing tests for changed files.": {
"en": "Missing tests for changed files.",
"bg": "Missing tests for changed files.",
"de": "Missing tests for changed files.",
"pl": "Missing tests for changed files.",
"ru": "Missing tests for changed files.",
"zh": "Missing tests for changed files."
},
"Module {mod} has no main() function": { "Module {mod} has no main() function": {
"bg": "Модул {mod} няма функция main()", "bg": "Модул {mod} няма функция main()",
"de": "Modul {mod} hat keine main()-Funktion", "de": "Modul {mod} hat keine main()-Funktion",
@@ -1007,6 +1535,30 @@
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.", "ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。" "zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
}, },
"No CI checks found for commit {sha}.": {
"en": "No CI checks found for commit {sha}.",
"bg": "No CI checks found for commit {sha}.",
"de": "No CI checks found for commit {sha}.",
"pl": "No CI checks found for commit {sha}.",
"ru": "No CI checks found for commit {sha}.",
"zh": "No CI checks found for commit {sha}."
},
"No badge SVG files generated": {
"bg": "No badge SVG files generated",
"de": "No badge SVG files generated",
"en": "No badge SVG files generated",
"pl": "No badge SVG files generated",
"ru": "No badge SVG files generated",
"zh": "No badge SVG files generated"
},
"No badge URLs found to update — README already up to date": {
"bg": "No badge URLs found to update — README already up to date",
"de": "No badge URLs found to update — README already up to date",
"en": "No badge URLs found to update — README already up to date",
"pl": "No badge URLs found to update — README already up to date",
"ru": "No badge URLs found to update — README already up to date",
"zh": "No badge URLs found to update — README already up to date"
},
"No changes between {base} and {head}.": { "No changes between {base} and {head}.": {
"bg": "No changes between {base} and {head}.", "bg": "No changes between {base} and {head}.",
"de": "No changes between {base} and {head}.", "de": "No changes between {base} and {head}.",
@@ -1015,6 +1567,38 @@
"ru": "No changes between {base} and {head}.", "ru": "No changes between {base} and {head}.",
"zh": "No changes between {base} and {head}." "zh": "No changes between {base} and {head}."
}, },
"No failed jobs.": {
"en": "No failed jobs.",
"bg": "No failed jobs.",
"de": "No failed jobs.",
"pl": "No failed jobs.",
"ru": "No failed jobs.",
"zh": "No failed jobs."
},
"No job matching '{job}' found.": {
"en": "No job matching '{job}' found.",
"bg": "No job matching '{job}' found.",
"de": "No job matching '{job}' found.",
"pl": "No job matching '{job}' found.",
"ru": "No job matching '{job}' found.",
"zh": "No job matching '{job}' found."
},
"No jobs found for run #{run_id}.": {
"en": "No jobs found for run #{run_id}.",
"bg": "No jobs found for run #{run_id}.",
"de": "No jobs found for run #{run_id}.",
"pl": "No jobs found for run #{run_id}.",
"ru": "No jobs found for run #{run_id}.",
"zh": "No jobs found for run #{run_id}."
},
"No open PR found for branch '{branch}'.": {
"en": "No open PR found for branch '{branch}'.",
"bg": "No open PR found for branch '{branch}'.",
"de": "No open PR found for branch '{branch}'.",
"pl": "No open PR found for branch '{branch}'.",
"ru": "No open PR found for branch '{branch}'.",
"zh": "No open PR found for branch '{branch}'."
},
"No staged changes — version and changelog already up to date.": { "No staged changes — version and changelog already up to date.": {
"bg": "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.", "de": "No staged changes — version and changelog already up to date.",
@@ -1079,6 +1663,14 @@
"ru": "No versions found.", "ru": "No versions found.",
"zh": "No versions found." "zh": "No versions found."
}, },
"No workflow runs found for SHA {sha}.": {
"en": "No workflow runs found for SHA {sha}.",
"bg": "No workflow runs found for SHA {sha}.",
"de": "No workflow runs found for SHA {sha}.",
"pl": "No workflow runs found for SHA {sha}.",
"ru": "No workflow runs found for SHA {sha}.",
"zh": "No workflow runs found for SHA {sha}."
},
"Note: Self-approval not allowed. Posting COMMENT instead.": { "Note: Self-approval not allowed. Posting COMMENT instead.": {
"bg": "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.", "de": "Note: Self-approval not allowed. Posting COMMENT instead.",
@@ -1255,6 +1847,22 @@
"ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.", "ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
"zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。" "zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
}, },
"Package owner not specified. Use --owner or set [tool.devx] repo_owner.": {
"en": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"bg": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"de": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"pl": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"ru": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"zh": "Package owner not specified. Use --owner or set [tool.devx] repo_owner."
},
"Package: {owner}/{name}": {
"bg": "Package: {owner}/{name}",
"de": "Package: {owner}/{name}",
"en": "Package: {owner}/{name}",
"pl": "Package: {owner}/{name}",
"ru": "Package: {owner}/{name}",
"zh": "Package: {owner}/{name}"
},
"Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME": { "Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME": {
"bg": "Разбор на owner={owner}, repo={repo} от DEVX_REPO_NAME", "bg": "Разбор на owner={owner}, repo={repo} от DEVX_REPO_NAME",
"de": "Owner={owner}, repo={repo} aus DEVX_REPO_NAME analysiert", "de": "Owner={owner}, repo={repo} aus DEVX_REPO_NAME analysiert",
@@ -1351,6 +1959,14 @@
"ru": "Push failed for {tag}: {error}", "ru": "Push failed for {tag}: {error}",
"zh": "Push failed for {tag}: {error}" "zh": "Push failed for {tag}: {error}"
}, },
"Pushed README update with badge SHA {sha}": {
"bg": "Pushed README update with badge SHA {sha}",
"de": "Pushed README update with badge SHA {sha}",
"en": "Pushed README update with badge SHA {sha}",
"pl": "Pushed README update with badge SHA {sha}",
"ru": "Pushed README update with badge SHA {sha}",
"zh": "Pushed README update with badge SHA {sha}"
},
"Pushed release commit to master.": { "Pushed release commit to master.": {
"bg": "Pushed release commit to master.", "bg": "Pushed release commit to master.",
"de": "Pushed release commit to master.", "de": "Pushed release commit to master.",
@@ -1375,30 +1991,6 @@
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).", "ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)." "zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
}, },
"CI_GITEA_TOKEN environment variable required": {
"bg": "CI_GITEA_TOKEN environment variable required",
"de": "CI_GITEA_TOKEN environment variable required",
"en": "CI_GITEA_TOKEN environment variable required",
"pl": "CI_GITEA_TOKEN environment variable required",
"ru": "CI_GITEA_TOKEN environment variable required",
"zh": "CI_GITEA_TOKEN environment variable required"
},
"Failed to delete {count} image version(s)": {
"bg": "Failed to delete {count} image version(s)",
"de": "Failed to delete {count} image version(s)",
"en": "Failed to delete {count} image version(s)",
"pl": "Failed to delete {count} image version(s)",
"ru": "Failed to delete {count} image version(s)",
"zh": "Failed to delete {count} image version(s)"
},
"CI_GITEA_TOKEN is not set. Required to create a PR.": {
"bg": "CI_GITEA_TOKEN не е зададен. Необходим за създаване на PR.",
"de": "CI_GITEA_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
"en": "CI_GITEA_TOKEN is not set. Required to create a PR.",
"pl": "CI_GITEA_TOKEN nie jest ustawiony. Wymagany do utworzenia PR.",
"ru": "CI_GITEA_TOKEN не установлен. Требуется для создания PR.",
"zh": "CI_GITEA_TOKEN 未设置。创建 PR 所需。"
},
"Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars": { "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars": {
"bg": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars", "bg": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
"de": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars", "de": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
@@ -1423,6 +2015,22 @@
"ru": "Registry login failed: {error}", "ru": "Registry login failed: {error}",
"zh": "Registry login failed: {error}" "zh": "Registry login failed: {error}"
}, },
"Regular merge commit — running all post-merge jobs.": {
"bg": "Regular merge commit — running all post-merge jobs.",
"de": "Regular merge commit — running all post-merge jobs.",
"en": "Regular merge commit — running all post-merge jobs.",
"pl": "Regular merge commit — running all post-merge jobs.",
"ru": "Regular merge commit — running all post-merge jobs.",
"zh": "Regular merge commit — running all post-merge jobs."
},
"Release commit — skipping all post-merge jobs.": {
"bg": "Release commit — skipping all post-merge jobs.",
"de": "Release commit — skipping all post-merge jobs.",
"en": "Release commit — skipping all post-merge jobs.",
"pl": "Release commit — skipping all post-merge jobs.",
"ru": "Release commit — skipping all post-merge jobs.",
"zh": "Release commit — skipping all post-merge jobs."
},
"Release creation failed: {error}": { "Release creation failed: {error}": {
"bg": "Release creation failed: {error}", "bg": "Release creation failed: {error}",
"de": "Release creation failed: {error}", "de": "Release creation failed: {error}",
@@ -1463,6 +2071,14 @@
"ru": "Repository in owner/name format", "ru": "Repository in owner/name format",
"zh": "Repository in owner/name format" "zh": "Repository in owner/name format"
}, },
"Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.": {
"en": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"bg": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"de": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"pl": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"ru": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"zh": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var."
},
"Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.": { "Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.": {
"bg": "Собственикът на хранилището не е зададен. Използвайте --owner или DEVX_REPO_OWNER env var.", "bg": "Собственикът на хранилището не е зададен. Използвайте --owner или DEVX_REPO_OWNER env var.",
"de": "Repository-Owner nicht gesetzt. Verwende --owner oder DEVX_REPO_OWNER env var.", "de": "Repository-Owner nicht gesetzt. Verwende --owner oder DEVX_REPO_OWNER env var.",
@@ -1471,6 +2087,14 @@
"ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.", "ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.",
"zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。" "zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。"
}, },
"Review body must be at least 50 characters.": {
"en": "Review body must be at least 50 characters.",
"bg": "Review body must be at least 50 characters.",
"de": "Review body must be at least 50 characters.",
"pl": "Review body must be at least 50 characters.",
"ru": "Review body must be at least 50 characters.",
"zh": "Review body must be at least 50 characters."
},
"Roles directory not found: {path}": { "Roles directory not found: {path}": {
"bg": "Roles directory not found: {path}", "bg": "Roles directory not found: {path}",
"de": "Roles directory not found: {path}", "de": "Roles directory not found: {path}",
@@ -1479,6 +2103,14 @@
"ru": "Roles directory not found: {path}", "ru": "Roles directory not found: {path}",
"zh": "Roles directory not found: {path}" "zh": "Roles directory not found: {path}"
}, },
"Runner count: {count}": {
"bg": "Runner count: {count}",
"de": "Runner count: {count}",
"en": "Runner count: {count}",
"pl": "Runner count: {count}",
"ru": "Runner count: {count}",
"zh": "Runner count: {count}"
},
"Runner index {index} out of range (0..{max})": { "Runner index {index} out of range (0..{max})": {
"bg": "Индексът на runner {index} е извън диапазона (0..{max})", "bg": "Индексът на runner {index} е извън диапазона (0..{max})",
"de": "Runner-Index {index} außerhalb des Bereichs (0..{max})", "de": "Runner-Index {index} außerhalb des Bereichs (0..{max})",
@@ -1487,6 +2119,30 @@
"ru": "Индекс runner {index} вне диапазона (0..{max})", "ru": "Индекс runner {index} вне диапазона (0..{max})",
"zh": "Runner 索引 {index} 超出范围 (0..{max})" "zh": "Runner 索引 {index} 超出范围 (0..{max})"
}, },
"Runner index {runner_index} is out of range (must be >= 1)": {
"bg": "Runner index {runner_index} is out of range (must be >= 1)",
"de": "Runner index {runner_index} is out of range (must be >= 1)",
"en": "Runner index {runner_index} is out of range (must be >= 1)",
"pl": "Runner index {runner_index} is out of range (must be >= 1)",
"ru": "Runner index {runner_index} is out of range (must be >= 1)",
"zh": "Runner index {runner_index} is out of range (must be >= 1)"
},
"Runner indices: {indices}": {
"bg": "Runner indices: {indices}",
"de": "Runner indices: {indices}",
"en": "Runner indices: {indices}",
"pl": "Runner indices: {indices}",
"ru": "Runner indices: {indices}",
"zh": "Runner indices: {indices}"
},
"Runner {i}: {labels}": {
"bg": "Runner {i}: {labels}",
"de": "Runner {i}: {labels}",
"en": "Runner {i}: {labels}",
"pl": "Runner {i}: {labels}",
"ru": "Runner {i}: {labels}",
"zh": "Runner {i}: {labels}"
},
"Running lint checks...": { "Running lint checks...": {
"bg": "Running lint checks...", "bg": "Running lint checks...",
"de": "Running lint checks...", "de": "Running lint checks...",
@@ -1503,6 +2159,14 @@
"ru": "Running tests...", "ru": "Running tests...",
"zh": "Running tests..." "zh": "Running tests..."
}, },
"Running: {cmd}": {
"bg": "Running: {cmd}",
"de": "Running: {cmd}",
"en": "Running: {cmd}",
"pl": "Running: {cmd}",
"ru": "Running: {cmd}",
"zh": "Running: {cmd}"
},
"Running: {scenario} on {platform}": { "Running: {scenario} on {platform}": {
"bg": "Running: {scenario} on {platform}", "bg": "Running: {scenario} on {platform}",
"de": "Running: {scenario} on {platform}", "de": "Running: {scenario} on {platform}",
@@ -1535,6 +2199,22 @@
"ru": "Skipping commit push — no staged changes.", "ru": "Skipping commit push — no staged changes.",
"zh": "Skipping commit push — no staged changes." "zh": "Skipping commit push — no staged changes."
}, },
"Skipping — runner index {runner_index} > max runners {max_runners}": {
"bg": "Skipping — runner index {runner_index} > max runners {max_runners}",
"de": "Skipping — runner index {runner_index} > max runners {max_runners}",
"en": "Skipping — runner index {runner_index} > max runners {max_runners}",
"pl": "Skipping — runner index {runner_index} > max runners {max_runners}",
"ru": "Skipping — runner index {runner_index} > max runners {max_runners}",
"zh": "Skipping — runner index {runner_index} > max runners {max_runners}"
},
"Synced to latest origin/{branch}": {
"bg": "Synced to latest origin/{branch}",
"de": "Synced to latest origin/{branch}",
"en": "Synced to latest origin/{branch}",
"pl": "Synced to latest origin/{branch}",
"ru": "Synced to latest origin/{branch}",
"zh": "Synced to latest origin/{branch}"
},
"Syncing {count} documentation pages to wiki...": { "Syncing {count} documentation pages to wiki...": {
"bg": "Syncing {count} documentation pages to wiki...", "bg": "Syncing {count} documentation pages to wiki...",
"de": "Syncing {count} documentation pages to wiki...", "de": "Syncing {count} documentation pages to wiki...",
@@ -1615,6 +2295,14 @@
"ru": "Tests passed.", "ru": "Tests passed.",
"zh": "Tests passed." "zh": "Tests passed."
}, },
"Timeout reached after {timeout}s.": {
"en": "Timeout reached after {timeout}s.",
"bg": "Timeout reached after {timeout}s.",
"de": "Timeout reached after {timeout}s.",
"pl": "Timeout reached after {timeout}s.",
"ru": "Timeout reached after {timeout}s.",
"zh": "Timeout reached after {timeout}s."
},
"Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).": { "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).": {
"bg": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).", "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).", "de": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
@@ -1639,6 +2327,14 @@
"ru": "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}" "zh": "Unknown check category '{check}'. Available: all, user-facing{tags}"
}, },
"Updated badge URLs in {filename}": {
"bg": "Updated badge URLs in {filename}",
"de": "Updated badge URLs in {filename}",
"en": "Updated badge URLs in {filename}",
"pl": "Updated badge URLs in {filename}",
"ru": "Updated badge URLs in {filename}",
"zh": "Updated badge URLs in {filename}"
},
"Updated version in {init}": { "Updated version in {init}": {
"bg": "Updated version in {init}", "bg": "Updated version in {init}",
"de": "Updated version in {init}", "de": "Updated version in {init}",
@@ -1687,6 +2383,14 @@
"ru": "Version file: {file}", "ru": "Version file: {file}",
"zh": "Version file: {file}" "zh": "Version file: {file}"
}, },
"Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.": {
"bg": "",
"de": "",
"en": "Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.",
"pl": "",
"ru": "",
"zh": ""
},
"Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.": { "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.": {
"bg": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", "bg": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"de": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", "de": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
@@ -1727,6 +2431,14 @@
"ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.", "ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.",
"zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。" "zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。"
}, },
"Waiting for CI checks to complete (timeout: {timeout}s)...": {
"en": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"bg": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"de": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"pl": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"ru": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"zh": "Waiting for CI checks to complete (timeout: {timeout}s)..."
},
"Warning: could not fetch tags from origin.": { "Warning: could not fetch tags from origin.": {
"bg": "Warning: could not fetch tags from origin.", "bg": "Warning: could not fetch tags from origin.",
"de": "Warning: could not fetch tags from origin.", "de": "Warning: could not fetch tags from origin.",
@@ -1735,6 +2447,54 @@
"ru": "Warning: could not fetch tags from origin.", "ru": "Warning: could not fetch tags from origin.",
"zh": "Warning: could not fetch tags from origin." "zh": "Warning: could not fetch tags from origin."
}, },
"Warning: instance-level runners query failed: {error}": {
"bg": "Warning: instance-level runners query failed: {error}",
"de": "Warning: instance-level runners query failed: {error}",
"en": "Warning: instance-level runners query failed: {error}",
"pl": "Warning: instance-level runners query failed: {error}",
"ru": "Warning: instance-level runners query failed: {error}",
"zh": "Warning: instance-level runners query failed: {error}"
},
"Warning: instance-level runners query returned HTTP {status}": {
"bg": "Warning: instance-level runners query returned HTTP {status}",
"de": "Warning: instance-level runners query returned HTTP {status}",
"en": "Warning: instance-level runners query returned HTTP {status}",
"pl": "Warning: instance-level runners query returned HTTP {status}",
"ru": "Warning: instance-level runners query returned HTTP {status}",
"zh": "Warning: instance-level runners query returned HTTP {status}"
},
"Warning: org-level runners query failed: {error}": {
"bg": "Warning: org-level runners query failed: {error}",
"de": "Warning: org-level runners query failed: {error}",
"en": "Warning: org-level runners query failed: {error}",
"pl": "Warning: org-level runners query failed: {error}",
"ru": "Warning: org-level runners query failed: {error}",
"zh": "Warning: org-level runners query failed: {error}"
},
"Warning: org-level runners query returned HTTP {status}": {
"bg": "Warning: org-level runners query returned HTTP {status}",
"de": "Warning: org-level runners query returned HTTP {status}",
"en": "Warning: org-level runners query returned HTTP {status}",
"pl": "Warning: org-level runners query returned HTTP {status}",
"ru": "Warning: org-level runners query returned HTTP {status}",
"zh": "Warning: org-level runners query returned HTTP {status}"
},
"Warning: repo-level runners query failed: {error}": {
"bg": "Warning: repo-level runners query failed: {error}",
"de": "Warning: repo-level runners query failed: {error}",
"en": "Warning: repo-level runners query failed: {error}",
"pl": "Warning: repo-level runners query failed: {error}",
"ru": "Warning: repo-level runners query failed: {error}",
"zh": "Warning: repo-level runners query failed: {error}"
},
"Warning: repo-level runners query returned HTTP {status}": {
"bg": "Warning: repo-level runners query returned HTTP {status}",
"de": "Warning: repo-level runners query returned HTTP {status}",
"en": "Warning: repo-level runners query returned HTTP {status}",
"pl": "Warning: repo-level runners query returned HTTP {status}",
"ru": "Warning: repo-level runners query returned HTTP {status}",
"zh": "Warning: repo-level runners query returned HTTP {status}"
},
"Wiki integrity check failed — {count} issue(s)": { "Wiki integrity check failed — {count} issue(s)": {
"bg": "Wiki integrity check failed — {count} issue(s)", "bg": "Wiki integrity check failed — {count} issue(s)",
"de": "Wiki integrity check failed — {count} issue(s)", "de": "Wiki integrity check failed — {count} issue(s)",
@@ -1871,6 +2631,14 @@
"ru": "завершён", "ru": "завершён",
"zh": "已完成" "zh": "已完成"
}, },
"count={count}": {
"bg": "count={count}",
"de": "count={count}",
"en": "count={count}",
"pl": "count={count}",
"ru": "count={count}",
"zh": "count={count}"
},
"devx version mismatch across extras: {detail}": { "devx version mismatch across extras: {detail}": {
"bg": "несъответствие на версията на devx между extras: {detail}", "bg": "несъответствие на версията на devx между extras: {detail}",
"de": "devx-Versionskonflikt zwischen Extras: {detail}", "de": "devx-Versionskonflikt zwischen Extras: {detail}",
@@ -1935,6 +2703,14 @@
"ru": "неактивен", "ru": "неактивен",
"zh": "未激活" "zh": "未激活"
}, },
"indices={indices}": {
"bg": "indices={indices}",
"de": "indices={indices}",
"en": "indices={indices}",
"pl": "indices={indices}",
"ru": "indices={indices}",
"zh": "indices={indices}"
},
"mapping.json keys and values must be strings, got {k}={v}": { "mapping.json keys and values must be strings, got {k}={v}": {
"bg": "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}", "de": "mapping.json keys and values must be strings, got {k}={v}",
@@ -1967,6 +2743,22 @@
"ru": "pyproject.toml не найден в текущей директории.", "ru": "pyproject.toml не найден в текущей директории.",
"zh": "在当前目录中未找到 pyproject.toml。" "zh": "在当前目录中未找到 pyproject.toml。"
}, },
"tea login '{name}' already configured.": {
"bg": "tea login '{name}' already configured.",
"de": "tea login '{name}' already configured.",
"en": "tea login '{name}' already configured.",
"pl": "tea login '{name}' already configured.",
"ru": "tea login '{name}' already configured.",
"zh": "tea login '{name}' already configured."
},
"tea not installed — skipping login configuration.": {
"bg": "tea not installed — skipping login configuration.",
"de": "tea not installed — skipping login configuration.",
"en": "tea not installed — skipping login configuration.",
"pl": "tea not installed — skipping login configuration.",
"ru": "tea not installed — skipping login configuration.",
"zh": "tea not installed — skipping login configuration."
},
"unknown": { "unknown": {
"bg": "неизвестен", "bg": "неизвестен",
"de": "unbekannt", "de": "unbekannt",
@@ -1983,276 +2775,12 @@
"ru": "{file} already exists. Use --force to overwrite.", "ru": "{file} already exists. Use --force to overwrite.",
"zh": "{file} already exists. Use --force to overwrite." "zh": "{file} already exists. Use --force to overwrite."
}, },
"Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.": { "{separator}": {
"bg": "", "bg": "{separator}",
"de": "", "de": "{separator}",
"en": "Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.", "en": "{separator}",
"pl": "", "pl": "{separator}",
"ru": "", "ru": "{separator}",
"zh": "" "zh": "{separator}"
},
"tea not installed — skipping login configuration.": {
"bg": "tea not installed — skipping login configuration.",
"de": "tea not installed — skipping login configuration.",
"en": "tea not installed — skipping login configuration.",
"pl": "tea not installed — skipping login configuration.",
"ru": "tea not installed — skipping login configuration.",
"zh": "tea not installed — skipping login configuration."
},
"CI_GITEA_TOKEN not set — skipping login configuration.": {
"bg": "CI_GITEA_TOKEN not set — skipping login configuration.",
"de": "CI_GITEA_TOKEN not set — skipping login configuration.",
"en": "CI_GITEA_TOKEN not set — skipping login configuration.",
"pl": "CI_GITEA_TOKEN not set — skipping login configuration.",
"ru": "CI_GITEA_TOKEN not set — skipping login configuration.",
"zh": "CI_GITEA_TOKEN not set — skipping login configuration."
},
"tea login '{name}' already configured.": {
"bg": "tea login '{name}' already configured.",
"de": "tea login '{name}' already configured.",
"en": "tea login '{name}' already configured.",
"pl": "tea login '{name}' already configured.",
"ru": "tea login '{name}' already configured.",
"zh": "tea login '{name}' already configured."
},
"Configuring tea login '{name}' for {url}...": {
"bg": "Configuring tea login '{name}' for {url}...",
"de": "Configuring tea login '{name}' for {url}...",
"en": "Configuring tea login '{name}' for {url}...",
"pl": "Configuring tea login '{name}' for {url}...",
"ru": "Configuring tea login '{name}' for {url}...",
"zh": "Configuring tea login '{name}' for {url}..."
},
" Could not fetch logs: {error}": {
"en": " Could not fetch logs: {error}",
"bg": " Could not fetch logs: {error}",
"de": " Could not fetch logs: {error}",
"pl": " Could not fetch logs: {error}",
"ru": " Could not fetch logs: {error}",
"zh": " Could not fetch logs: {error}"
},
"Added label '{label}' to PR #{pr}.": {
"en": "Added label '{label}' to PR #{pr}.",
"bg": "Added label '{label}' to PR #{pr}.",
"de": "Added label '{label}' to PR #{pr}.",
"pl": "Added label '{label}' to PR #{pr}.",
"ru": "Added label '{label}' to PR #{pr}.",
"zh": "Added label '{label}' to PR #{pr}."
},
"CI checks did not complete within timeout.": {
"en": "CI checks did not complete within timeout.",
"bg": "CI checks did not complete within timeout.",
"de": "CI checks did not complete within timeout.",
"pl": "CI checks did not complete within timeout.",
"ru": "CI checks did not complete within timeout.",
"zh": "CI checks did not complete within timeout."
},
"CI checks failed.": {
"en": "CI checks failed.",
"bg": "CI checks failed.",
"de": "CI checks failed.",
"pl": "CI checks failed.",
"ru": "CI checks failed.",
"zh": "CI checks failed."
},
"CI_GITEA_TOKEN is not set.": {
"en": "CI_GITEA_TOKEN is not set.",
"bg": "CI_GITEA_TOKEN is not set.",
"de": "CI_GITEA_TOKEN is not set.",
"pl": "CI_GITEA_TOKEN is not set.",
"ru": "CI_GITEA_TOKEN is not set.",
"zh": "CI_GITEA_TOKEN is not set."
},
"Checking status for PR #{pr_number}...": {
"en": "Checking status for PR #{pr_number}...",
"bg": "Checking status for PR #{pr_number}...",
"de": "Checking status for PR #{pr_number}...",
"pl": "Checking status for PR #{pr_number}...",
"ru": "Checking status for PR #{pr_number}...",
"zh": "Checking status for PR #{pr_number}..."
},
"Commit: {sha}": {
"en": "Commit: {sha}",
"bg": "Commit: {sha}",
"de": "Commit: {sha}",
"pl": "Commit: {sha}",
"ru": "Commit: {sha}",
"zh": "Commit: {sha}"
},
"Could not determine head SHA for PR #{pr_number}.": {
"en": "Could not determine head SHA for PR #{pr_number}.",
"bg": "Could not determine head SHA for PR #{pr_number}.",
"de": "Could not determine head SHA for PR #{pr_number}.",
"pl": "Could not determine head SHA for PR #{pr_number}.",
"ru": "Could not determine head SHA for PR #{pr_number}.",
"zh": "Could not determine head SHA for PR #{pr_number}."
},
"Fetching logs for PR #{pr_number}...": {
"en": "Fetching logs for PR #{pr_number}...",
"bg": "Fetching logs for PR #{pr_number}...",
"de": "Fetching logs for PR #{pr_number}...",
"pl": "Fetching logs for PR #{pr_number}...",
"ru": "Fetching logs for PR #{pr_number}...",
"zh": "Fetching logs for PR #{pr_number}..."
},
"Label '{label}' already on PR #{pr}.": {
"en": "Label '{label}' already on PR #{pr}.",
"bg": "Label '{label}' already on PR #{pr}.",
"de": "Label '{label}' already on PR #{pr}.",
"pl": "Label '{label}' already on PR #{pr}.",
"ru": "Label '{label}' already on PR #{pr}.",
"zh": "Label '{label}' already on PR #{pr}."
},
"Latest run: #{run_id} (status: {status})": {
"en": "Latest run: #{run_id} (status: {status})",
"bg": "Latest run: #{run_id} (status: {status})",
"de": "Latest run: #{run_id} (status: {status})",
"pl": "Latest run: #{run_id} (status: {status})",
"ru": "Latest run: #{run_id} (status: {status})",
"zh": "Latest run: #{run_id} (status: {status})"
},
"No CI checks found for commit {sha}.": {
"en": "No CI checks found for commit {sha}.",
"bg": "No CI checks found for commit {sha}.",
"de": "No CI checks found for commit {sha}.",
"pl": "No CI checks found for commit {sha}.",
"ru": "No CI checks found for commit {sha}.",
"zh": "No CI checks found for commit {sha}."
},
"No failed jobs.": {
"en": "No failed jobs.",
"bg": "No failed jobs.",
"de": "No failed jobs.",
"pl": "No failed jobs.",
"ru": "No failed jobs.",
"zh": "No failed jobs."
},
"No job matching '{job}' found.": {
"en": "No job matching '{job}' found.",
"bg": "No job matching '{job}' found.",
"de": "No job matching '{job}' found.",
"pl": "No job matching '{job}' found.",
"ru": "No job matching '{job}' found.",
"zh": "No job matching '{job}' found."
},
"No jobs found for run #{run_id}.": {
"en": "No jobs found for run #{run_id}.",
"bg": "No jobs found for run #{run_id}.",
"de": "No jobs found for run #{run_id}.",
"pl": "No jobs found for run #{run_id}.",
"ru": "No jobs found for run #{run_id}.",
"zh": "No jobs found for run #{run_id}."
},
"No open PR found for branch '{branch}'.": {
"en": "No open PR found for branch '{branch}'.",
"bg": "No open PR found for branch '{branch}'.",
"de": "No open PR found for branch '{branch}'.",
"pl": "No open PR found for branch '{branch}'.",
"ru": "No open PR found for branch '{branch}'.",
"zh": "No open PR found for branch '{branch}'."
},
"No workflow runs found for SHA {sha}.": {
"en": "No workflow runs found for SHA {sha}.",
"bg": "No workflow runs found for SHA {sha}.",
"de": "No workflow runs found for SHA {sha}.",
"pl": "No workflow runs found for SHA {sha}.",
"ru": "No workflow runs found for SHA {sha}.",
"zh": "No workflow runs found for SHA {sha}."
},
"Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.": {
"en": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"bg": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"de": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"pl": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"ru": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"zh": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var."
},
"Timeout reached after {timeout}s.": {
"en": "Timeout reached after {timeout}s.",
"bg": "Timeout reached after {timeout}s.",
"de": "Timeout reached after {timeout}s.",
"pl": "Timeout reached after {timeout}s.",
"ru": "Timeout reached after {timeout}s.",
"zh": "Timeout reached after {timeout}s."
},
"Waiting for CI checks to complete (timeout: {timeout}s)...": {
"en": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"bg": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"de": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"pl": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"ru": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"zh": "Waiting for CI checks to complete (timeout: {timeout}s)..."
},
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.": {
"en": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"bg": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"de": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"pl": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"ru": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"zh": "\n[check_test_coverage] Fix: add the missing test file(s) before committing."
},
"Package owner not specified. Use --owner or set [tool.devx] repo_owner.": {
"en": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"bg": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"de": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"pl": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"ru": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"zh": "Package owner not specified. Use --owner or set [tool.devx] repo_owner."
},
"Configuration validation failed.": {
"en": "Configuration validation failed.",
"bg": "Configuration validation failed.",
"de": "Configuration validation failed.",
"pl": "Configuration validation failed.",
"ru": "Configuration validation failed.",
"zh": "Configuration validation failed."
},
"Missing tests for changed files.": {
"en": "Missing tests for changed files.",
"bg": "Missing tests for changed files.",
"de": "Missing tests for changed files.",
"pl": "Missing tests for changed files.",
"ru": "Missing tests for changed files.",
"zh": "Missing tests for changed files."
},
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.": {
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"pl": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'."
},
"--checklist-categories must list at least 8 of 13 categories. Got {count}.": {
"en": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"bg": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"de": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"pl": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"ru": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"zh": "--checklist-categories must list at least 8 of 13 categories. Got {count}."
},
"--checklist-confirmed is required for APPROVE events.": {
"en": "--checklist-confirmed is required for APPROVE events.",
"bg": "--checklist-confirmed is required for APPROVE events.",
"de": "--checklist-confirmed is required for APPROVE events.",
"pl": "--checklist-confirmed is required for APPROVE events.",
"ru": "--checklist-confirmed is required for APPROVE events.",
"zh": "--checklist-confirmed is required for APPROVE events."
},
"Invalid checklist category: {cat}. Must be numbers.": {
"en": "Invalid checklist category: {cat}. Must be numbers.",
"bg": "Invalid checklist category: {cat}. Must be numbers.",
"de": "Invalid checklist category: {cat}. Must be numbers.",
"pl": "Invalid checklist category: {cat}. Must be numbers.",
"ru": "Invalid checklist category: {cat}. Must be numbers.",
"zh": "Invalid checklist category: {cat}. Must be numbers."
},
"Review body must be at least 50 characters.": {
"en": "Review body must be at least 50 characters.",
"bg": "Review body must be at least 50 characters.",
"de": "Review body must be at least 50 characters.",
"pl": "Review body must be at least 50 characters.",
"ru": "Review body must be at least 50 characters.",
"zh": "Review body must be at least 50 characters."
} }
} }
+12 -33
View File
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
import requests import requests
from devx.api_clients import GiteaClient, VikunjaClient, _is_retryable, _parse_error from devx.api_clients import GiteaClient, VikunjaClient, _parse_error
from devx.config import ( from devx.config import (
DEFAULT_PER_PAGE, DEFAULT_PER_PAGE,
DEFAULT_TIMEOUT, DEFAULT_TIMEOUT,
@@ -507,7 +507,7 @@ class TestGiteaClient:
assert result["id"] == 1 assert result["id"] == 1
assert client._session.request.call_count == 2 assert client._session.request.call_count == 2
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_request_retries_on_429(self, mock_sleep: MagicMock) -> None: def test_request_retries_on_429(self, mock_sleep: MagicMock) -> None:
"""Should retry on 429 rate limit with exponential backoff.""" """Should retry on 429 rate limit with exponential backoff."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo") client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
@@ -520,7 +520,7 @@ class TestGiteaClient:
assert client._session.request.call_count == 3 assert client._session.request.call_count == 3
assert mock_sleep.call_count == 2 assert mock_sleep.call_count == 2
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_request_retries_on_503(self, mock_sleep: MagicMock) -> None: def test_request_retries_on_503(self, mock_sleep: MagicMock) -> None:
"""Should retry on 503 service unavailable.""" """Should retry on 503 service unavailable."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo") client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
@@ -532,7 +532,7 @@ class TestGiteaClient:
assert result.json() == {"ok": True} assert result.json() == {"ok": True}
assert client._session.request.call_count == 2 assert client._session.request.call_count == 2
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_request_no_retry_on_404(self, mock_sleep: MagicMock) -> None: def test_request_no_retry_on_404(self, mock_sleep: MagicMock) -> None:
"""Should NOT retry on 404 — it's not a transient error.""" """Should NOT retry on 404 — it's not a transient error."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo") client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
@@ -545,7 +545,7 @@ class TestGiteaClient:
assert client._session.request.call_count == 1 assert client._session.request.call_count == 1
mock_sleep.assert_not_called() mock_sleep.assert_not_called()
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_request_retries_on_connection_error(self, mock_sleep: MagicMock) -> None: def test_request_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
"""Should retry on connection errors.""" """Should retry on connection errors."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo") client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
@@ -555,7 +555,7 @@ class TestGiteaClient:
assert result.json() == {"ok": True} assert result.json() == {"ok": True}
assert client._session.request.call_count == 2 assert client._session.request.call_count == 2
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_request_max_retries_exhausted(self, mock_sleep: MagicMock) -> None: def test_request_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
"""Should raise APIError after max retries on persistent 503.""" """Should raise APIError after max retries on persistent 503."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo") client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
@@ -567,7 +567,7 @@ class TestGiteaClient:
assert exc_info.value.status == 503 assert exc_info.value.status == 503
assert client._session.request.call_count == 3 # MAX_RETRIES assert client._session.request.call_count == 3 # MAX_RETRIES
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_request_connection_error_exhausted(self, mock_sleep: MagicMock) -> None: def test_request_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
"""Should raise APIError after max retries on persistent connection errors.""" """Should raise APIError after max retries on persistent connection errors."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo") client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
@@ -691,7 +691,7 @@ class TestVikunjaClient:
json={"id": 42, "title": "My task", "done": True}, json={"id": 42, "title": "My task", "done": True},
) )
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None: def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None:
client = VikunjaClient("https://work.example.com", "tok") client = VikunjaClient("https://work.example.com", "tok")
mock_resp = MagicMock() mock_resp = MagicMock()
@@ -713,7 +713,7 @@ class TestVikunjaClient:
client.list_tasks() client.list_tasks()
assert "connection failed" in str(exc_info.value) assert "connection failed" in str(exc_info.value)
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_vikunja_retries_on_503(self, mock_sleep: MagicMock) -> None: def test_vikunja_retries_on_503(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should also retry on 503.""" """VikunjaClient should also retry on 503."""
client = VikunjaClient("https://work.example.com", "tok") client = VikunjaClient("https://work.example.com", "tok")
@@ -725,7 +725,7 @@ class TestVikunjaClient:
assert len(result) == 1 assert len(result) == 1
assert client._session.request.call_count == 2 assert client._session.request.call_count == 2
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_vikunja_retries_on_connection_error(self, mock_sleep: MagicMock) -> None: def test_vikunja_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should retry on connection errors.""" """VikunjaClient should retry on connection errors."""
client = VikunjaClient("https://work.example.com", "tok") client = VikunjaClient("https://work.example.com", "tok")
@@ -735,7 +735,7 @@ class TestVikunjaClient:
assert len(result) == 1 assert len(result) == 1
assert client._session.request.call_count == 2 assert client._session.request.call_count == 2
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_vikunja_max_retries_exhausted(self, mock_sleep: MagicMock) -> None: def test_vikunja_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should raise APIError after max retries on persistent 503.""" """VikunjaClient should raise APIError after max retries on persistent 503."""
client = VikunjaClient("https://work.example.com", "tok") client = VikunjaClient("https://work.example.com", "tok")
@@ -747,7 +747,7 @@ class TestVikunjaClient:
assert exc_info.value.status == 503 assert exc_info.value.status == 503
assert client._session.request.call_count == 3 # MAX_RETRIES assert client._session.request.call_count == 3 # MAX_RETRIES
@patch("devx.api_clients.time.sleep") @patch("time.sleep")
def test_vikunja_connection_error_exhausted(self, mock_sleep: MagicMock) -> None: def test_vikunja_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should raise APIError after max retries on persistent connection errors.""" """VikunjaClient should raise APIError after max retries on persistent connection errors."""
client = VikunjaClient("https://work.example.com", "tok") client = VikunjaClient("https://work.example.com", "tok")
@@ -818,22 +818,6 @@ class TestVikunjaClient:
assert result["title"] == "Found on page 2" assert result["title"] == "Found on page 2"
class TestIsRetryable:
def test_connection_error_is_retryable(self) -> None:
assert _is_retryable(requests.ConnectionError("refused")) is True
def test_timeout_is_retryable(self) -> None:
assert _is_retryable(requests.Timeout("timed out")) is True
def test_429_is_retryable(self) -> None:
err = _mock_http_error(429, "rate limited")
assert _is_retryable(err) is True
def test_404_is_not_retryable(self) -> None:
err = _mock_http_error(404, "not found")
assert _is_retryable(err) is False
class TestGiteaClientPrLabels: class TestGiteaClientPrLabels:
def test_add_pr_label(self) -> None: def test_add_pr_label(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo") client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
@@ -913,8 +897,3 @@ class TestGiteaClientActions:
"https://git.example.com/repos/owner/repo/actions/jobs/10026/logs", "https://git.example.com/repos/owner/repo/actions/jobs/10026/logs",
timeout=DEFAULT_TIMEOUT, timeout=DEFAULT_TIMEOUT,
) )
class TestIsRetryableGeneric:
def test_generic_exception_is_not_retryable(self) -> None:
assert _is_retryable(ValueError("oops")) is False
+2 -2
View File
@@ -6,12 +6,12 @@ import click
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
from devx.ci._shared import run_cmd
from devx.ci.auto_merge import ( from devx.ci.auto_merge import (
extract_conventional_msg, extract_conventional_msg,
extract_task_id, extract_task_id,
main, main,
read_taskid, read_taskid,
run_cmd,
validate_pr_title, validate_pr_title,
validate_pr_title_matches_vikunja, validate_pr_title_matches_vikunja,
) )
@@ -399,7 +399,7 @@ class TestMain:
mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master") mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master")
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
with patch("devx.ci.auto_merge.run_cmd") as mock_run: with patch("devx.ci._shared.run_cmd") as mock_run:
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
main, main,
+3 -1
View File
@@ -92,8 +92,10 @@ class TestGetPrTitleFromGitea:
@patch("devx.ci.check_auto_merge_ready.GiteaClient") @patch("devx.ci.check_auto_merge_ready.GiteaClient")
def test_returns_none_on_exception(self, mock_client_cls: MagicMock) -> None: def test_returns_none_on_exception(self, mock_client_cls: MagicMock) -> None:
from devx.exceptions import APIError
mock_client = MagicMock() mock_client = MagicMock()
mock_client.get_pr.side_effect = Exception("API error") mock_client.get_pr.side_effect = APIError(500, "API error")
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True): with patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True):
result = get_pr_title_from_gitea("owner/repo", 1) result = get_pr_title_from_gitea("owner/repo", 1)
+2 -1
View File
@@ -32,9 +32,10 @@ class TestDefaultConfigs:
assert config["branch_name"] == "master" assert config["branch_name"] == "master"
assert config["enable_push"] is True assert config["enable_push"] is True
assert config["enable_push_whitelist"] is False assert config["enable_push_whitelist"] is False
assert config["required_approvals"] == 0 assert config["required_approvals"] == 1
assert isinstance(config["status_check_contexts"], list) assert isinstance(config["status_check_contexts"], list)
assert "CI / quality (pull_request)" in config["status_check_contexts"] assert "CI / quality (pull_request)" in config["status_check_contexts"]
assert config["block_admin_merge_override"] is True
def test_default_repo_settings_config(self) -> None: def test_default_repo_settings_config(self) -> None:
config = _default_repo_settings_config() config = _default_repo_settings_config()
+250
View File
@@ -0,0 +1,250 @@
"""Unit tests for devx.ci.distribute_items."""
import pytest
from click.testing import CliRunner
from devx.ci.distribute_items import (
DEFAULT_WEIGHT,
distribute,
items_for_runner,
main,
parse_items,
parse_weighted_items,
)
class TestParseItems:
def test_string_array(self) -> None:
assert parse_items('["a", "b", "c"]') == ["a", "b", "c"]
def test_object_array(self) -> None:
raw = '[{"id": "a", "weight": 2}, {"id": "b"}]'
assert parse_items(raw) == ["a", "b"]
def test_empty_array(self) -> None:
assert parse_items("[]") == []
def test_not_an_array(self) -> None:
with pytest.raises(Exception, match="must be a JSON array"):
parse_items('{"key": "value"}')
def test_invalid_entry_type(self) -> None:
with pytest.raises(Exception, match="must be a string or an object"):
parse_items("[42]")
def test_object_without_id(self) -> None:
with pytest.raises(Exception, match="must be a string or an object"):
parse_items('[{"weight": 2}]')
class TestParseWeightedItems:
def test_string_array_default_weights(self) -> None:
items, weights = parse_weighted_items('["a", "b"]')
assert items == ["a", "b"]
assert weights == [DEFAULT_WEIGHT, DEFAULT_WEIGHT]
def test_object_array_with_weights(self) -> None:
items, weights = parse_weighted_items('[{"id": "a", "weight": 5}, {"id": "b", "weight": 1}]')
assert items == ["a", "b"]
assert weights == [5, 1]
def test_object_array_missing_weight(self) -> None:
items, weights = parse_weighted_items('[{"id": "a"}]')
assert items == ["a"]
assert weights == [DEFAULT_WEIGHT]
def test_not_an_array(self) -> None:
with pytest.raises(Exception, match="must be a JSON array"):
parse_weighted_items('"hello"')
def test_invalid_entry(self) -> None:
with pytest.raises(Exception, match="must be a string or an object"):
parse_weighted_items("[true]")
class TestDistribute:
def test_even_split(self) -> None:
items = [f"vm-{i}" for i in range(6)]
weights = [1] * 6
groups = distribute(items, weights, 3)
assert len(groups) == 3
assert all(len(g) == 2 for g in groups)
def test_uneven_split(self) -> None:
items = [f"vm-{i}" for i in range(5)]
weights = [1] * 5
groups = distribute(items, weights, 3)
assert len(groups[0]) == 2
assert len(groups[1]) == 2
assert len(groups[2]) == 1
def test_more_runners_than_items(self) -> None:
items = ["vm-a"]
weights = [1]
groups = distribute(items, weights, 5)
assert len(groups) == 5
assert len(groups[0]) == 1
assert all(len(g) == 0 for g in groups[1:])
def test_lpt_heavy_item_on_least_loaded(self) -> None:
items = ["heavy", "light1", "light2", "light3"]
weights = [10, 1, 1, 1]
groups = distribute(items, weights, 2)
# Heavy item goes to runner 0, lights go to runner 1 (least loaded)
assert "heavy" in groups[0]
# Runner 1 should have more items but less total weight
assert len(groups[1]) >= 2
def test_empty_items(self) -> None:
groups = distribute([], [], 3)
assert len(groups) == 3
assert all(len(g) == 0 for g in groups)
def test_single_runner(self) -> None:
items = ["a", "b", "c"]
weights = [1, 2, 3]
groups = distribute(items, weights, 1)
assert len(groups) == 1
assert len(groups[0]) == 3
class TestItemsForRunner:
def test_returns_assigned_subset(self) -> None:
items = ["a", "b", "c", "d", "e", "f"]
weights = [1] * 6
result = items_for_runner(items, weights, 0, 3)
assert len(result) == 2
assert all(item in items for item in result)
def test_out_of_range(self) -> None:
with pytest.raises(Exception, match="out of range"):
items_for_runner(["a"], [1], 5, 3)
def test_negative_index(self) -> None:
with pytest.raises(Exception, match="out of range"):
items_for_runner(["a"], [1], -1, 3)
class TestMain:
def test_stdin_string_array(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--runner-index", "1", "--max-runners", "2"], input='["a", "b", "c"]')
assert result.exit_code == 0
# LPT: heaviest first, so "a" goes to runner 0, "b" to runner 1, "c" to runner 0
# All weights equal, so round-robin-ish: runner 0 gets "a","c"; runner 1 gets "b"
assert "a" in result.output
def test_stdin_object_array(self) -> None:
runner = CliRunner()
result = runner.invoke(
main,
["--runner-index", "1", "--max-runners", "2"],
input='[{"id": "a", "weight": 5}, {"id": "b", "weight": 1}]',
)
assert result.exit_code == 0
assert "a" in result.output
def test_items_file(self, tmp_path: object) -> None:
import pathlib
items_file = pathlib.Path(str(tmp_path)) / "items.json"
items_file.write_text('["x", "y", "z"]')
runner = CliRunner()
result = runner.invoke(main, ["--items-file", str(items_file), "--runner-index", "1", "--max-runners", "3"])
assert result.exit_code == 0
assert "x" in result.output
def test_print_all_groups_no_runner_index(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--max-runners", "2"], input='["a", "b"]')
assert result.exit_code == 0
assert "Runner 0:" in result.output
assert "Runner 1:" in result.output
def test_empty_stdin(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--runner-index", "1", "--max-runners", "3"], input="")
assert result.exit_code == 0
# Empty input → empty assigned items
assert result.output.strip() == ""
def test_github_env(self, tmp_path: object, monkeypatch: pytest.MonkeyPatch) -> None:
import pathlib
gh_env = pathlib.Path(str(tmp_path)) / "gh_env"
gh_env.write_text("")
monkeypatch.setenv("GITHUB_ENV", str(gh_env))
runner = CliRunner()
result = runner.invoke(
main,
["--runner-index", "1", "--max-runners", "2", "--github-env"],
input='["a", "b"]',
)
assert result.exit_code == 0
content = gh_env.read_text()
assert "ASSIGNED_ITEMS=" in content
assert "SKIP=false" in content
def test_skip_if_excess(self, tmp_path: object, monkeypatch: pytest.MonkeyPatch) -> None:
import pathlib
gh_env = pathlib.Path(str(tmp_path)) / "gh_env"
gh_env.write_text("")
monkeypatch.setenv("GITHUB_ENV", str(gh_env))
runner = CliRunner()
result = runner.invoke(
main,
["--runner-index", "5", "--max-runners", "3", "--github-env", "--skip-if-excess"],
input='["a"]',
)
assert result.exit_code == 0
content = gh_env.read_text()
assert "ASSIGNED_ITEMS=" in content
assert "SKIP=true" in content
def test_runner_index_zero(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--runner-index", "0"], input='["a"]')
assert result.exit_code != 0
assert "out of range" in result.output
def test_default_max_runners(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--runner-index", "1"], input='["a"]')
assert result.exit_code == 0
assert "a" in result.output
def test_github_env_not_set(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_ENV", raising=False)
runner = CliRunner()
result = runner.invoke(
main,
["--runner-index", "1", "--github-env"],
input='["a"]',
)
assert result.exit_code != 0
assert "GITHUB_ENV" in result.output
def test_invalid_json(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--runner-index", "1"], input="not json")
assert result.exit_code != 0
def test_multiline_github_env(self, tmp_path: object, monkeypatch: pytest.MonkeyPatch) -> None:
import pathlib
gh_env = pathlib.Path(str(tmp_path)) / "gh_env"
gh_env.write_text("")
monkeypatch.setenv("GITHUB_ENV", str(gh_env))
runner = CliRunner()
# Items with newlines in their IDs would trigger multiline syntax
# Normal items don't have newlines, but test the path anyway
result = runner.invoke(
main,
["--runner-index", "1", "--max-runners", "1", "--github-env"],
input='["a\\nb"]',
)
assert result.exit_code == 0
content = gh_env.read_text()
# Item "a\nb" contains a newline → heredoc syntax
assert "ASSIGNED_ITEMS<<" in content
+5 -4
View File
@@ -8,21 +8,22 @@ import pytest
from click import ClickException from click import ClickException
import devx.tools.install_checkmake as install_checkmake import devx.tools.install_checkmake as install_checkmake
from devx.tools._shared import arch_string
class TestArch: class TestArchString:
def test_amd64(self) -> None: def test_amd64(self) -> None:
with patch.object(platform, "machine", return_value="x86_64"): with patch.object(platform, "machine", return_value="x86_64"):
assert install_checkmake._arch() == "amd64" assert arch_string() == "amd64"
def test_arm64(self) -> None: def test_arm64(self) -> None:
with patch.object(platform, "machine", return_value="aarch64"): with patch.object(platform, "machine", return_value="aarch64"):
assert install_checkmake._arch() == "arm64" assert arch_string() == "arm64"
def test_unsupported(self) -> None: def test_unsupported(self) -> None:
with patch.object(platform, "machine", return_value="riscv64"): with patch.object(platform, "machine", return_value="riscv64"):
with pytest.raises(ClickException): with pytest.raises(ClickException):
install_checkmake._arch() arch_string()
class TestInstallWithGo: class TestInstallWithGo:
+3 -3
View File
@@ -34,20 +34,20 @@ from devx.ci.release import (
class TestRunCmd: class TestRunCmd:
@patch("devx.ci.release.subprocess.run") @patch("devx.ci._shared.subprocess.run")
def test_success(self, mock_run: MagicMock) -> None: def test_success(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stderr="", stdout="") mock_run.return_value = MagicMock(returncode=0, stderr="", stdout="")
result = run_cmd(["echo", "hi"]) result = run_cmd(["echo", "hi"])
assert result.returncode == 0 assert result.returncode == 0
mock_run.assert_called_once() mock_run.assert_called_once()
@patch("devx.ci.release.subprocess.run") @patch("devx.ci._shared.subprocess.run")
def test_failure_raises(self, mock_run: MagicMock) -> None: def test_failure_raises(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="") mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="")
with pytest.raises(click.ClickException): with pytest.raises(click.ClickException):
run_cmd(["false"]) run_cmd(["false"])
@patch("devx.ci.release.subprocess.run") @patch("devx.ci._shared.subprocess.run")
def test_check_false_no_raise(self, mock_run: MagicMock) -> None: def test_check_false_no_raise(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="") mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="")
result = run_cmd(["false"], check=False) result = run_cmd(["false"], check=False)
+8 -10
View File
@@ -272,18 +272,16 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
mock_start.assert_called_once_with(60) mock_start.assert_called_once_with(60)
@patch("devx.molecule.start_docker.os.environ.get")
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True) @patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
def test_exports_github_env(self, mock_start: MagicMock, mock_get: MagicMock) -> None: def test_exports_github_env(self, mock_start: MagicMock) -> None:
"""Should write DOCKER_HOST to GITHUB_ENV when available.""" """Should write DOCKER_HOST to GITHUB_ENV when available."""
mock_get.side_effect = lambda key, default="": ( env = {"GITHUB_ENV": "/tmp/github_env", "DOCKER_HOST": f"unix://{DOCKER_SOCK}"}
"/tmp/github_env" if key == "GITHUB_ENV" else f"unix://{DOCKER_SOCK}" if key == "DOCKER_HOST" else default with patch.dict("os.environ", env, clear=True):
) with patch("builtins.open", mock_open()) as mock_file:
with patch("builtins.open", mock_open()) as mock_file: runner = CliRunner()
runner = CliRunner() result = runner.invoke(main, [])
result = runner.invoke(main, []) assert result.exit_code == 0
assert result.exit_code == 0 mock_file.assert_called_with("/tmp/github_env", "a", encoding="utf-8")
mock_file.assert_called_with("/tmp/github_env", "a")
@patch("devx.molecule.start_docker.os.environ.get", return_value="") @patch("devx.molecule.start_docker.os.environ.get", return_value="")
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True) @patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)