diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a62c2b9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.venv/ +.git/ +.gitea/ +tests/ +docs/ +*.egg-info/ +__pycache__/ +htmlcov/ +.coverage +dist/ +build/ +*.md +!README.md +.env +.env.example +activate.sh +activate.fish +activate.zsh +hooks/ +.devin/ diff --git a/.gitea/workflows/build-images.yml b/.gitea/workflows/build-images.yml new file mode 100644 index 0000000..870bbae --- /dev/null +++ b/.gitea/workflows/build-images.yml @@ -0,0 +1,131 @@ +name: Build Images + +# Builds and pushes pre-built Docker runner images to the Gitea registry. +# These images eliminate the 40-120s setup tax on every CI job by baking +# devx and all dependencies into the image. +# +# Triggers: +# - On push to master (after post-merge release completes) +# - Manually via workflow_dispatch +# +# The workflow builds 3 tier images in sequence: +# ci-base → ci-quality → ci-full +# +# Each tier builds FROM the previous one, so they must be built in order. +# After pushing, a cleanup job removes old versions (keeps last 2 + latest). + +on: + push: + branches: [master] + paths: + - docker/** + - pyproject.toml + - src/devx/** + workflow_dispatch: + +jobs: + detect-type: + runs-on: docker + timeout-minutes: 5 + outputs: + is-release: ${{ steps.check.outputs.is-release }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Set up environment + run: make setup-ci + - name: Check if this is a release commit + id: check + env: + PYTHONPATH: src + run: | + . .venv/bin/activate + python3 -m devx.ci.detect_release_commit + + build-and-push: + needs: [detect-type] + if: needs.detect-type.outputs.is-release == 'false' + runs-on: docker + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up environment + env: + REPO_TOKEN: ${{ secrets.REPO_TOKEN }} + run: make setup-release + - name: Docker registry login + env: + REPO_TOKEN: ${{ secrets.REPO_TOKEN }} + REGISTRY_USERNAME: ${{ vars.REGISTRY_USERNAME }} + run: | + . .venv/bin/activate + echo "$REPO_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$REGISTRY_USERNAME" --password-stdin + - name: Build and push tier images + env: + REPO_TOKEN: ${{ secrets.REPO_TOKEN }} + REGISTRY_USERNAME: ${{ vars.REGISTRY_USERNAME }} + PYTHONPATH: src + run: | + . .venv/bin/activate + export PATH="$HOME/.local/bin:$PATH" + # Build ci-base first (it's the base for ci-quality and ci-full) + python3 -m devx.tools.build_image \ + --dockerfile docker/ci-base/Dockerfile \ + --name oblachno-oss/runner-images/ci-base \ + --tag latest \ + --registry git.oblachno.oblachno.fyi \ + --push --pull + # Build ci-quality (FROM ci-base-latest) + python3 -m devx.tools.build_image \ + --dockerfile docker/ci-quality/Dockerfile \ + --name oblachno-oss/runner-images/ci-quality \ + --tag latest \ + --registry git.oblachno.oblachno.fyi \ + --push + # Build ci-full (FROM ci-quality-latest) + python3 -m devx.tools.build_image \ + --dockerfile docker/ci-full/Dockerfile \ + --name oblachno-oss/runner-images/ci-full \ + --tag latest \ + --registry git.oblachno.oblachno.fyi \ + --push + - name: Notify on failure + if: failure() + env: + REPO_TOKEN: ${{ secrets.REPO_TOKEN }} + PYTHONPATH: src + run: | + . .venv/bin/activate 2>/dev/null || true + export PATH="$HOME/.local/bin:$PATH" + python3 -m devx.ci.notify_failure \ + --repo "${{ github.repository }}" \ + --run-id "${{ github.run_id }}" \ + --workflow "build-images/build-and-push" \ + --commit "${{ github.sha }}" + + cleanup: + needs: [build-and-push] + if: always() && needs.build-and-push.result == 'success' + runs-on: docker + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Set up environment + run: make setup-ci + - name: Clean up old image versions + env: + REPO_TOKEN: ${{ secrets.REPO_TOKEN }} + PYTHONPATH: src + run: | + . .venv/bin/activate + python3 -m devx.tools.clean_images \ + --owner oblachno-oss \ + --name oblachno-oss/runner-images/ci-base \ + --name oblachno-oss/runner-images/ci-quality \ + --name oblachno-oss/runner-images/ci-full \ + --keep 2 diff --git a/AGENTS.md b/AGENTS.md index ad55803..d7f27a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,9 @@ src/devx/ ├── tools/ # Developer tooling modules (run locally or by CI) │ ├── setup.py # Environment setup (venv, deps, hooks) │ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea +│ ├── install_checkmake.py # Install checkmake (Makefile linter) +│ ├── build_image.py # Build and push Docker images to Gitea registry +│ ├── clean_images.py # Clean up old Docker image versions from Gitea registry │ ├── check_test_speed.py # Measure unit test execution time │ ├── check_mutable_globals.py # Detect module-level mutable globals (test isolation bugs) │ ├── check_pyproject_deps.py # Validate pyproject.toml deps have documentation comments @@ -424,6 +427,11 @@ projects. | `devx-check-test-speed` | Verify test suite timing | | `devx-pre-push` | Run lint + tests before push | | `devx-clean` | Remove caches, build artifacts, coverage data | +| `devx-setup-image` | Link /opt/venv + install project (for pre-built image CI jobs) | +| `devx-build-images` | Build Docker images from manifest (no push) | +| `devx-push-images` | Build and push Docker images to Gitea registry | +| `devx-build-images-dry-run` | Show what would be built/pushed | +| `devx-clean-images` | Delete old image versions (keep last 2 + latest) | **Variables** (set BEFORE including devx.mak): - `DEVX_PYTHON` — Python executable (default: `python3`) @@ -433,6 +441,44 @@ projects. - `DEVX_COV_PKG` — coverage package (default: `src/devx`) - `DEVX_TEST_PATHS` — pytest paths (default: `tests/`) - `DEVX_PR_BASE` — PR base branch (default: `master`) +- `DEVX_GITEA_REGISTRY` — registry URL (default: `git.oblachno.oblachno.fyi`) +- `DEVX_IMAGE_MANIFEST` — path to JSON manifest (default: `docker/images.json`) +- `DEVX_IMAGE_OWNER` — package owner for cleanup (default: `oblachno-oss`) + +## Pre-built Docker Runner Images + +devx builds and publishes three tier images to the Gitea container registry +to eliminate the 40-120s setup tax on every CI job: + +| Image | Contains | Used by jobs | +|-------|----------|-------------| +| `ci-base-latest` | Python 3.12 + devx[ci] + tea | detect-changes, detect-type, validate-commit-msg, pr-review, auto-merge, sync-wiki, vikunja, configure-repo | +| `ci-quality-latest` | ci-base + devx[lint] + actionlint + checkmake | quality, badges | +| `ci-full-latest` | ci-quality + devx[release,molecule,deploy] + git-cliff + OpenTofu | release, publish, release-dry-run, molecule-tests, deploy jobs | + +**Build process** (in `build-images.yml` workflow): +1. `ci-base` builds FROM `gitea/runner-images:ubuntu-latest` +2. `ci-quality` builds FROM `ci-base-latest` +3. `ci-full` builds FROM `ci-quality-latest` + +Each image is tagged `latest` and pushed to +`git.oblachno.oblachno.fyi/oblachno-oss/runner-images:-latest`. + +**Using images in workflows**: +```yaml +jobs: + quality: + runs-on: docker + container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images:ci-quality-latest + steps: + - uses: actions/checkout@v4 + - name: Set up environment + run: make setup-image # links /opt/venv, installs project (no-deps) +``` + +**Image build/push tools** (tested Python modules): +- `devx.tools.build_image` — Build and push Docker images from Dockerfile or manifest +- `devx.tools.clean_images` — Delete old image versions via Gitea API (keep last N + latest) **Usage in project Makefile**: ```makefile diff --git a/Makefile b/Makefile index cf6e215..0fb7648 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all setup setup-ci setup-quality setup-release install update lint lint-all test test-unit pytest-cov clean install-tools install-hooks activate-scripts checkmake check-mutable-globals check-dep-docs check-test-speed +.PHONY: all setup setup-ci setup-quality setup-release setup-image install update lint lint-all test test-unit pytest-cov clean install-tools install-hooks activate-scripts checkmake check-mutable-globals check-dep-docs check-test-speed build-images push-images build-images-dry-run clean-images PYTHON := python3 VENV := .venv @@ -30,6 +30,11 @@ setup-release: $(VENV)/bin/activate .env export PATH="$(HOME)/.local/bin:$$PATH"; \ $(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-pre-commit +# Setup for pre-built image jobs (deps already in image, just link venv + install project) +setup-image: + @if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate && pip install -e . --no-deps 2>/dev/null; \ + else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi + .env: @if [ ! -f .env ]; then cp .env.example .env; echo "Created .env from .env.example — please edit it."; fi @@ -105,3 +110,17 @@ pre-push: lint-all pytest-cov clean: devx-clean @echo "[clean] Done." + +# ── Docker image management ────────────────────────────────────────────────── + +build-images: devx-build-images + @echo "[build-images] Done." + +push-images: devx-push-images + @echo "[push-images] Done." + +build-images-dry-run: devx-build-images-dry-run + @echo "[build-images-dry-run] Done." + +clean-images: devx-clean-images + @echo "[clean-images] Done." diff --git a/docker/ci-base/Dockerfile b/docker/ci-base/Dockerfile new file mode 100644 index 0000000..0738fd9 --- /dev/null +++ b/docker/ci-base/Dockerfile @@ -0,0 +1,26 @@ +# ci-base — lightweight image for CI jobs that only need devx core + tea. +# +# Used by: detect-type, detect-changes, validate-commit-msg, pr-review, +# auto-merge, sync-wiki, vikunja, configure-repo, discover-runners, +# molecule-report, discover-integration-runners +# +# Jobs using this image: setup is instant (ln -s /opt/venv .venv) +# No pip install needed — devx and all deps are pre-installed. + +FROM gitea/runner-images:ubuntu-latest + +# Create a virtual environment with all deps pre-installed +RUN python3 -m venv /opt/venv +ENV PATH="/opt/venv/bin:/root/.local/bin:$PATH" + +# Install devx from local source (build context = devx repo root) +COPY . /tmp/devx +RUN pip install --no-cache-dir --upgrade pip setuptools wheel \ + && pip install --no-cache-dir /tmp/devx[ci] \ + && rm -rf /tmp/devx + +# Install tea CLI (for Gitea API operations in CI) +RUN python3 -m devx.tools.install_tools --tool tea + +# Workspace directory (actions/checkout mounts repo here) +WORKDIR /workspace diff --git a/docker/ci-full/Dockerfile b/docker/ci-full/Dockerfile new file mode 100644 index 0000000..5a820fb --- /dev/null +++ b/docker/ci-full/Dockerfile @@ -0,0 +1,24 @@ +# ci-full — heaviest image, includes everything for release, molecule, deploy. +# +# Used by: release, publish, release-dry-run, molecule-tests, +# provision-infra, deploy-observability, provision-zitadel, +# deploy-customer, integration-tests +# +# Layers on top of ci-quality: adds release tools, molecule, deploy deps, +# git-cliff, and OpenTofu. + +FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images:ci-quality-latest + +# Install devx[release,molecule,deploy] from local source +COPY . /tmp/devx +RUN pip install --no-cache-dir /tmp/devx[release,molecule,deploy] \ + && rm -rf /tmp/devx + +# Install git-cliff (changelog generator for release job) +RUN python3 -m devx.tools.install_tools --tool git-cliff + +# Install OpenTofu (for infra deploy jobs) +RUN ARCH=$(uname -m | sed 's/x86_64/amd64') \ + && VERSION=1.12.3 \ + && curl -fsSL "https://github.com/opentofu/opentofu/releases/download/v${VERSION}/tofu_${VERSION}_$(uname -s | tr '[:upper:]' '[:lower:]')_${ARCH}.tar.gz" \ + | tar -xz -C /usr/local/bin tofu diff --git a/docker/ci-quality/Dockerfile b/docker/ci-quality/Dockerfile new file mode 100644 index 0000000..c02e4a7 --- /dev/null +++ b/docker/ci-quality/Dockerfile @@ -0,0 +1,17 @@ +# ci-quality — image for lint, type-checking, badge generation. +# +# Used by: quality (lint-all + pytest-cov + checks), badges (generate_badges +# runs ruff/pyright/bandit to produce quality badge) +# +# Layers on top of ci-base: adds lint tools + actionlint + checkmake. + +FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images:ci-base-latest + +# Install devx[lint] from local source (adds ruff, pyright, bandit, etc.) +COPY . /tmp/devx +RUN pip install --no-cache-dir /tmp/devx[lint] \ + && rm -rf /tmp/devx + +# Install CI/CD binary tools +RUN python3 -m devx.tools.install_tools --tool actionlint \ + && python3 -m devx.tools.install_checkmake diff --git a/docker/images.json b/docker/images.json new file mode 100644 index 0000000..c01464d --- /dev/null +++ b/docker/images.json @@ -0,0 +1,20 @@ +[ + { + "name": "oblachno-oss/runner-images/ci-base", + "dockerfile": "docker/ci-base/Dockerfile", + "context": ".", + "tags": ["latest"] + }, + { + "name": "oblachno-oss/runner-images/ci-quality", + "dockerfile": "docker/ci-quality/Dockerfile", + "context": ".", + "tags": ["latest"] + }, + { + "name": "oblachno-oss/runner-images/ci-full", + "dockerfile": "docker/ci-full/Dockerfile", + "context": ".", + "tags": ["latest"] + } +] diff --git a/pyproject.toml b/pyproject.toml index abe0203..6b5b905 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,14 +26,13 @@ devx = "devx.cli:cli" version = {attr = "devx.__version__"} [project.optional-dependencies] -# Minimal deps for CI scripts that only need click/dotenv/requests +# Test runners (pytest + coverage + parallel execution) ci = [ "pytest>=9.1.0", "pytest-cov>=7.1.0", - "build>=1.5.0", - "twine>=6.2.0", + "pytest-xdist>=3.8", ] -# Lint and type-checking tools (quality job) +# Lint and type-checking tools (quality job, badge generation) lint = [ "ruff>=0.15.17", "pyright>=1.1.410", @@ -41,16 +40,30 @@ lint = [ "pip-audit>=2.10", "pre-commit>=4.6.0", ] -# Molecule testing (optional — for projects with Ansible roles) +# Release tools (build + publish to PyPI/Gitea registry) +release = [ + "build>=1.5.0", + "twine>=6.2.0", +] +# Molecule testing (for projects with Ansible roles) molecule = [ "molecule>=26.4.0", "molecule-docker>=2.1.0", "ansible-lint>=26.4.0", - "ansible>=14.0.0", + "ansible-core>=2.15,<2.17", +] +# Deploy tools (for infra staging/production deployments) +deploy = [ + "ansible-core>=2.15,<2.17", + "boto3>=1.34", + "docker>=7.0", + "jinja2>=3.1", + "pyyaml>=6.0", + "cryptography>=41.0", ] # Full dev environment (local development) dev = [ - "devx[ci,lint]", + "devx[ci,lint,release,molecule]", "build>=1.3.0", "twine>=6.2.0", ] diff --git a/src/devx/make/devx.mak b/src/devx/make/devx.mak index bc32f77..be5b526 100644 --- a/src/devx/make/devx.mak +++ b/src/devx/make/devx.mak @@ -249,3 +249,58 @@ devx-clean: @find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true @find . -type f -name "*.pyc" -delete 2>/dev/null || true @rm -rf .coverage htmlcov/ dist/ build/ *.egg-info/ .molecule/ 2>/dev/null || true + +# ── Pre-built image setup ───────────────────────────────────────────────────── +# +# When running inside a pre-built Docker runner image (ci-base, ci-quality, +# ci-full), all deps are already installed in /opt/venv. This target links +# the venv and installs the project itself (no-deps, fast). +# Falls back to devx-setup-ci if /opt/venv is not present (local dev). + +devx-setup-image: + @if [ -d /opt/venv ]; then \ + ln -sf /opt/venv $(DEVX_VENV); \ + . $(DEVX_BIN)/activate && pip install -e . --no-deps 2>/dev/null; \ + echo "[devx-setup-image] Linked /opt/venv and installed project (no-deps)."; \ + else \ + echo "[devx-setup-image] /opt/venv not found — falling back to devx-setup-ci"; \ + $(MAKE) devx-setup-ci; \ + fi + +# ── Docker image build / push / cleanup ─────────────────────────────────────── +# +# Variables: +# DEVX_GITEA_REGISTRY — registry URL (default: git.oblachno.oblachno.fyi) +# DEVX_IMAGE_MANIFEST — path to JSON manifest (default: docker/images.json) +# DEVX_IMAGE_OWNER — package owner for cleanup (default: oblachno-oss) + +DEVX_GITEA_REGISTRY ?= git.oblachno.oblachno.fyi +DEVX_IMAGE_MANIFEST ?= docker/images.json +DEVX_IMAGE_OWNER ?= oblachno-oss + +# Build all images from manifest (no push) +devx-build-images: + @$(DEVX_PYTHON) -m devx.tools.build_image --manifest $(DEVX_IMAGE_MANIFEST) --pull + +# Build and push all images to the Gitea registry +devx-push-images: + @$(DEVX_PYTHON) -m devx.tools.build_image \ + --manifest $(DEVX_IMAGE_MANIFEST) \ + --registry $(DEVX_GITEA_REGISTRY) \ + --push --pull + +# Dry-run: show what would be built/pushed +devx-build-images-dry-run: + @$(DEVX_PYTHON) -m devx.tools.build_image \ + --manifest $(DEVX_IMAGE_MANIFEST) \ + --registry $(DEVX_GITEA_REGISTRY) \ + --push --dry-run + +# Clean up old image versions (keep last 2 + latest) +devx-clean-images: + @$(DEVX_PYTHON) -m devx.tools.clean_images \ + --owner $(DEVX_IMAGE_OWNER) \ + --name oblachno-oss/runner-images/ci-base \ + --name oblachno-oss/runner-images/ci-quality \ + --name oblachno-oss/runner-images/ci-full \ + --keep 2 diff --git a/src/devx/tools/build_image.py b/src/devx/tools/build_image.py new file mode 100644 index 0000000..db211c6 --- /dev/null +++ b/src/devx/tools/build_image.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""Build and push Docker images to a Gitea container registry. + +Replaces raw ``docker build`` / ``docker push`` shell commands with a +tested Python tool. Supports: + +- Building from any Dockerfile with a configurable context directory +- Tagging with multiple tags (e.g. ``latest`` + version) +- Optional push to a Gitea registry (with login) +- Dry-run mode (prints commands without executing) + +Usage:: + + # Build a single image + python3 -m devx.tools.build_image \\ + --dockerfile docker/ci-base/Dockerfile \\ + --tag ci-base:latest \\ + --tag ci-base:0.19.3 + + # Build and push to registry + python3 -m devx.tools.build_image \\ + --dockerfile docker/ci-base/Dockerfile \\ + --tag ci-base:latest \\ + --tag ci-base:0.19.3 \\ + --registry git.oblachno.oblachno.fyi \\ + --push + + # Build multiple images (from a manifest file) + python3 -m devx.tools.build_image --manifest docker/images.json --push + +The manifest file is a JSON list of dicts, each with: + - ``name``: image name (e.g. ``ci-base``) + - ``dockerfile``: path to Dockerfile (relative to repo root) + - ``context``: build context directory (optional, defaults to repo root) + - ``tags``: list of tags (optional, defaults to ``["latest"]``) + +Registry authentication uses ``REPO_TOKEN`` (or ``GITEA_REGISTRY_TOKEN``) +and ``REGISTRY_USERNAME`` (or ``GITEA_REGISTRY_USERNAME``) environment +variables, matching the existing CI workflow patterns. +""" + +from __future__ import annotations + +import json +import os +import subprocess # nosec B404 +from dataclasses import dataclass, field +from pathlib import Path + +import click + +from devx.i18n import _ + + +@dataclass +class ImageSpec: + """Specification for a single Docker image to build.""" + + name: str + dockerfile: str + context: str = "." + tags: list[str] = field(default_factory=lambda: ["latest"]) + + @classmethod + def from_dict(cls, data: dict[str, object]) -> ImageSpec: + """Create an ImageSpec from a dict (e.g. from a JSON manifest).""" + name = str(data.get("name", "")) + if not name: + raise ValueError(_("Image manifest entry missing 'name'")) + dockerfile = str(data.get("dockerfile", "")) + if not dockerfile: + raise ValueError(_("Image manifest entry missing 'dockerfile'")) + context = str(data.get("context", ".")) + tags_raw = data.get("tags", ["latest"]) + if not isinstance(tags_raw, list): + raise ValueError(_("Image 'tags' must be a list")) + tags = [str(t) for t in tags_raw] if tags_raw else ["latest"] + return cls(name=name, dockerfile=dockerfile, context=context, tags=tags) + + +def load_manifest(path: str | Path) -> list[ImageSpec]: + """Load a JSON manifest file describing images to build. + + The file must contain a JSON list of dicts with at least ``name`` and + ``dockerfile`` keys. ``context`` and ``tags`` are optional. + + Returns a list of :class:`ImageSpec` instances. + """ + p = Path(path) + if not p.is_file(): + raise click.ClickException(_("Manifest file not found: {path}", path=p)) + with p.open() as f: # noqa: PTH123 + data = json.load(f) + if not isinstance(data, list): + raise click.ClickException(_("Manifest must be a JSON list")) + return [ImageSpec.from_dict(entry) for entry in data] + + +def build_full_tag(registry: str | None, name: str, tag: str) -> str: + """Build a full image tag, optionally prefixed with a registry. + + >>> build_full_tag(None, "ci-base", "latest") + 'ci-base:latest' + >>> build_full_tag("git.example.com", "ci-base", "0.1.0") + 'git.example.com/ci-base:0.1.0' + """ + if registry: + return f"{registry}/{name}:{tag}" + return f"{name}:{tag}" + + +def registry_login( + registry: str, + username: str, + token: str, + *, + dry_run: bool = False, +) -> bool: + """Log in to a Docker registry. + + Returns True on success, False on failure. + In dry-run mode, prints the command without executing. + """ + cmd = ["docker", "login", registry, "-u", username, "--password-stdin"] + if dry_run: + click.echo(f"[dry-run] {' '.join(cmd)}") + return True + result = subprocess.run( # nosec B603 + cmd, + input=token, + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + click.echo( + _("Registry login failed: {error}", error=result.stderr.strip()), + err=True, + ) + return False + click.echo(f"Logged in to {registry}") + return True + + +def build_image( + spec: ImageSpec, + registry: str | None = None, + *, + dry_run: bool = False, + pull: bool = False, +) -> bool: + """Build a Docker image from a Dockerfile. + + Tags the image with all specified tags, optionally prefixed with the + registry. Returns True on success, False on failure. + """ + if not Path(spec.dockerfile).is_file(): + click.echo( + _("Dockerfile not found: {path}", path=spec.dockerfile), + err=True, + ) + return False + + full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags] + cmd = ["docker", "build"] + if pull: + cmd.append("--pull") + for ft in full_tags: + cmd.extend(["-t", ft]) + cmd.extend(["-f", spec.dockerfile, spec.context]) + + if dry_run: + click.echo(f"[dry-run] {' '.join(cmd)}") + return True + + click.echo(f"Building {spec.name} ({len(full_tags)} tag(s))...") + result = subprocess.run( # nosec B603 + cmd, + check=False, + ) + if result.returncode != 0: + click.echo(_("Build failed for {name}", name=spec.name), err=True) + return False + click.echo(f"Built {spec.name}") + return True + + +def push_image( + spec: ImageSpec, + registry: str, + *, + dry_run: bool = False, +) -> bool: + """Push all tags of a Docker image to the registry. + + Returns True if all pushes succeed, False if any fail. + """ + full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags] + all_ok = True + for ft in full_tags: + cmd = ["docker", "push", ft] + if dry_run: + click.echo(f"[dry-run] {' '.join(cmd)}") + continue + click.echo(f"Pushing {ft}...") + result = subprocess.run( # nosec B603 + cmd, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + click.echo( + _("Push failed for {tag}: {error}", tag=ft, error=result.stderr.strip()), + err=True, + ) + all_ok = False + else: + click.echo(f"Pushed {ft}") + return all_ok + + +def _get_registry_creds() -> tuple[str, str]: + """Get registry credentials from environment variables. + + Supports both REPO_TOKEN/GITEA_REGISTRY_TOKEN and + REGISTRY_USERNAME/GITEA_REGISTRY_USERNAME patterns. + """ + token = os.environ.get("REPO_TOKEN") or os.environ.get("GITEA_REGISTRY_TOKEN", "") + username = os.environ.get("REGISTRY_USERNAME") or os.environ.get("GITEA_REGISTRY_USERNAME", "") + return username, token + + +@click.command() +@click.option( + "--dockerfile", + "dockerfile", + default=None, + help="Path to Dockerfile (for single-image build).", +) +@click.option( + "--context", + "context", + default=".", + help="Build context directory (for single-image build).", +) +@click.option( + "--name", + "name", + default=None, + help="Image name (for single-image build).", +) +@click.option( + "--tag", + "tags", + multiple=True, + help="Tag(s) for the image. Can be repeated. Defaults to 'latest'.", +) +@click.option( + "--manifest", + "manifest", + default=None, + help="Path to JSON manifest file listing images to build.", +) +@click.option( + "--registry", + "registry", + default=None, + help="Registry URL (e.g. git.example.com). If set with --push, images are tagged and pushed there.", +) +@click.option( + "--push", + is_flag=True, + default=False, + help="Push images to the registry after building.", +) +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="Print commands without executing.", +) +@click.option( + "--pull", + is_flag=True, + default=False, + help="Pass --pull to docker build (always fetch latest base image).", +) +def main( + dockerfile: str | None, + context: str, + name: str | None, + tags: tuple[str, ...], + manifest: str | None, + registry: str | None, + push: bool, + dry_run: bool, + pull: bool, +) -> None: + """Build and optionally push Docker images to a Gitea registry.""" + if manifest: + specs = load_manifest(manifest) + elif dockerfile and name: + tag_list = list(tags) if tags else ["latest"] + specs = [ImageSpec(name=name, dockerfile=dockerfile, context=context, tags=tag_list)] + else: + raise click.ClickException(_("Provide --manifest or both --dockerfile and --name")) + + if push: + if not registry: + raise click.ClickException(_("--push requires --registry")) + username, token = _get_registry_creds() + if not token or not username: + raise click.ClickException( + _("Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars") + ) + if not registry_login(registry, username, token, dry_run=dry_run): + raise click.ClickException(_("Registry login failed")) + + failed: list[str] = [] + for spec in specs: + if not build_image(spec, registry, dry_run=dry_run, pull=pull): + failed.append(spec.name) + continue + if push and not push_image(spec, registry, dry_run=dry_run): # type: ignore[arg-type] + failed.append(spec.name) + + if failed: + raise click.ClickException(_("Failed images: {names}", names=", ".join(failed))) + click.echo(f"\nDone. {len(specs)} image(s) processed.") + + +if __name__ == "__main__": # pragma: no cover + main() # pragma: no cover diff --git a/src/devx/tools/clean_images.py b/src/devx/tools/clean_images.py new file mode 100644 index 0000000..9c6da0a --- /dev/null +++ b/src/devx/tools/clean_images.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Clean up old Docker images from a Gitea container registry. + +Queries the Gitea API for all versions of a package (container type) and +deletes all but the most recent N versions. The ``latest`` tag is always +preserved if present. + +Usage:: + + # Clean up ci-base images, keep last 2 versions + python3 -m devx.tools.clean_images \\ + --owner oblachno-oss \\ + --name ci-base \\ + --keep 2 + + # Clean up multiple images + python3 -m devx.tools.clean_images \\ + --owner oblachno-oss \\ + --name ci-base \\ + --name ci-quality \\ + --name ci-full \\ + --keep 2 + + # Dry run (list what would be deleted) + python3 -m devx.tools.clean_images \\ + --owner oblachno-oss \\ + --name ci-base \\ + --keep 2 \\ + --dry-run + +Authentication uses ``REPO_TOKEN`` environment variable. +""" + +from __future__ import annotations + +import os +from typing import Any + +import click +import requests + +from devx.config import GITEA_API_URL +from devx.i18n import _ + + +def list_package_versions( + api_url: str, + owner: str, + name: str, + token: str, + *, + timeout: int = 30, +) -> list[dict[str, Any]]: + """List all versions of a container package from the Gitea API. + + Returns a list of version dicts, each containing at least ``version`` + and ``created_at`` fields. + """ + url = f"{api_url}/packages/{owner}?type=container&name={name}" + headers = {"Authorization": f"token {token}"} + all_versions: list[dict[str, Any]] = [] + page = 1 + while True: + resp = requests.get( + f"{url}&page={page}&limit=50", + headers=headers, + timeout=timeout, + ) + resp.raise_for_status() + data = resp.json() + if not data: + break + all_versions.extend(data) + if len(data) < 50: + break + page += 1 + return all_versions + + +def delete_package_version( + api_url: str, + owner: str, + name: str, + version: str, + token: str, + *, + timeout: int = 30, +) -> bool: + """Delete a specific version of a container package. + + Returns True on success, False on failure. + """ + url = f"{api_url}/packages/{owner}/{name}/{version}" + headers = {"Authorization": f"token {token}"} + resp = requests.delete(url, headers=headers, timeout=timeout) + return resp.status_code in (204, 200) + + +def sort_versions_by_date( + versions: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Sort package versions by creation date, newest first. + + Falls back to version string comparison if created_at is missing. + """ + + def _sort_key(v: dict[str, Any]) -> str: + return str(v.get("created_at", v.get("version", ""))) + + return sorted(versions, key=_sort_key, reverse=True) + + +def select_for_deletion( + versions: list[dict[str, Any]], + keep: int, +) -> list[dict[str, Any]]: + """Select versions to delete, keeping the most recent ``keep`` versions. + + Versions named ``latest`` are always preserved. + """ + sorted_versions = sort_versions_by_date(versions) + to_delete = sorted_versions[keep:] + # Always preserve 'latest' tag + to_delete = [v for v in to_delete if v.get("version") != "latest"] + return to_delete + + +@click.command() +@click.option( + "--owner", + required=True, + help="Package owner (user or org).", +) +@click.option( + "--name", + "names", + multiple=True, + required=True, + help="Package name(s). Can be repeated.", +) +@click.option( + "--keep", + default=2, + type=int, + show_default=True, + help="Number of recent versions to keep (excluding 'latest').", +) +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="List versions that would be deleted without actually deleting.", +) +@click.option( + "--api-url", + default=None, + help="Gitea API URL (defaults to DEVX_GITEA_API_URL or built-in default).", +) +def main( + owner: str, + names: tuple[str, ...], + keep: int, + dry_run: bool, + api_url: str | None, +) -> None: + """Clean up old Docker image versions from a Gitea registry.""" + token = os.environ.get("REPO_TOKEN", "") + if not token: + raise click.ClickException(_("REPO_TOKEN environment variable required")) + base_url = api_url or GITEA_API_URL + + total_deleted = 0 + total_kept = 0 + for name in names: + click.echo(f"\n{'=' * 60}") + click.echo(f"Package: {owner}/{name}") + click.echo(f"{'=' * 60}") + try: + versions = list_package_versions(base_url, owner, name, token) + except requests.RequestException as exc: + click.echo( + _("Failed to list versions for {name}: {error}", name=name, error=exc), + err=True, + ) + continue + + if not versions: + click.echo(_("No versions found.")) + continue + + click.echo(f"Found {len(versions)} version(s):") + for v in sort_versions_by_date(versions): + click.echo(f" {v.get('version', '?')} (created: {v.get('created_at', '?')})") + + to_delete = select_for_deletion(versions, keep) + kept_count = len(versions) - len(to_delete) + click.echo(f"\nKeeping {kept_count}, would delete {len(to_delete)}") + + if dry_run: + for v in to_delete: + click.echo(f" [dry-run] Would delete: {v.get('version', '?')}") + total_kept += kept_count + continue + + deleted_count = 0 + for v in to_delete: + version = str(v.get("version", "")) + if delete_package_version(base_url, owner, name, version, token): + click.echo(f" Deleted: {version}") + deleted_count += 1 + else: + click.echo(f" FAILED to delete: {version}", err=True) + + total_deleted += deleted_count + total_kept += kept_count + + click.echo(f"\nDone. Deleted {total_deleted}, kept {total_kept}.") + + +if __name__ == "__main__": # pragma: no cover + main() # pragma: no cover diff --git a/src/devx/translations.json b/src/devx/translations.json index c72d62c..614027f 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -343,6 +343,14 @@ "ru": " Updated: {title}", "zh": " Updated: {title}" }, + "--push requires --registry": { + "bg": "--push requires --registry", + "de": "--push requires --registry", + "en": "--push requires --registry", + "pl": "--push requires --registry", + "ru": "--push requires --registry", + "zh": "--push requires --registry" + }, "--skip-build: skipping package build and PyPI publish.": { "bg": "--skip-build: skipping package build and PyPI publish.", "de": "--skip-build: skipping package build and PyPI publish.", @@ -367,6 +375,14 @@ "ru": "API poll warning: {exc}", "zh": "API poll warning: {exc}" }, + "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.", + "en": "Additional directory to scan (default: scripts, tests). Can be repeated.", + "pl": "Additional directory to scan (default: scripts, tests). Can be repeated.", + "ru": "Additional directory to scan (default: scripts, tests). Can be repeated.", + "zh": "Additional directory to scan (default: scripts, tests). Can be repeated." + }, "All molecule tests passed.": { "bg": "All molecule tests passed.", "de": "All molecule tests passed.", @@ -383,6 +399,22 @@ "ru": "Another molecule runner failed. Stopping this runner early.", "zh": "Another molecule runner failed. Stopping this runner early." }, + "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description": { + "bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание", + "de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung", + "en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description", + "pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis", + "ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание", + "zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述" + }, + "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"": { + "bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание\n Пример: {prefix}-42-add-feature\n Решение: преименувайте клона или създайте Vikunja задача:\n python -m devx.tools.create_task --title \"Заглавие на задача\"", + "de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung\n Beispiel: {prefix}-42-add-feature\n Fix: Branch umbenennen oder Vikunja-Task erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"", + "en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"", + "pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis\n Przykład: {prefix}-42-add-feature\n Naprawa: zmień nazwę gałęzi lub utwórz zadanie Vikunja:\n python -m devx.tools.create_task --title \"Tytuł zadania\"", + "ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание\n Пример: {prefix}-42-add-feature\n Исправление: переименуйте ветку или создайте задачу Vikunja:\n python -m devx.tools.create_task --title \"Заголовок задачи\"", + "zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述\n 示例: {prefix}-42-add-feature\n 修复: 重命名分支或先创建 Vikunja 任务:\n python -m devx.tools.create_task --title \"任务标题\"" + }, "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": { "bg": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.", "de": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.", @@ -391,6 +423,38 @@ "ru": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.", "zh": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label." }, + "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master": { + "bg": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master", + "de": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master", + "en": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master", + "pl": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master", + "ru": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master", + "zh": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master" + }, + "Branch name (e.g., DEVX-256-fix-foo)": { + "bg": "Branch name (e.g., DEVX-256-fix-foo)", + "de": "Branch name (e.g., DEVX-256-fix-foo)", + "en": "Branch name (e.g., DEVX-256-fix-foo)", + "pl": "Branch name (e.g., DEVX-256-fix-foo)", + "ru": "Branch name (e.g., DEVX-256-fix-foo)", + "zh": "Branch name (e.g., DEVX-256-fix-foo)" + }, + "Branch name must contain a task ID.": { + "bg": "Branch name must contain a task ID.", + "de": "Branch name must contain a task ID.", + "en": "Branch name must contain a task ID.", + "pl": "Branch name must contain a task ID.", + "ru": "Branch name must contain a task ID.", + "zh": "Branch name must contain a task ID." + }, + "Build failed for {name}": { + "bg": "Build failed for {name}", + "de": "Build failed for {name}", + "en": "Build failed for {name}", + "pl": "Build failed for {name}", + "ru": "Build failed for {name}", + "zh": "Build failed for {name}" + }, "Bumping version: {current} -> v{new_version}": { "bg": "Bumping version: {current} -> v{new_version}", "de": "Bumping version: {current} -> v{new_version}", @@ -399,6 +463,14 @@ "ru": "Bumping version: {current} -> v{new_version}", "zh": "Bumping version: {current} -> v{new_version}" }, + "Check that changed files have corresponding tests": { + "bg": "Check that changed files have corresponding tests", + "de": "Check that changed files have corresponding tests", + "en": "Check that changed files have corresponding tests", + "pl": "Check that changed files have corresponding tests", + "ru": "Check that changed files have corresponding tests", + "zh": "Check that changed files have corresponding tests" + }, "Checking CLI command documentation...": { "bg": "Checking CLI command documentation...", "de": "Checking CLI command documentation...", @@ -423,6 +495,14 @@ "ru": "Comparing {base}..{head} ({count} files changed)", "zh": "Comparing {base}..{head} ({count} files changed)" }, + "Configuration OK: [tool.devx] present, devx versions consistent.": { + "bg": "Конфигурацията е OK: [tool.devx] присъства, версиите на devx са консистентни.", + "de": "Konfiguration OK: [tool.devx] vorhanden, devx-Versionen konsistent.", + "en": "Configuration OK: [tool.devx] present, devx versions consistent.", + "pl": "Konfiguracja OK: [tool.devx] obecne, wersje devx spójne.", + "ru": "Конфигурация OK: [tool.devx] присутствует, версии devx согласованы.", + "zh": "配置正常: [tool.devx] 已存在, devx 版本一致。" + }, "Configuring branch protection for {branch}...": { "bg": "Конфигуриране на защита на клона {branch}...", "de": "Konfiguriere Branch-Schutz für {branch}...", @@ -439,13 +519,13 @@ "ru": "Настройка параметров репозитория...", "zh": "正在配置仓库设置..." }, - "Configuration OK: [tool.devx] present, devx versions consistent.": { - "bg": "Конфигурацията е OK: [tool.devx] присъства, версиите на devx са консистентни.", - "de": "Konfiguration OK: [tool.devx] vorhanden, devx-Versionen konsistent.", - "en": "Configuration OK: [tool.devx] present, devx versions consistent.", - "pl": "Konfiguracja OK: [tool.devx] obecne, wersje devx spójne.", - "ru": "Конфигурация OK: [tool.devx] присутствует, версии devx согласованы.", - "zh": "配置正常: [tool.devx] 已存在, devx 版本一致。" + "Could not detect current branch: {error}": { + "bg": "Не може да се определи текущия клон: {error}", + "de": "Aktueller Branch konnte nicht erkannt werden: {error}", + "en": "Could not detect current branch: {error}", + "pl": "Nie można wykryć bieżącej gałęzi: {error}", + "ru": "Не удалось определить текущую ветку: {error}", + "zh": "无法检测当前分支: {error}" }, "Could not extract conventional commit message from PR commits.": { "bg": "Could not extract conventional commit message from PR commits.", @@ -455,6 +535,22 @@ "ru": "Could not extract conventional commit message from PR commits.", "zh": "Could not extract conventional commit message from PR commits." }, + "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).": { + "bg": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).", + "de": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).", + "en": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).", + "pl": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).", + "ru": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).", + "zh": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found)." + }, + "Could not find Vikunja task {task_id} in project {project_id}.": { + "bg": "Не е намерена Vikunja задача {task_id} в проект {project_id}.", + "de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.", + "en": "Could not find Vikunja task {task_id} in project {project_id}.", + "pl": "Nie znaleziono zadania Vikunja {task_id} w projekcie {project_id}.", + "ru": "Не найдена задача Vikunja {task_id} в проекте {project_id}.", + "zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。" + }, "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": { "bg": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.", "de": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.", @@ -479,6 +575,22 @@ "ru": "Could not parse test execution time from output.", "zh": "Could not parse test execution time from output." }, + "Created PR #{index}: {title}\n {url}": { + "bg": "Създаден PR #{index}: {title}\n {url}", + "de": "PR erstellt #{index}: {title}\n {url}", + "en": "Created PR #{index}: {title}\n {url}", + "pl": "Utworzono PR #{index}: {title}\n {url}", + "ru": "Создан PR #{index}: {title}\n {url}", + "zh": "已创建 PR #{index}: {title}\n {url}" + }, + "Created Vikunja task: {identifier} (id={task_id})": { + "bg": "Създадена Vikunja задача: {identifier} (id={task_id})", + "de": "Vikunja-Task erstellt: {identifier} (id={task_id})", + "en": "Created Vikunja task: {identifier} (id={task_id})", + "pl": "Utworzono zadanie Vikunja: {identifier} (id={task_id})", + "ru": "Создана задача Vikunja: {identifier} (id={task_id})", + "zh": "已创建 Vikunja 任务: {identifier} (id={task_id})" + }, "Created issue #{issue_id}: {title}": { "bg": "Created issue #{issue_id}: {title}", "de": "Created issue #{issue_id}: {title}", @@ -495,13 +607,13 @@ "ru": "Created release commit.", "zh": "Created release commit." }, - "devx version mismatch across extras: {detail}": { - "bg": "несъответствие на версията на devx между extras: {detail}", - "de": "devx-Versionskonflikt zwischen Extras: {detail}", - "en": "devx version mismatch across extras: {detail}", - "pl": "niezgodność wersji devx między extras: {detail}", - "ru": "несоответствие версии devx между extras: {detail}", - "zh": "devx 版本在 extras 之间不一致: {detail}" + "Dependencies must have documentation comments.": { + "bg": "Dependencies must have documentation comments.", + "de": "Dependencies must have documentation comments.", + "en": "Dependencies must have documentation comments.", + "pl": "Dependencies must have documentation comments.", + "ru": "Dependencies must have documentation comments.", + "zh": "Dependencies must have documentation comments." }, "Docker daemon already running": { "bg": "Докер демонът вече работи", @@ -527,6 +639,14 @@ "ru": "Docker-демон запущен", "zh": "Docker 守护进程已启动" }, + "Dockerfile not found: {path}": { + "bg": "Dockerfile not found: {path}", + "de": "Dockerfile not found: {path}", + "en": "Dockerfile not found: {path}", + "pl": "Dockerfile not found: {path}", + "ru": "Dockerfile not found: {path}", + "zh": "Dockerfile not found: {path}" + }, "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": { "bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", "de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", @@ -575,6 +695,14 @@ "ru": "ERROR: mapping.json not found at {path}", "zh": "ERROR: mapping.json not found at {path}" }, + "FAILED: {count} undocumented dependency/ies": { + "bg": "FAILED: {count} undocumented dependency/ies", + "de": "FAILED: {count} undocumented dependency/ies", + "en": "FAILED: {count} undocumented dependency/ies", + "pl": "FAILED: {count} undocumented dependency/ies", + "ru": "FAILED: {count} undocumented dependency/ies", + "zh": "FAILED: {count} undocumented dependency/ies" + }, "FAILED: {pair} exited with code {code}": { "bg": "FAILED: {pair} exited with code {code}", "de": "FAILED: {pair} exited with code {code}", @@ -583,6 +711,14 @@ "ru": "FAILED: {pair} exited with code {code}", "zh": "FAILED: {pair} exited with code {code}" }, + "Failed images: {names}": { + "bg": "Failed images: {names}", + "de": "Failed images: {names}", + "en": "Failed images: {names}", + "pl": "Failed images: {names}", + "ru": "Failed images: {names}", + "zh": "Failed images: {names}" + }, "Failed to create issue via tea: {error}": { "bg": "Failed to create issue via tea: {error}", "de": "Failed to create issue via tea: {error}", @@ -591,6 +727,14 @@ "ru": "Failed to create issue via tea: {error}", "zh": "Failed to create issue via tea: {error}" }, + "Failed to list versions for {name}: {error}": { + "bg": "Failed to list versions for {name}: {error}", + "de": "Failed to list versions for {name}: {error}", + "en": "Failed to list versions for {name}: {error}", + "pl": "Failed to list versions for {name}: {error}", + "ru": "Failed to list versions for {name}: {error}", + "zh": "Failed to list versions for {name}: {error}" + }, "Found {count} existing wiki pages.": { "bg": "Found {count} existing wiki pages.", "de": "Found {count} existing wiki pages.", @@ -599,6 +743,22 @@ "ru": "Found {count} existing wiki pages.", "zh": "Found {count} existing wiki pages." }, + "Found {count} mutable global(s) — use factory functions or pytest fixtures.": { + "bg": "Found {count} mutable global(s) — use factory functions or pytest fixtures.", + "de": "Found {count} mutable global(s) — use factory functions or pytest fixtures.", + "en": "Found {count} mutable global(s) — use factory functions or pytest fixtures.", + "pl": "Found {count} mutable global(s) — use factory functions or pytest fixtures.", + "ru": "Found {count} mutable global(s) — use factory functions or pytest fixtures.", + "zh": "Found {count} mutable global(s) — use factory functions or pytest fixtures." + }, + "Found {count} stale documentation reference(s)": { + "bg": "Found {count} stale documentation reference(s)", + "de": "Found {count} stale documentation reference(s)", + "en": "Found {count} stale documentation reference(s)", + "pl": "Found {count} stale documentation reference(s)", + "ru": "Found {count} stale documentation reference(s)", + "zh": "Found {count} stale documentation reference(s)" + }, "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": { "bg": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "de": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", @@ -687,6 +847,30 @@ "ru": "Хост Docker недоступен, запускается локальный dockerd...", "zh": "主机 Docker 不可用,正在启动本地 dockerd..." }, + "Image 'tags' must be a list": { + "bg": "Image 'tags' must be a list", + "de": "Image 'tags' must be a list", + "en": "Image 'tags' must be a list", + "pl": "Image 'tags' must be a list", + "ru": "Image 'tags' must be a list", + "zh": "Image 'tags' must be a list" + }, + "Image manifest entry missing 'dockerfile'": { + "bg": "Image manifest entry missing 'dockerfile'", + "de": "Image manifest entry missing 'dockerfile'", + "en": "Image manifest entry missing 'dockerfile'", + "pl": "Image manifest entry missing 'dockerfile'", + "ru": "Image manifest entry missing 'dockerfile'", + "zh": "Image manifest entry missing 'dockerfile'" + }, + "Image manifest entry missing 'name'": { + "bg": "Image manifest entry missing 'name'", + "de": "Image manifest entry missing 'name'", + "en": "Image manifest entry missing 'name'", + "pl": "Image manifest entry missing 'name'", + "ru": "Image manifest entry missing 'name'", + "zh": "Image manifest entry missing 'name'" + }, "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": { "bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}", "de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}", @@ -735,6 +919,22 @@ "ru": "Lint passed.", "zh": "Lint passed." }, + "Manifest file not found: {path}": { + "bg": "Manifest file not found: {path}", + "de": "Manifest file not found: {path}", + "en": "Manifest file not found: {path}", + "pl": "Manifest file not found: {path}", + "ru": "Manifest file not found: {path}", + "zh": "Manifest file not found: {path}" + }, + "Manifest must be a JSON list": { + "bg": "Manifest must be a JSON list", + "de": "Manifest must be a JSON list", + "en": "Manifest must be a JSON list", + "pl": "Manifest must be a JSON list", + "ru": "Manifest must be a JSON list", + "zh": "Manifest must be a JSON list" + }, "Mapped file {file} is empty. Update the content or remove from mapping.json.": { "bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.", "de": "Mapped file {file} is empty. Update the content or remove from mapping.json.", @@ -775,6 +975,14 @@ "ru": "Директория molecule не найдена: {path}", "zh": "未找到 molecule 目录: {path}" }, + "Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})": { + "bg": "Следващи стъпки:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-кратко-описание\n 3. Имплементирайте промените, commit с conventional commit формат\n 4. git push -u origin HEAD\n 5. make create-pr (създава PR с заглавие: {identifier}: {title})", + "de": "Nächste Schritte:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-kurz-beschreibung\n 3. Änderungen implementieren, mit Conventional-Commit-Format committen\n 4. git push -u origin HEAD\n 5. make create-pr (erstellt PR mit Titel: {identifier}: {title})", + "en": "Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})", + "pl": "Następne kroki:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-krótki-opis\n 3. Wprowadź zmiany, commituj w formacie conventional commit\n 4. git push -u origin HEAD\n 5. make create-pr (tworzy PR z tytułem: {identifier}: {title})", + "ru": "Следующие шаги:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-краткое-описание\n 3. Реализуйте изменения, коммитьте в conventional commit формате\n 4. git push -u origin HEAD\n 5. make create-pr (создаёт PR с заголовком: {identifier}: {title})", + "zh": "后续步骤:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-简短描述\n 3. 实现更改,使用 conventional commit 格式提交\n 4. git push -u origin HEAD\n 5. make create-pr (创建 PR,标题: {identifier}: {title})" + }, "Nice! Gitea release {tag} created.": { "bg": "Отлично! Gitea release {tag} е създаден.", "de": "Prima! Gitea-Release {tag} erstellt.", @@ -847,6 +1055,14 @@ "ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", "zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID." }, + "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.": { + "bg": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.", + "de": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.", + "en": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.", + "pl": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.", + "ru": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.", + "zh": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description." + }, "No unreleased changes found. Nothing to release.": { "bg": "No unreleased changes found. Nothing to release.", "de": "No unreleased changes found. Nothing to release.", @@ -863,6 +1079,14 @@ "ru": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.", "zh": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release." }, + "No versions found.": { + "bg": "No versions found.", + "de": "No versions found.", + "en": "No versions found.", + "pl": "No versions found.", + "ru": "No versions found.", + "zh": "No versions found." + }, "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.", @@ -871,6 +1095,14 @@ "ru": "Note: Self-approval not allowed. Posting COMMENT instead.", "zh": "Note: Self-approval not allowed. Posting COMMENT instead." }, + "Only check staged files (for pre-commit)": { + "bg": "Only check staged files (for pre-commit)", + "de": "Only check staged files (for pre-commit)", + "en": "Only check staged files (for pre-commit)", + "pl": "Only check staged files (for pre-commit)", + "ru": "Only check staged files (for pre-commit)", + "zh": "Only check staged files (for pre-commit)" + }, "Oops! Commit message must follow conventional commit format.\n Expected: : \n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE": { "bg": "Опа! Съобщението за commit трябва да следва конвенционален формат.\n Очаква се: : \n Получено: {subject}\n Разрешени типове: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", "de": "Ups! Commit-Nachricht muss dem konventionellen Commit-Format folgen.\n Erwartet: : \n Erhalten: {subject}\n Erlaubte Typen: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE", @@ -959,6 +1191,22 @@ "ru": "PASSED: {pair}", "zh": "PASSED: {pair}" }, + "PR already exists: #{index} — {url}": { + "bg": "PR вече съществува: #{index} — {url}", + "de": "PR existiert bereits: #{index} — {url}", + "en": "PR already exists: #{index} — {url}", + "pl": "PR już istnieje: #{index} — {url}", + "ru": "PR уже существует: #{index} — {url}", + "zh": "PR 已存在: #{index} — {url}" + }, + "PR number (to fetch title from Gitea)": { + "bg": "PR number (to fetch title from Gitea)", + "de": "PR number (to fetch title from Gitea)", + "en": "PR number (to fetch title from Gitea)", + "pl": "PR number (to fetch title from Gitea)", + "ru": "PR number (to fetch title from Gitea)", + "zh": "PR number (to fetch title from Gitea)" + }, "PR number must be an integer, got: {pr_number}": { "bg": "PR number must be an integer, got: {pr_number}", "de": "PR number must be an integer, got: {pr_number}", @@ -967,6 +1215,14 @@ "ru": "PR number must be an integer, got: {pr_number}", "zh": "PR number must be an integer, got: {pr_number}" }, + "PR title (auto-fetched if --pr-number given)": { + "bg": "PR title (auto-fetched if --pr-number given)", + "de": "PR title (auto-fetched if --pr-number given)", + "en": "PR title (auto-fetched if --pr-number given)", + "pl": "PR title (auto-fetched if --pr-number given)", + "ru": "PR title (auto-fetched if --pr-number given)", + "zh": "PR title (auto-fetched if --pr-number given)" + }, "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": { "bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", "de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", @@ -975,6 +1231,30 @@ "ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", "zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}" }, + "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}": { + "bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}", + "de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}", + "en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}", + "pl": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}", + "ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}", + "zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}" + }, + "PR title must follow format '{prefix}-N: '.\n Got: {title}": { + "bg": "PR title must follow format '{prefix}-N: '.\n Got: {title}", + "de": "PR title must follow format '{prefix}-N: '.\n Got: {title}", + "en": "PR title must follow format '{prefix}-N: '.\n Got: {title}", + "pl": "PR title must follow format '{prefix}-N: '.\n Got: {title}", + "ru": "PR title must follow format '{prefix}-N: '.\n Got: {title}", + "zh": "PR title must follow format '{prefix}-N: '.\n Got: {title}" + }, + "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}": { + "bg": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}", + "de": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}", + "en": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}", + "pl": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}", + "ru": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}", + "zh": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}" + }, "PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.": { "bg": "PYPI_TOKEN не е зададен и няма конфигуриран URL на registry — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.", "de": "PYPI_TOKEN nicht gesetzt und keine Registry-URL konfiguriert — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.", @@ -991,6 +1271,14 @@ "ru": "Извлечён owner={owner}, repo={repo} из DEVX_REPO_NAME", "zh": "从 DEVX_REPO_NAME 解析 owner={owner}, repo={repo}" }, + "Path to pyproject.toml (default: pyproject.toml in CWD).": { + "bg": "Path to pyproject.toml (default: pyproject.toml in CWD).", + "de": "Path to pyproject.toml (default: pyproject.toml in CWD).", + "en": "Path to pyproject.toml (default: pyproject.toml in CWD).", + "pl": "Path to pyproject.toml (default: pyproject.toml in CWD).", + "ru": "Path to pyproject.toml (default: pyproject.toml in CWD).", + "zh": "Path to pyproject.toml (default: pyproject.toml in CWD)." + }, "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.": { "bg": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.", "de": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.", @@ -999,6 +1287,38 @@ "ru": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.", "zh": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit." }, + "Pre-merge validation failed.": { + "bg": "Pre-merge validation failed.", + "de": "Pre-merge validation failed.", + "en": "Pre-merge validation failed.", + "pl": "Pre-merge validation failed.", + "ru": "Pre-merge validation failed.", + "zh": "Pre-merge validation failed." + }, + "Pre-push check passed: task {task_id} exists.": { + "bg": "Pre-push проверката премина: задача {task_id} съществува.", + "de": "Pre-push-Prüfung bestanden: Task {task_id} existiert.", + "en": "Pre-push check passed: task {task_id} exists.", + "pl": "Sprawdzanie pre-push zakończone: zadanie {task_id} istnieje.", + "ru": "Pre-push проверка пройдена: задача {task_id} существует.", + "zh": "Pre-push 检查通过: 任务 {task_id} 存在。" + }, + "Print warnings but always exit 0": { + "bg": "Print warnings but always exit 0", + "de": "Print warnings but always exit 0", + "en": "Print warnings but always exit 0", + "pl": "Print warnings but always exit 0", + "ru": "Print warnings but always exit 0", + "zh": "Print warnings but always exit 0" + }, + "Provide --manifest or both --dockerfile and --name": { + "bg": "Provide --manifest or both --dockerfile and --name", + "de": "Provide --manifest or both --dockerfile and --name", + "en": "Provide --manifest or both --dockerfile and --name", + "pl": "Provide --manifest or both --dockerfile and --name", + "ru": "Provide --manifest or both --dockerfile and --name", + "zh": "Provide --manifest or both --dockerfile and --name" + }, "Provide a commit message file or use --git.": { "bg": "Provide a commit message file or use --git.", "de": "Provide a commit message file or use --git.", @@ -1031,6 +1351,14 @@ "ru": "Publishing release {tag}...", "zh": "Publishing release {tag}..." }, + "Push failed for {tag}: {error}": { + "bg": "Push failed for {tag}: {error}", + "de": "Push failed for {tag}: {error}", + "en": "Push failed for {tag}: {error}", + "pl": "Push failed for {tag}: {error}", + "ru": "Push failed for {tag}: {error}", + "zh": "Push failed for {tag}: {error}" + }, "Pushed release commit to master.": { "bg": "Pushed release commit to master.", "de": "Pushed release commit to master.", @@ -1047,6 +1375,54 @@ "ru": "Публикация в PyPI не удалась (некритично — продолжаем создание Gitea release):\n{error}", "zh": "PyPI 发布失败(非致命 — 继续创建 Gitea release):\n{error}" }, + "REPO argument is required (or set GITHUB_REPOSITORY env var).": { + "bg": "REPO argument is required (or set GITHUB_REPOSITORY env var).", + "de": "REPO argument is required (or set GITHUB_REPOSITORY env var).", + "en": "REPO argument is required (or set GITHUB_REPOSITORY env var).", + "pl": "Argument REPO jest wymagany (lub ustaw zmienną GITHUB_REPOSITORY).", + "ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).", + "zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)." + }, + "REPO_TOKEN environment variable required": { + "bg": "REPO_TOKEN environment variable required", + "de": "REPO_TOKEN environment variable required", + "en": "REPO_TOKEN environment variable required", + "pl": "REPO_TOKEN environment variable required", + "ru": "REPO_TOKEN environment variable required", + "zh": "REPO_TOKEN environment variable required" + }, + "REPO_TOKEN is not set. Required to create a PR.": { + "bg": "REPO_TOKEN не е зададен. Необходим за създаване на PR.", + "de": "REPO_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.", + "en": "REPO_TOKEN is not set. Required to create a PR.", + "pl": "REPO_TOKEN nie jest ustawiony. Wymagany do utworzenia PR.", + "ru": "REPO_TOKEN не установлен. Требуется для создания PR.", + "zh": "REPO_TOKEN 未设置。创建 PR 所需。" + }, + "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars": { + "bg": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars", + "de": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars", + "en": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars", + "pl": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars", + "ru": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars", + "zh": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars" + }, + "Registry login failed": { + "bg": "Registry login failed", + "de": "Registry login failed", + "en": "Registry login failed", + "pl": "Registry login failed", + "ru": "Registry login failed", + "zh": "Registry login failed" + }, + "Registry login failed: {error}": { + "bg": "Registry login failed: {error}", + "de": "Registry login failed: {error}", + "en": "Registry login failed: {error}", + "pl": "Registry login failed: {error}", + "ru": "Registry login failed: {error}", + "zh": "Registry login failed: {error}" + }, "Release creation failed: {error}": { "bg": "Release creation failed: {error}", "de": "Release creation failed: {error}", @@ -1079,6 +1455,30 @@ "ru": "Конфигурация репозитория завершена.", "zh": "仓库配置完成。" }, + "Repository in owner/name format": { + "bg": "Repository in owner/name format", + "de": "Repository in owner/name format", + "en": "Repository in owner/name format", + "pl": "Repository in owner/name format", + "ru": "Repository in owner/name format", + "zh": "Repository in owner/name format" + }, + "Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.": { + "bg": "Името на хранилището не е зададено. Използвайте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.", + "de": "Repository-Name nicht gesetzt. Verwende DEVX_REPO_NAME oder GITHUB_REPOSITORY env var.", + "en": "Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.", + "pl": "Nazwa repozytorium nie jest ustawiona. Użyj DEVX_REPO_NAME lub GITHUB_REPOSITORY env var.", + "ru": "Имя репозитория не установлено. Используйте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.", + "zh": "仓库名称未设置。使用 DEVX_REPO_NAME 或 GITHUB_REPOSITORY 环境变量。" + }, + "Repository owner not set. Use --owner or 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.", + "en": "Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.", + "pl": "Właściciel repozytorium nie jest ustawiony. Użyj --owner lub DEVX_REPO_OWNER env var.", + "ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.", + "zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。" + }, "Roles directory not found: {path}": { "bg": "Roles directory not found: {path}", "de": "Roles directory not found: {path}", @@ -1119,6 +1519,22 @@ "ru": "Running: {scenario} on {platform}", "zh": "Running: {scenario} on {platform}" }, + "Skip Vikunja title match check": { + "bg": "Skip Vikunja title match check", + "de": "Skip Vikunja title match check", + "en": "Skip Vikunja title match check", + "pl": "Skip Vikunja title match check", + "ru": "Skip Vikunja title match check", + "zh": "Skip Vikunja title match check" + }, + "Skip branch-behind-master check": { + "bg": "Skip branch-behind-master check", + "de": "Skip branch-behind-master check", + "en": "Skip branch-behind-master check", + "pl": "Skip branch-behind-master check", + "ru": "Skip branch-behind-master check", + "zh": "Skip branch-behind-master check" + }, "Skipping commit push — no staged changes.": { "bg": "Skipping commit push — no staged changes.", "de": "Skipping commit push — no staged changes.", @@ -1151,14 +1567,6 @@ "ru": "Tag is required (or use --from-tag).", "zh": "Tag is required (or use --from-tag)." }, - "REPO argument is required (or set GITHUB_REPOSITORY env var).": { - "bg": "REPO argument is required (or set GITHUB_REPOSITORY env var).", - "de": "REPO argument is required (or set GITHUB_REPOSITORY env var).", - "en": "REPO argument is required (or set GITHUB_REPOSITORY env var).", - "pl": "Argument REPO jest wymagany (lub ustaw zmienną GITHUB_REPOSITORY).", - "ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).", - "zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)." - }, "Tag v{version} already existed. Publish workflow should already have been triggered.": { "bg": "Tag v{version} already existed. Publish workflow should already have been triggered.", "de": "Tag v{version} already existed. Publish workflow should already have been triggered.", @@ -1255,6 +1663,22 @@ "ru": "Updated {changelog_file}", "zh": "Updated {changelog_file}" }, + "VIKUNJA_TOKEN is not set. Required to derive PR title.": { + "bg": "VIKUNJA_TOKEN не е зададен. Необходим за извличане на PR заглавие.", + "de": "VIKUNJA_TOKEN nicht gesetzt. Erforderlich zum Ableiten des PR-Titels.", + "en": "VIKUNJA_TOKEN is not set. Required to derive PR title.", + "pl": "VIKUNJA_TOKEN nie jest ustawiony. Wymagany do pobrania tytułu PR.", + "ru": "VIKUNJA_TOKEN не установлен. Требуется для получения заголовка PR.", + "zh": "VIKUNJA_TOKEN 未设置。推导 PR 标题所需。" + }, + "VIKUNJA_TOKEN is not set. Set it in .env or environment.": { + "bg": "VIKUNJA_TOKEN не е зададен. Задайте го в .env или средата.", + "de": "VIKUNJA_TOKEN nicht gesetzt. In .env oder Umgebung setzen.", + "en": "VIKUNJA_TOKEN is not set. Set it in .env or environment.", + "pl": "VIKUNJA_TOKEN nie jest ustawiony. Ustaw go w .env lub środowisku.", + "ru": "VIKUNJA_TOKEN не установлен. Установите его в .env или среде.", + "zh": "VIKUNJA_TOKEN 未设置。在 .env 或环境中设置它。" + }, "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.": { "bg": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", "de": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", @@ -1279,6 +1703,14 @@ "ru": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", "zh": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update." }, + "Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.": { + "bg": "Vikunja задача {task_id} не е намерена в проект {project_id}.\n Създайте я първо:\n python -m devx.tools.create_task --title \"Заглавие на задача\"\n Или проверете че ID на задачата в името на клона е правилно.", + "de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.\n Zuerst erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"\n Oder prüfen, ob die Task-ID im Branch-Namen korrekt ist.", + "en": "Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.", + "pl": "Zadanie Vikunja {task_id} nie znalezione w projekcie {project_id}.\n Utwórz je najpierw:\n python -m devx.tools.create_task --title \"Tytuł zadania\"\n Lub sprawdź, czy ID zadania w nazwie gałęzi jest poprawne.", + "ru": "Задача Vikunja {task_id} не найдена в проекте {project_id}.\n Сначала создайте её:\n python -m devx.tools.create_task --title \"Заголовок задачи\"\n Или проверьте, что ID задачи в имени ветки корректен.", + "zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。\n 请先创建:\n python -m devx.tools.create_task --title \"任务标题\"\n 或检查分支名称中的任务 ID 是否正确。" + }, "WARNING: --skip-tests passed — skipping test verification.": { "bg": "WARNING: --skip-tests passed — skipping test verification.", "de": "WARNING: --skip-tests passed — skipping test verification.", @@ -1295,6 +1727,14 @@ "ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.", "zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。" }, + "WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": { + "bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.", + "de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.", + "en": "WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.", + "pl": "OSTRZEŻENIE: VIKUNJA_TOKEN nie jest ustawiony — pomijanie sprawdzania istnienia zadania. Ustaw w .env, aby włączyć pełną walidację.", + "ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.", + "zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。" + }, "Warning: could not fetch tags from origin.": { "bg": "Warning: could not fetch tags from origin.", "de": "Warning: could not fetch tags from origin.", @@ -1319,13 +1759,45 @@ "ru": "Wiki verification failed — {failures} page(s) empty or mismatched", "zh": "Wiki verification failed — {failures} page(s) empty or mismatched" }, - "[tool.devx] missing required keys: {keys}": { - "bg": "[tool.devx] липсват задължителни ключове: {keys}", - "de": "[tool.devx] fehlt erforderliche Schlüssel: {keys}", - "en": "[tool.devx] missing required keys: {keys}", - "pl": "[tool.devx] brak wymaganych kluczy: {keys}", - "ru": "[tool.devx] отсутствуют обязательные ключи: {keys}", - "zh": "[tool.devx] 缺少必需的键: {keys}" + "Wrote tag {tag} to GITHUB_OUTPUT.": { + "bg": "Wrote tag {tag} to GITHUB_OUTPUT.", + "de": "Wrote tag {tag} to GITHUB_OUTPUT.", + "en": "Wrote tag {tag} to GITHUB_OUTPUT.", + "ru": "Wrote tag {tag} to GITHUB_OUTPUT.", + "zh": "Wrote tag {tag} to GITHUB_OUTPUT.", + "pl": "Wrote tag {tag} to GITHUB_OUTPUT." + }, + "[check-dep-docs] Passed: all dependencies are documented": { + "bg": "[check-dep-docs] Passed: all dependencies are documented", + "de": "[check-dep-docs] Passed: all dependencies are documented", + "en": "[check-dep-docs] Passed: all dependencies are documented", + "pl": "[check-dep-docs] Passed: all dependencies are documented", + "ru": "[check-dep-docs] Passed: all dependencies are documented", + "zh": "[check-dep-docs] Passed: all dependencies are documented" + }, + "[check-mutable-globals] Passed: no mutable path globals found": { + "bg": "[check-mutable-globals] Passed: no mutable path globals found", + "de": "[check-mutable-globals] Passed: no mutable path globals found", + "en": "[check-mutable-globals] Passed: no mutable path globals found", + "pl": "[check-mutable-globals] Passed: no mutable path globals found", + "ru": "[check-mutable-globals] Passed: no mutable path globals found", + "zh": "[check-mutable-globals] Passed: no mutable path globals found" + }, + "[check_agent_docs] Passed: scanned {count} file(s), no stale references": { + "bg": "[check_agent_docs] Passed: scanned {count} file(s), no stale references", + "de": "[check_agent_docs] Passed: scanned {count} file(s), no stale references", + "en": "[check_agent_docs] Passed: scanned {count} file(s), no stale references", + "pl": "[check_agent_docs] Passed: scanned {count} file(s), no stale references", + "ru": "[check_agent_docs] Passed: scanned {count} file(s), no stale references", + "zh": "[check_agent_docs] Passed: scanned {count} file(s), no stale references" + }, + "[check_test_coverage] No changed files to check.": { + "bg": "[check_test_coverage] No changed files to check.", + "de": "[check_test_coverage] No changed files to check.", + "en": "[check_test_coverage] No changed files to check.", + "pl": "[check_test_coverage] No changed files to check.", + "ru": "[check_test_coverage] No changed files to check.", + "zh": "[check_test_coverage] No changed files to check." }, "[dry-run] Would commit: release: v{version}": { "bg": "[dry-run] Would commit: release: v{version}", @@ -1383,6 +1855,14 @@ "ru": "[dry-run] Would update {init}", "zh": "[dry-run] Would update {init}" }, + "[tool.devx] missing required keys: {keys}": { + "bg": "[tool.devx] липсват задължителни ключове: {keys}", + "de": "[tool.devx] fehlt erforderliche Schlüssel: {keys}", + "en": "[tool.devx] missing required keys: {keys}", + "pl": "[tool.devx] brak wymaganych kluczy: {keys}", + "ru": "[tool.devx] отсутствуют обязательные ключи: {keys}", + "zh": "[tool.devx] 缺少必需的键: {keys}" + }, "active": { "bg": "активен", "de": "aktiv", @@ -1399,6 +1879,14 @@ "ru": "завершён", "zh": "已完成" }, + "devx version mismatch across extras: {detail}": { + "bg": "несъответствие на версията на devx между extras: {detail}", + "de": "devx-Versionskonflikt zwischen Extras: {detail}", + "en": "devx version mismatch across extras: {detail}", + "pl": "niezgodność wersji devx między extras: {detail}", + "ru": "несоответствие версии devx между extras: {detail}", + "zh": "devx 版本在 extras 之间不一致: {detail}" + }, "failed": { "bg": "неуспешен", "de": "fehlgeschlagen", @@ -1502,357 +1990,5 @@ "pl": "{file} już istnieje. Użyj --force, aby nadpisać.", "ru": "{file} already exists. Use --force to overwrite.", "zh": "{file} already exists. Use --force to overwrite." - }, - "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description": { - "bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание", - "de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung", - "en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description", - "pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis", - "ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание", - "zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述" - }, - "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"": { - "bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание\n Пример: {prefix}-42-add-feature\n Решение: преименувайте клона или създайте Vikunja задача:\n python -m devx.tools.create_task --title \"Заглавие на задача\"", - "de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung\n Beispiel: {prefix}-42-add-feature\n Fix: Branch umbenennen oder Vikunja-Task erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"", - "en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"", - "pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis\n Przykład: {prefix}-42-add-feature\n Naprawa: zmień nazwę gałęzi lub utwórz zadanie Vikunja:\n python -m devx.tools.create_task --title \"Tytuł zadania\"", - "ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание\n Пример: {prefix}-42-add-feature\n Исправление: переименуйте ветку или создайте задачу Vikunja:\n python -m devx.tools.create_task --title \"Заголовок задачи\"", - "zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述\n 示例: {prefix}-42-add-feature\n 修复: 重命名分支或先创建 Vikunja 任务:\n python -m devx.tools.create_task --title \"任务标题\"" - }, - "Could not find Vikunja task {task_id} in project {project_id}.": { - "bg": "Не е намерена Vikunja задача {task_id} в проект {project_id}.", - "de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.", - "en": "Could not find Vikunja task {task_id} in project {project_id}.", - "pl": "Nie znaleziono zadania Vikunja {task_id} w projekcie {project_id}.", - "ru": "Не найдена задача Vikunja {task_id} в проекте {project_id}.", - "zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。" - }, - "Could not detect current branch: {error}": { - "bg": "Не може да се определи текущия клон: {error}", - "de": "Aktueller Branch konnte nicht erkannt werden: {error}", - "en": "Could not detect current branch: {error}", - "pl": "Nie można wykryć bieżącej gałęzi: {error}", - "ru": "Не удалось определить текущую ветку: {error}", - "zh": "无法检测当前分支: {error}" - }, - "Created PR #{index}: {title}\n {url}": { - "bg": "Създаден PR #{index}: {title}\n {url}", - "de": "PR erstellt #{index}: {title}\n {url}", - "en": "Created PR #{index}: {title}\n {url}", - "pl": "Utworzono PR #{index}: {title}\n {url}", - "ru": "Создан PR #{index}: {title}\n {url}", - "zh": "已创建 PR #{index}: {title}\n {url}" - }, - "Created Vikunja task: {identifier} (id={task_id})": { - "bg": "Създадена Vikunja задача: {identifier} (id={task_id})", - "de": "Vikunja-Task erstellt: {identifier} (id={task_id})", - "en": "Created Vikunja task: {identifier} (id={task_id})", - "pl": "Utworzono zadanie Vikunja: {identifier} (id={task_id})", - "ru": "Создана задача Vikunja: {identifier} (id={task_id})", - "zh": "已创建 Vikunja 任务: {identifier} (id={task_id})" - }, - "Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})": { - "bg": "Следващи стъпки:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-кратко-описание\n 3. Имплементирайте промените, commit с conventional commit формат\n 4. git push -u origin HEAD\n 5. make create-pr (създава PR с заглавие: {identifier}: {title})", - "de": "Nächste Schritte:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-kurz-beschreibung\n 3. Änderungen implementieren, mit Conventional-Commit-Format committen\n 4. git push -u origin HEAD\n 5. make create-pr (erstellt PR mit Titel: {identifier}: {title})", - "en": "Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})", - "pl": "Następne kroki:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-krótki-opis\n 3. Wprowadź zmiany, commituj w formacie conventional commit\n 4. git push -u origin HEAD\n 5. make create-pr (tworzy PR z tytułem: {identifier}: {title})", - "ru": "Следующие шаги:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-краткое-описание\n 3. Реализуйте изменения, коммитьте в conventional commit формате\n 4. git push -u origin HEAD\n 5. make create-pr (создаёт PR с заголовком: {identifier}: {title})", - "zh": "后续步骤:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-简短描述\n 3. 实现更改,使用 conventional commit 格式提交\n 4. git push -u origin HEAD\n 5. make create-pr (创建 PR,标题: {identifier}: {title})" - }, - "PR already exists: #{index} — {url}": { - "bg": "PR вече съществува: #{index} — {url}", - "de": "PR existiert bereits: #{index} — {url}", - "en": "PR already exists: #{index} — {url}", - "pl": "PR już istnieje: #{index} — {url}", - "ru": "PR уже существует: #{index} — {url}", - "zh": "PR 已存在: #{index} — {url}" - }, - "Pre-push check passed: task {task_id} exists.": { - "bg": "Pre-push проверката премина: задача {task_id} съществува.", - "de": "Pre-push-Prüfung bestanden: Task {task_id} existiert.", - "en": "Pre-push check passed: task {task_id} exists.", - "pl": "Sprawdzanie pre-push zakończone: zadanie {task_id} istnieje.", - "ru": "Pre-push проверка пройдена: задача {task_id} существует.", - "zh": "Pre-push 检查通过: 任务 {task_id} 存在。" - }, - "REPO_TOKEN is not set. Required to create a PR.": { - "bg": "REPO_TOKEN не е зададен. Необходим за създаване на PR.", - "de": "REPO_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.", - "en": "REPO_TOKEN is not set. Required to create a PR.", - "pl": "REPO_TOKEN nie jest ustawiony. Wymagany do utworzenia PR.", - "ru": "REPO_TOKEN не установлен. Требуется для создания PR.", - "zh": "REPO_TOKEN 未设置。创建 PR 所需。" - }, - "Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.": { - "bg": "Името на хранилището не е зададено. Използвайте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.", - "de": "Repository-Name nicht gesetzt. Verwende DEVX_REPO_NAME oder GITHUB_REPOSITORY env var.", - "en": "Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.", - "pl": "Nazwa repozytorium nie jest ustawiona. Użyj DEVX_REPO_NAME lub GITHUB_REPOSITORY env var.", - "ru": "Имя репозитория не установлено. Используйте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.", - "zh": "仓库名称未设置。使用 DEVX_REPO_NAME 或 GITHUB_REPOSITORY 环境变量。" - }, - "Repository owner not set. Use --owner or 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.", - "en": "Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.", - "pl": "Właściciel repozytorium nie jest ustawiony. Użyj --owner lub DEVX_REPO_OWNER env var.", - "ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.", - "zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。" - }, - "VIKUNJA_TOKEN is not set. Required to derive PR title.": { - "bg": "VIKUNJA_TOKEN не е зададен. Необходим за извличане на PR заглавие.", - "de": "VIKUNJA_TOKEN nicht gesetzt. Erforderlich zum Ableiten des PR-Titels.", - "en": "VIKUNJA_TOKEN is not set. Required to derive PR title.", - "pl": "VIKUNJA_TOKEN nie jest ustawiony. Wymagany do pobrania tytułu PR.", - "ru": "VIKUNJA_TOKEN не установлен. Требуется для получения заголовка PR.", - "zh": "VIKUNJA_TOKEN 未设置。推导 PR 标题所需。" - }, - "VIKUNJA_TOKEN is not set. Set it in .env or environment.": { - "bg": "VIKUNJA_TOKEN не е зададен. Задайте го в .env или средата.", - "de": "VIKUNJA_TOKEN nicht gesetzt. In .env oder Umgebung setzen.", - "en": "VIKUNJA_TOKEN is not set. Set it in .env or environment.", - "pl": "VIKUNJA_TOKEN nie jest ustawiony. Ustaw go w .env lub środowisku.", - "ru": "VIKUNJA_TOKEN не установлен. Установите его в .env или среде.", - "zh": "VIKUNJA_TOKEN 未设置。在 .env 或环境中设置它。" - }, - "Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.": { - "bg": "Vikunja задача {task_id} не е намерена в проект {project_id}.\n Създайте я първо:\n python -m devx.tools.create_task --title \"Заглавие на задача\"\n Или проверете че ID на задачата в името на клона е правилно.", - "de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.\n Zuerst erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"\n Oder prüfen, ob die Task-ID im Branch-Namen korrekt ist.", - "en": "Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.", - "pl": "Zadanie Vikunja {task_id} nie znalezione w projekcie {project_id}.\n Utwórz je najpierw:\n python -m devx.tools.create_task --title \"Tytuł zadania\"\n Lub sprawdź, czy ID zadania w nazwie gałęzi jest poprawne.", - "ru": "Задача Vikunja {task_id} не найдена в проекте {project_id}.\n Сначала создайте её:\n python -m devx.tools.create_task --title \"Заголовок задачи\"\n Или проверьте, что ID задачи в имени ветки корректен.", - "zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。\n 请先创建:\n python -m devx.tools.create_task --title \"任务标题\"\n 或检查分支名称中的任务 ID 是否正确。" - }, - "WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": { - "bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.", - "de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.", - "en": "WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.", - "pl": "OSTRZEŻENIE: VIKUNJA_TOKEN nie jest ustawiony — pomijanie sprawdzania istnienia zadania. Ustaw w .env, aby włączyć pełną walidację.", - "ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.", - "zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。" - }, - "[check-mutable-globals] Passed: no mutable path globals found": { - "bg": "[check-mutable-globals] Passed: no mutable path globals found", - "de": "[check-mutable-globals] Passed: no mutable path globals found", - "en": "[check-mutable-globals] Passed: no mutable path globals found", - "pl": "[check-mutable-globals] Passed: no mutable path globals found", - "ru": "[check-mutable-globals] Passed: no mutable path globals found", - "zh": "[check-mutable-globals] Passed: no mutable path globals found" - }, - "[check_agent_docs] Passed: scanned {count} file(s), no stale references": { - "bg": "[check_agent_docs] Passed: scanned {count} file(s), no stale references", - "de": "[check_agent_docs] Passed: scanned {count} file(s), no stale references", - "en": "[check_agent_docs] Passed: scanned {count} file(s), no stale references", - "pl": "[check_agent_docs] Passed: scanned {count} file(s), no stale references", - "ru": "[check_agent_docs] Passed: scanned {count} file(s), no stale references", - "zh": "[check_agent_docs] Passed: scanned {count} file(s), no stale references" - }, - "[check_test_coverage] No changed files to check.": { - "bg": "[check_test_coverage] No changed files to check.", - "de": "[check_test_coverage] No changed files to check.", - "en": "[check_test_coverage] No changed files to check.", - "pl": "[check_test_coverage] No changed files to check.", - "ru": "[check_test_coverage] No changed files to check.", - "zh": "[check_test_coverage] No changed files to check." - }, - "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.", - "en": "Additional directory to scan (default: scripts, tests). Can be repeated.", - "pl": "Additional directory to scan (default: scripts, tests). Can be repeated.", - "ru": "Additional directory to scan (default: scripts, tests). Can be repeated.", - "zh": "Additional directory to scan (default: scripts, tests). Can be repeated." - }, - "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master": { - "bg": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master", - "de": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master", - "en": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master", - "pl": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master", - "ru": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master", - "zh": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master" - }, - "Branch name (e.g., DEVX-256-fix-foo)": { - "bg": "Branch name (e.g., DEVX-256-fix-foo)", - "de": "Branch name (e.g., DEVX-256-fix-foo)", - "en": "Branch name (e.g., DEVX-256-fix-foo)", - "pl": "Branch name (e.g., DEVX-256-fix-foo)", - "ru": "Branch name (e.g., DEVX-256-fix-foo)", - "zh": "Branch name (e.g., DEVX-256-fix-foo)" - }, - "Branch name must contain a task ID.": { - "bg": "Branch name must contain a task ID.", - "de": "Branch name must contain a task ID.", - "en": "Branch name must contain a task ID.", - "pl": "Branch name must contain a task ID.", - "ru": "Branch name must contain a task ID.", - "zh": "Branch name must contain a task ID." - }, - "Check that changed files have corresponding tests": { - "bg": "Check that changed files have corresponding tests", - "de": "Check that changed files have corresponding tests", - "en": "Check that changed files have corresponding tests", - "pl": "Check that changed files have corresponding tests", - "ru": "Check that changed files have corresponding tests", - "zh": "Check that changed files have corresponding tests" - }, - "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).": { - "bg": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).", - "de": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).", - "en": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).", - "pl": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).", - "ru": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).", - "zh": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found)." - }, - "Dependencies must have documentation comments.": { - "bg": "Dependencies must have documentation comments.", - "de": "Dependencies must have documentation comments.", - "en": "Dependencies must have documentation comments.", - "pl": "Dependencies must have documentation comments.", - "ru": "Dependencies must have documentation comments.", - "zh": "Dependencies must have documentation comments." - }, - "FAILED: {count} undocumented dependency/ies": { - "bg": "FAILED: {count} undocumented dependency/ies", - "de": "FAILED: {count} undocumented dependency/ies", - "en": "FAILED: {count} undocumented dependency/ies", - "pl": "FAILED: {count} undocumented dependency/ies", - "ru": "FAILED: {count} undocumented dependency/ies", - "zh": "FAILED: {count} undocumented dependency/ies" - }, - "Found {count} mutable global(s) — use factory functions or pytest fixtures.": { - "bg": "Found {count} mutable global(s) — use factory functions or pytest fixtures.", - "de": "Found {count} mutable global(s) — use factory functions or pytest fixtures.", - "en": "Found {count} mutable global(s) — use factory functions or pytest fixtures.", - "pl": "Found {count} mutable global(s) — use factory functions or pytest fixtures.", - "ru": "Found {count} mutable global(s) — use factory functions or pytest fixtures.", - "zh": "Found {count} mutable global(s) — use factory functions or pytest fixtures." - }, - "Found {count} stale documentation reference(s)": { - "bg": "Found {count} stale documentation reference(s)", - "de": "Found {count} stale documentation reference(s)", - "en": "Found {count} stale documentation reference(s)", - "pl": "Found {count} stale documentation reference(s)", - "ru": "Found {count} stale documentation reference(s)", - "zh": "Found {count} stale documentation reference(s)" - }, - "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.": { - "bg": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.", - "de": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.", - "en": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.", - "pl": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.", - "ru": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.", - "zh": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description." - }, - "Only check staged files (for pre-commit)": { - "bg": "Only check staged files (for pre-commit)", - "de": "Only check staged files (for pre-commit)", - "en": "Only check staged files (for pre-commit)", - "pl": "Only check staged files (for pre-commit)", - "ru": "Only check staged files (for pre-commit)", - "zh": "Only check staged files (for pre-commit)" - }, - "PR number (to fetch title from Gitea)": { - "bg": "PR number (to fetch title from Gitea)", - "de": "PR number (to fetch title from Gitea)", - "en": "PR number (to fetch title from Gitea)", - "pl": "PR number (to fetch title from Gitea)", - "ru": "PR number (to fetch title from Gitea)", - "zh": "PR number (to fetch title from Gitea)" - }, - "PR title (auto-fetched if --pr-number given)": { - "bg": "PR title (auto-fetched if --pr-number given)", - "de": "PR title (auto-fetched if --pr-number given)", - "en": "PR title (auto-fetched if --pr-number given)", - "pl": "PR title (auto-fetched if --pr-number given)", - "ru": "PR title (auto-fetched if --pr-number given)", - "zh": "PR title (auto-fetched if --pr-number given)" - }, - "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}": { - "bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}", - "de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}", - "en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}", - "pl": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}", - "ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}", - "zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}" - }, - "PR title must follow format '{prefix}-N: '.\n Got: {title}": { - "bg": "PR title must follow format '{prefix}-N: '.\n Got: {title}", - "de": "PR title must follow format '{prefix}-N: '.\n Got: {title}", - "en": "PR title must follow format '{prefix}-N: '.\n Got: {title}", - "pl": "PR title must follow format '{prefix}-N: '.\n Got: {title}", - "ru": "PR title must follow format '{prefix}-N: '.\n Got: {title}", - "zh": "PR title must follow format '{prefix}-N: '.\n Got: {title}" - }, - "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}": { - "bg": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}", - "de": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}", - "en": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}", - "pl": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}", - "ru": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}", - "zh": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}" - }, - "Path to pyproject.toml (default: pyproject.toml in CWD).": { - "bg": "Path to pyproject.toml (default: pyproject.toml in CWD).", - "de": "Path to pyproject.toml (default: pyproject.toml in CWD).", - "en": "Path to pyproject.toml (default: pyproject.toml in CWD).", - "pl": "Path to pyproject.toml (default: pyproject.toml in CWD).", - "ru": "Path to pyproject.toml (default: pyproject.toml in CWD).", - "zh": "Path to pyproject.toml (default: pyproject.toml in CWD)." - }, - "Pre-merge validation failed.": { - "bg": "Pre-merge validation failed.", - "de": "Pre-merge validation failed.", - "en": "Pre-merge validation failed.", - "pl": "Pre-merge validation failed.", - "ru": "Pre-merge validation failed.", - "zh": "Pre-merge validation failed." - }, - "Print warnings but always exit 0": { - "bg": "Print warnings but always exit 0", - "de": "Print warnings but always exit 0", - "en": "Print warnings but always exit 0", - "pl": "Print warnings but always exit 0", - "ru": "Print warnings but always exit 0", - "zh": "Print warnings but always exit 0" - }, - "Repository in owner/name format": { - "bg": "Repository in owner/name format", - "de": "Repository in owner/name format", - "en": "Repository in owner/name format", - "pl": "Repository in owner/name format", - "ru": "Repository in owner/name format", - "zh": "Repository in owner/name format" - }, - "Skip Vikunja title match check": { - "bg": "Skip Vikunja title match check", - "de": "Skip Vikunja title match check", - "en": "Skip Vikunja title match check", - "pl": "Skip Vikunja title match check", - "ru": "Skip Vikunja title match check", - "zh": "Skip Vikunja title match check" - }, - "Skip branch-behind-master check": { - "bg": "Skip branch-behind-master check", - "de": "Skip branch-behind-master check", - "en": "Skip branch-behind-master check", - "pl": "Skip branch-behind-master check", - "ru": "Skip branch-behind-master check", - "zh": "Skip branch-behind-master check" - }, - "[check-dep-docs] Passed: all dependencies are documented": { - "bg": "[check-dep-docs] Passed: all dependencies are documented", - "de": "[check-dep-docs] Passed: all dependencies are documented", - "en": "[check-dep-docs] Passed: all dependencies are documented", - "pl": "[check-dep-docs] Passed: all dependencies are documented", - "ru": "[check-dep-docs] Passed: all dependencies are documented", - "zh": "[check-dep-docs] Passed: all dependencies are documented" - }, - "Wrote tag {tag} to GITHUB_OUTPUT.": { - "bg": "Wrote tag {tag} to GITHUB_OUTPUT.", - "de": "Wrote tag {tag} to GITHUB_OUTPUT.", - "en": "Wrote tag {tag} to GITHUB_OUTPUT.", - "ru": "Wrote tag {tag} to GITHUB_OUTPUT.", - "zh": "Wrote tag {tag} to GITHUB_OUTPUT.", - "pl": "Wrote tag {tag} to GITHUB_OUTPUT." } } diff --git a/tests/unit/test_build_image.py b/tests/unit/test_build_image.py new file mode 100644 index 0000000..624b9ec --- /dev/null +++ b/tests/unit/test_build_image.py @@ -0,0 +1,583 @@ +"""Unit tests for devx.tools.build_image.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from click import ClickException +from click.testing import CliRunner + +import devx.tools.build_image as build_image +from devx.tools.build_image import ( + ImageSpec, + build_full_tag, + load_manifest, + push_image, + registry_login, +) +from devx.tools.build_image import ( + build_image as do_build, +) +from devx.tools.clean_images import select_for_deletion, sort_versions_by_date + + +class TestImageSpec: + def test_from_dict_minimal(self) -> None: + spec = ImageSpec.from_dict({"name": "ci-base", "dockerfile": "docker/ci-base/Dockerfile"}) + assert spec.name == "ci-base" + assert spec.dockerfile == "docker/ci-base/Dockerfile" + assert spec.context == "." + assert spec.tags == ["latest"] + + def test_from_dict_full(self) -> None: + spec = ImageSpec.from_dict( + { + "name": "ci-quality", + "dockerfile": "docker/ci-quality/Dockerfile", + "context": ".", + "tags": ["latest", "0.19.3"], + } + ) + assert spec.name == "ci-quality" + assert spec.dockerfile == "docker/ci-quality/Dockerfile" + assert spec.context == "." + assert spec.tags == ["latest", "0.19.3"] + + def test_from_dict_missing_name(self) -> None: + with pytest.raises(ValueError, match="missing 'name'"): + ImageSpec.from_dict({"dockerfile": "Dockerfile"}) + + def test_from_dict_missing_dockerfile(self) -> None: + with pytest.raises(ValueError, match="missing 'dockerfile'"): + ImageSpec.from_dict({"name": "ci-base"}) + + def test_from_dict_tags_not_list(self) -> None: + with pytest.raises(ValueError, match="tags.*must be a list"): + ImageSpec.from_dict( + { + "name": "ci-base", + "dockerfile": "Dockerfile", + "tags": "latest", + } + ) + + def test_from_dict_empty_tags_defaults_to_latest(self) -> None: + spec = ImageSpec.from_dict( + { + "name": "ci-base", + "dockerfile": "Dockerfile", + "tags": [], + } + ) + assert spec.tags == ["latest"] + + +class TestBuildFullTag: + def test_no_registry(self) -> None: + assert build_full_tag(None, "ci-base", "latest") == "ci-base:latest" + + def test_with_registry(self) -> None: + assert build_full_tag("git.example.com", "ci-base", "0.1.0") == "git.example.com/ci-base:0.1.0" + + def test_with_registry_and_path(self) -> None: + assert ( + build_full_tag("git.example.com", "oblachno/ci-base", "latest") == "git.example.com/oblachno/ci-base:latest" + ) + + +class TestLoadManifest: + def test_load_valid_manifest(self, tmp_path: Path) -> None: + manifest = tmp_path / "images.json" + manifest.write_text( + json.dumps( + [ + {"name": "ci-base", "dockerfile": "docker/ci-base/Dockerfile"}, + {"name": "ci-quality", "dockerfile": "docker/ci-quality/Dockerfile", "tags": ["latest", "1.0"]}, + ] + ) + ) + specs = load_manifest(manifest) + assert len(specs) == 2 + assert specs[0].name == "ci-base" + assert specs[1].tags == ["latest", "1.0"] + + def test_load_missing_file(self, tmp_path: Path) -> None: + with pytest.raises(ClickException, match="not found"): + load_manifest(tmp_path / "nonexistent.json") + + def test_load_not_a_list(self, tmp_path: Path) -> None: + manifest = tmp_path / "images.json" + manifest.write_text(json.dumps({"name": "ci-base"})) + with pytest.raises(ClickException, match="must be a JSON list"): + load_manifest(manifest) + + +class TestRegistryLogin: + def test_success(self) -> None: + mock_result = MagicMock(returncode=0, stderr="", stdout="") + with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run: + assert registry_login("git.example.com", "user", "token") is True + assert mock_run.call_args.args[0] == [ + "docker", + "login", + "git.example.com", + "-u", + "user", + "--password-stdin", + ] + assert mock_run.call_args.kwargs["input"] == "token" + + def test_failure(self) -> None: + mock_result = MagicMock(returncode=1, stderr="auth failed", stdout="") + with patch("devx.tools.build_image.subprocess.run", return_value=mock_result): + assert registry_login("git.example.com", "user", "bad") is False + + def test_dry_run(self) -> None: + with patch("devx.tools.build_image.subprocess.run") as mock_run: + assert registry_login("git.example.com", "user", "token", dry_run=True) is True + mock_run.assert_not_called() + + +class TestBuildImage: + def test_success(self, tmp_path: Path) -> None: + dockerfile = tmp_path / "Dockerfile" + dockerfile.touch() + spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), context=".", tags=["latest"]) + mock_result = MagicMock(returncode=0) + with patch("devx.tools.build_image.subprocess.run", return_value=mock_result): + assert do_build(spec) is True + + def test_dockerfile_not_found(self) -> None: + spec = ImageSpec(name="ci-base", dockerfile="nonexistent/Dockerfile") + assert do_build(spec) is False + + def test_build_failure(self, tmp_path: Path) -> None: + dockerfile = tmp_path / "Dockerfile" + dockerfile.touch() + spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile)) + mock_result = MagicMock(returncode=1) + with patch("devx.tools.build_image.subprocess.run", return_value=mock_result): + assert do_build(spec) is False + + def test_dry_run(self, tmp_path: Path) -> None: + dockerfile = tmp_path / "Dockerfile" + dockerfile.touch() + spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), tags=["latest", "1.0"]) + with patch("devx.tools.build_image.subprocess.run") as mock_run: + assert do_build(spec, dry_run=True) is True + mock_run.assert_not_called() + + def test_with_registry(self, tmp_path: Path) -> None: + dockerfile = tmp_path / "Dockerfile" + dockerfile.touch() + spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), tags=["latest"]) + mock_result = MagicMock(returncode=0) + with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run: + assert do_build(spec, registry="git.example.com") is True + cmd = mock_run.call_args.args[0] + assert "-t" in cmd + idx = cmd.index("-t") + assert cmd[idx + 1] == "git.example.com/ci-base:latest" + + def test_pull_flag(self, tmp_path: Path) -> None: + dockerfile = tmp_path / "Dockerfile" + dockerfile.touch() + spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile)) + mock_result = MagicMock(returncode=0) + with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run: + assert do_build(spec, pull=True) is True + cmd = mock_run.call_args.args[0] + assert "--pull" in cmd + + +class TestPushImage: + def test_success(self) -> None: + spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"]) + mock_result = MagicMock(returncode=0, stderr="", stdout="") + with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run: + assert push_image(spec, "git.example.com") is True + assert mock_run.call_count == 2 + + def test_partial_failure(self) -> None: + spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"]) + results = [ + MagicMock(returncode=0, stderr="", stdout=""), + MagicMock(returncode=1, stderr="push failed", stdout=""), + ] + with patch("devx.tools.build_image.subprocess.run", side_effect=results): + assert push_image(spec, "git.example.com") is False + + def test_dry_run(self) -> None: + spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"]) + with patch("devx.tools.build_image.subprocess.run") as mock_run: + assert push_image(spec, "git.example.com", dry_run=True) is True + mock_run.assert_not_called() + + +class TestSortVersions: + def test_sort_by_created_at_desc(self) -> None: + versions = [ + {"version": "0.1.0", "created_at": "2025-01-01T00:00:00Z"}, + {"version": "0.3.0", "created_at": "2025-03-01T00:00:00Z"}, + {"version": "0.2.0", "created_at": "2025-02-01T00:00:00Z"}, + ] + result = sort_versions_by_date(versions) + assert [v["version"] for v in result] == ["0.3.0", "0.2.0", "0.1.0"] + + def test_sort_fallback_to_version(self) -> None: + versions = [ + {"version": "0.1.0"}, + {"version": "0.3.0"}, + {"version": "0.2.0"}, + ] + result = sort_versions_by_date(versions) + assert [v["version"] for v in result] == ["0.3.0", "0.2.0", "0.1.0"] + + +class TestSelectForDeletion: + def test_keep_2(self) -> None: + versions = [ + {"version": "0.1.0", "created_at": "2025-01-01"}, + {"version": "0.2.0", "created_at": "2025-02-01"}, + {"version": "0.3.0", "created_at": "2025-03-01"}, + {"version": "0.4.0", "created_at": "2025-04-01"}, + ] + to_delete = select_for_deletion(versions, keep=2) + assert len(to_delete) == 2 + assert {v["version"] for v in to_delete} == {"0.1.0", "0.2.0"} + + def test_preserve_latest_tag(self) -> None: + versions = [ + {"version": "latest", "created_at": "2025-01-01"}, + {"version": "0.2.0", "created_at": "2025-02-01"}, + {"version": "0.3.0", "created_at": "2025-03-01"}, + {"version": "0.4.0", "created_at": "2025-04-01"}, + ] + to_delete = select_for_deletion(versions, keep=2) + deleted_versions = {v["version"] for v in to_delete} + assert "latest" not in deleted_versions + # latest is oldest by date but still preserved + assert "0.2.0" in deleted_versions + + def test_keep_all(self) -> None: + versions = [ + {"version": "0.1.0", "created_at": "2025-01-01"}, + {"version": "0.2.0", "created_at": "2025-02-01"}, + ] + to_delete = select_for_deletion(versions, keep=2) + assert len(to_delete) == 0 + + def test_keep_more_than_available(self) -> None: + versions = [ + {"version": "0.1.0", "created_at": "2025-01-01"}, + ] + to_delete = select_for_deletion(versions, keep=5) + assert len(to_delete) == 0 + + +class TestCleanImagesAPI: + """Tests for the clean_images module's API functions.""" + + def test_list_package_versions(self) -> None: + from devx.tools.clean_images import list_package_versions + + mock_resp = MagicMock() + mock_resp.json.return_value = [{"version": "0.1.0"}] + mock_resp.raise_for_status = MagicMock() + with patch("devx.tools.clean_images.requests.get", return_value=mock_resp) as mock_get: + versions = list_package_versions( + "https://git.example.com/api/v1", + "oblachno-oss", + "ci-base", + "token", + ) + assert versions == [{"version": "0.1.0"}] + assert "page=1" in mock_get.call_args.args[0] + + def test_list_package_versions_pagination(self) -> None: + from devx.tools.clean_images import list_package_versions + + # First page: 50 items, second page: 3 items, third page: empty + page1 = [{"version": f"0.{i}.0"} for i in range(50)] + page2 = [{"version": f"1.{i}.0"} for i in range(3)] + responses = [ + MagicMock(json=MagicMock(return_value=page1), raise_for_status=MagicMock()), + MagicMock(json=MagicMock(return_value=page2), raise_for_status=MagicMock()), + MagicMock(json=MagicMock(return_value=[]), raise_for_status=MagicMock()), + ] + with patch("devx.tools.clean_images.requests.get", side_effect=responses): + versions = list_package_versions( + "https://git.example.com/api/v1", + "oblachno-oss", + "ci-base", + "token", + ) + assert len(versions) == 53 + + def test_delete_package_version_success(self) -> None: + from devx.tools.clean_images import delete_package_version + + mock_resp = MagicMock(status_code=204) + with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp): + assert ( + delete_package_version( + "https://git.example.com/api/v1", + "oblachno-oss", + "ci-base", + "0.1.0", + "token", + ) + is True + ) + + def test_delete_package_version_failure(self) -> None: + from devx.tools.clean_images import delete_package_version + + mock_resp = MagicMock(status_code=404) + with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp): + assert ( + delete_package_version( + "https://git.example.com/api/v1", + "oblachno-oss", + "ci-base", + "0.1.0", + "token", + ) + is False + ) + + +class TestCLIBuildImage: + def test_single_image_build(self, tmp_path: Path) -> None: + dockerfile = tmp_path / "Dockerfile" + dockerfile.touch() + runner = CliRunner() + mock_result = MagicMock(returncode=0) + with patch("devx.tools.build_image.subprocess.run", return_value=mock_result): + result = runner.invoke( + build_image.main, + ["--dockerfile", str(dockerfile), "--name", "ci-base", "--tag", "latest"], + ) + assert result.exit_code == 0 + + def test_manifest_build(self, tmp_path: Path) -> None: + dockerfile = tmp_path / "Dockerfile" + dockerfile.touch() + manifest = tmp_path / "images.json" + manifest.write_text( + json.dumps( + [ + {"name": "ci-base", "dockerfile": str(dockerfile)}, + ] + ) + ) + runner = CliRunner() + mock_result = MagicMock(returncode=0) + with patch("devx.tools.build_image.subprocess.run", return_value=mock_result): + result = runner.invoke( + build_image.main, + ["--manifest", str(manifest)], + ) + assert result.exit_code == 0 + + def test_missing_dockerfile_and_manifest(self) -> None: + runner = CliRunner() + result = runner.invoke(build_image.main, []) + assert result.exit_code != 0 + assert "manifest" in result.output.lower() or "dockerfile" in result.output.lower() + + def test_push_without_registry(self, tmp_path: Path) -> None: + dockerfile = tmp_path / "Dockerfile" + dockerfile.touch() + runner = CliRunner() + result = runner.invoke( + build_image.main, + ["--dockerfile", str(dockerfile), "--name", "ci-base", "--push"], + ) + assert result.exit_code != 0 + assert "registry" in result.output.lower() + + def test_push_without_credentials(self, tmp_path: Path) -> None: + dockerfile = tmp_path / "Dockerfile" + dockerfile.touch() + runner = CliRunner() + with patch.dict("os.environ", {}, clear=True): + result = runner.invoke( + build_image.main, + ["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"], + ) + assert result.exit_code != 0 + assert "credential" in result.output.lower() or "token" in result.output.lower() + + def test_dry_run(self, tmp_path: Path) -> None: + dockerfile = tmp_path / "Dockerfile" + dockerfile.touch() + runner = CliRunner() + with patch("devx.tools.build_image.subprocess.run") as mock_run: + result = runner.invoke( + build_image.main, + ["--dockerfile", str(dockerfile), "--name", "ci-base", "--dry-run"], + ) + assert result.exit_code == 0 + mock_run.assert_not_called() + assert "dry-run" in result.output + + def test_build_failure_exits_with_error(self, tmp_path: Path) -> None: + dockerfile = tmp_path / "Dockerfile" + dockerfile.touch() + runner = CliRunner() + mock_result = MagicMock(returncode=1) + with patch("devx.tools.build_image.subprocess.run", return_value=mock_result): + result = runner.invoke( + build_image.main, + ["--dockerfile", str(dockerfile), "--name", "ci-base"], + ) + assert result.exit_code != 0 + + def test_push_login_failure(self, tmp_path: Path) -> None: + dockerfile = tmp_path / "Dockerfile" + dockerfile.touch() + runner = CliRunner() + login_result = MagicMock(returncode=1, stderr="auth failed", stdout="") + with patch.dict("os.environ", {"REPO_TOKEN": "fake", "REGISTRY_USERNAME": "user"}): + with patch("devx.tools.build_image.subprocess.run", return_value=login_result): + result = runner.invoke( + build_image.main, + ["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"], + ) + assert result.exit_code != 0 + assert "login" in result.output.lower() + + def test_push_image_failure(self, tmp_path: Path) -> None: + dockerfile = tmp_path / "Dockerfile" + dockerfile.touch() + runner = CliRunner() + build_result = MagicMock(returncode=0) + login_result = MagicMock(returncode=0, stderr="", stdout="") + push_result = MagicMock(returncode=1, stderr="push failed", stdout="") + with patch.dict("os.environ", {"REPO_TOKEN": "fake", "REGISTRY_USERNAME": "user"}): + with patch( + "devx.tools.build_image.subprocess.run", + side_effect=[login_result, build_result, push_result], + ): + result = runner.invoke( + build_image.main, + ["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"], + ) + assert result.exit_code != 0 + + +class TestCLICleanImages: + def test_dry_run(self) -> None: + from devx.tools.clean_images import main as clean_main + + runner = CliRunner() + mock_resp = MagicMock() + mock_resp.json.return_value = [ + {"version": "0.1.0", "created_at": "2025-01-01"}, + {"version": "0.2.0", "created_at": "2025-02-01"}, + {"version": "0.3.0", "created_at": "2025-03-01"}, + ] + mock_resp.raise_for_status = MagicMock() + with patch.dict("os.environ", {"REPO_TOKEN": "fake"}): + with patch("devx.tools.clean_images.requests.get", return_value=mock_resp): + result = runner.invoke( + clean_main, + ["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "1", "--dry-run"], + ) + assert result.exit_code == 0 + assert "dry-run" in result.output + assert "0.1.0" in result.output + + def test_no_token(self) -> None: + from devx.tools.clean_images import main as clean_main + + runner = CliRunner() + with patch.dict("os.environ", {}, clear=True): + result = runner.invoke( + clean_main, + ["--owner", "oblachno-oss", "--name", "ci-base"], + ) + assert result.exit_code != 0 + assert "token" in result.output.lower() + + def test_no_versions_found(self) -> None: + from devx.tools.clean_images import main as clean_main + + runner = CliRunner() + mock_resp = MagicMock() + mock_resp.json.return_value = [] + mock_resp.raise_for_status = MagicMock() + with patch.dict("os.environ", {"REPO_TOKEN": "fake"}): + with patch("devx.tools.clean_images.requests.get", return_value=mock_resp): + result = runner.invoke( + clean_main, + ["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"], + ) + assert result.exit_code == 0 + assert "No versions" in result.output + + def test_actual_delete(self) -> None: + from devx.tools.clean_images import main as clean_main + + runner = CliRunner() + list_resp = MagicMock() + list_resp.json.return_value = [ + {"version": "0.1.0", "created_at": "2025-01-01"}, + {"version": "0.2.0", "created_at": "2025-02-01"}, + {"version": "0.3.0", "created_at": "2025-03-01"}, + ] + list_resp.raise_for_status = MagicMock() + delete_resp = MagicMock(status_code=204) + with patch.dict("os.environ", {"REPO_TOKEN": "fake"}): + with patch("devx.tools.clean_images.requests.get", return_value=list_resp): + with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp): + result = runner.invoke( + clean_main, + ["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"], + ) + assert result.exit_code == 0 + assert "Deleted" in result.output + + def test_list_request_exception(self) -> None: + import requests as req + + from devx.tools.clean_images import main as clean_main + + runner = CliRunner() + with patch.dict("os.environ", {"REPO_TOKEN": "fake"}): + with patch( + "devx.tools.clean_images.requests.get", + side_effect=req.ConnectionError("network down"), + ): + result = runner.invoke( + clean_main, + ["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"], + ) + assert result.exit_code == 0 + assert "Failed to list" in result.output + + def test_delete_failure_in_cli(self) -> None: + from devx.tools.clean_images import main as clean_main + + runner = CliRunner() + list_resp = MagicMock() + list_resp.json.return_value = [ + {"version": "0.1.0", "created_at": "2025-01-01"}, + {"version": "0.2.0", "created_at": "2025-02-01"}, + {"version": "0.3.0", "created_at": "2025-03-01"}, + ] + list_resp.raise_for_status = MagicMock() + delete_resp = MagicMock(status_code=500) + with patch.dict("os.environ", {"REPO_TOKEN": "fake"}): + with patch("devx.tools.clean_images.requests.get", return_value=list_resp): + with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp): + result = runner.invoke( + clean_main, + ["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"], + ) + assert result.exit_code == 0 + assert "FAILED" in result.output