Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a14d838564 | ||
|
|
7dcb9c03c0 | ||
|
|
6f2b110c17 | ||
|
|
cfb856ff75 | ||
|
|
95adf86895 | ||
|
|
7cf039ebbe | ||
|
|
a17982f2cf | ||
|
|
cf85964877 | ||
|
|
fbb1fc3134 | ||
|
|
a8f86aca68 | ||
|
|
13bed1d99c | ||
|
|
107cff5dec | ||
|
|
cb037aa69c | ||
|
|
7fa1c4450c | ||
|
|
cf2921845b | ||
|
|
c272150275 | ||
|
|
8de91be405 | ||
|
|
46b8fe5078 | ||
|
|
7e2a8b4535 | ||
|
|
c90518acdb | ||
|
|
80ca622838 | ||
|
|
3f2d19d7ac | ||
|
|
c839d49fe3 | ||
|
|
93b5d2f926 | ||
|
|
131c04c9d0 | ||
|
|
8e9681cf7d | ||
|
|
6631525a1d | ||
|
|
6985030a3c | ||
|
|
4d073f3beb | ||
|
|
037d7b0d16 | ||
|
|
9cb706e387 |
@@ -120,8 +120,8 @@ jobs:
|
||||
|
||||
auto-merge:
|
||||
# Auto-merge runs after all CI checks pass. It reads the task ID
|
||||
# from .taskid file, validates the PR title, and squash-merges.
|
||||
# No manual label or review needed — CI is the quality gate.
|
||||
# from the branch name (falling back to .taskid file), validates
|
||||
# the PR title, and squash-merges.
|
||||
needs: [quality, detect-changes, pr-review]
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: docker
|
||||
|
||||
@@ -4,6 +4,12 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to publish (e.g. v0.9.11)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
@@ -34,7 +40,7 @@ jobs:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.publish "${{ github.ref_name }}" "${{ github.repository }}"
|
||||
python3 -m devx.ci.publish "${{ github.event.inputs.tag || github.ref_name }}" "${{ github.repository }}"
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
|
||||
@@ -35,3 +35,6 @@ Thumbs.db
|
||||
|
||||
# Badges
|
||||
.badges/
|
||||
|
||||
# Deprecated CI task tracking (branch name is the sole source of truth)
|
||||
.taskid
|
||||
|
||||
@@ -285,6 +285,32 @@ setuptools via `dynamic = ["version"]` in `pyproject.toml`.
|
||||
| PR title | `DEVX-N: <vikunja task title>` | `DEVX-12: Add release automation` |
|
||||
| Merge commit | `DEVX-N <conventional commit>` | `DEVX-12 feat: add release script` |
|
||||
|
||||
### Task ID Resolution
|
||||
|
||||
`auto_merge` resolves the task ID solely from the branch name (e.g.
|
||||
`DEVX-12-fix-foo` → `DEVX-12`). Branch names must include the task ID
|
||||
prefix — there is no `.taskid` file fallback. If a stale `.taskid` file
|
||||
exists in the repo, a deprecation warning is printed advising its removal.
|
||||
|
||||
### Workflow `auto-merge` Job and `always()`
|
||||
|
||||
When `auto-merge` depends on a job that can be skipped (e.g.
|
||||
`molecule-tests`), the `if:` condition MUST include `always() &&`
|
||||
at the start. Without it, Gitea Actions skips `auto-merge` when any
|
||||
dependency is skipped, even if the condition explicitly allows
|
||||
`result == 'skipped'`.
|
||||
|
||||
```yaml
|
||||
auto-merge:
|
||||
needs: [quality, detect-changes, pr-review, molecule-tests]
|
||||
if: >-
|
||||
always() &&
|
||||
github.event_name == 'pull_request' &&
|
||||
needs.quality.result == 'success' &&
|
||||
needs.pr-review.result == 'success' &&
|
||||
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
|
||||
```
|
||||
|
||||
## Config System
|
||||
|
||||
devx uses environment variables with `.env` file fallback for configuration.
|
||||
@@ -295,6 +321,10 @@ devx uses environment variables with `.env` file fallback for configuration.
|
||||
|----------|---------|-------------|
|
||||
| `DEVX_GITEA_API_URL` | `https://git.oblachno.oblachno.fyi/api/v1` | Gitea API base URL |
|
||||
| `DEVX_VIKUNJA_API_URL` | `https://work.oblachno.oblachno.fyi/api/v1` | Vikunja API base URL |
|
||||
| `DEVX_REPO_OWNER` | **(none — must be set)** | Repository owner for API calls |
|
||||
| `DEVX_REPO_NAME` | **(none — must be set)** | Repository name (or `owner/repo`) |
|
||||
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
|
||||
| `DEVX_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID |
|
||||
| `DEVX_LANG` | `en` | Language for i18n (en, bg) |
|
||||
| `REPO_TOKEN` | (from .env) | Gitea API token |
|
||||
| `VIKUNJA_TOKEN` | (from .env) | Vikunja API token |
|
||||
|
||||
@@ -2,6 +2,68 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.10.2] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Badge generation respects pyproject.toml testpaths, shows stdout in warnings
|
||||
|
||||
## [0.10.1] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Badge generation REPO_ROOT, auto-detect package, error feedback
|
||||
|
||||
## [0.10.0] - 2026-06-24
|
||||
|
||||
### Features
|
||||
|
||||
- Remove .taskid file fallback, use branch name only
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use raw/branch/badges/ URLs for badges in README and docs
|
||||
|
||||
## [0.9.12] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Clean dist/ before build and add workflow_dispatch to publish
|
||||
|
||||
## [0.9.11] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use raw/branch/badges/ URLs for badges in README and docs
|
||||
- Resolve repo_root from GITHUB_WORKSPACE or cwd
|
||||
|
||||
## [0.9.10] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Retrospective fixes for CI/CD friction
|
||||
|
||||
## [0.9.9] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets
|
||||
- Prefer branch name for task ID extraction + strip heads/ prefix in release
|
||||
- Filter non-version tags in release verification
|
||||
- Use explicit refspecs for git push to avoid tag/branch ambiguity
|
||||
|
||||
## [0.9.8] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets
|
||||
|
||||
## [0.9.7] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add rootless socket fallback and GITHUB_ENV export
|
||||
|
||||
## [0.9.6] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,16 +1,61 @@
|
||||
# devx — Reusable Development & CI/CD Tools
|
||||
|
||||
A Python package providing reusable development and CI/CD automation tools for oblachno-oss projects. devx consolidates release management, PR automation, wiki sync, badge generation, translation checks, and more into a single installable package.
|
||||
A Python package providing reusable development and CI/CD automation tools for
|
||||
oblachno-oss projects. devx consolidates release management, PR automation,
|
||||
wiki sync, badge generation, translation checks, documentation coverage,
|
||||
parallel test distribution, and more into a single installable package.
|
||||
|
||||
It was extracted from the [GRM](https://git.oblachno.oblachno.fyi/oblachno-oss/grm)
|
||||
project to be reusable across all oblachno-oss repositories. Any project hosted
|
||||
on a Gitea instance with Gitea Actions can install devx and inherit a complete,
|
||||
opinionated CI/CD pipeline: conventional commits, automated versioning via
|
||||
git-cliff, squash-merge automation, Vikunja task tracking, wiki sync, and
|
||||
quality badges.
|
||||
|
||||
> An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Why devx?
|
||||
|
||||
Every oblachno-oss project shares the same CI/CD needs: automated releases,
|
||||
PR review, task tracking, documentation sync, and quality badges. Without a
|
||||
shared package, each repository duplicates this logic in shell scripts and
|
||||
workflow YAML, leading to drift, bugs, and maintenance burden.
|
||||
|
||||
devx solves this by providing a single, tested Python package that any
|
||||
oblachno-oss project can install. The project declares its configuration via
|
||||
environment variables and `pyproject.toml`, and devx handles the rest. Updates
|
||||
to the CI/CD pipeline ship as new devx releases — consumer projects pick them
|
||||
up by bumping their devx dependency.
|
||||
|
||||
### Key features
|
||||
|
||||
- **Automated releases** — git-cliff-driven semver versioning, changelog
|
||||
generation, tagging, and publishing to a Gitea PyPI registry.
|
||||
- **PR automation** — squash-merge with task ID validation, automated PR
|
||||
review with inline comments, and conventional commit enforcement.
|
||||
- **Smart change classification** — user-facing vs workflow-only change
|
||||
detection so infrastructure-only changes skip releases.
|
||||
- **Documentation sync** — push `docs/` markdown to the Gitea wiki with
|
||||
integrity verification.
|
||||
- **Quality badges** — generate self-contained SVG badges for coverage,
|
||||
tests, docs, quality, version, and Python version.
|
||||
- **Translation checks** — validate i18n keys against source code, detect
|
||||
dead keys and missing languages.
|
||||
- **Parallel test distribution** — split test files or molecule scenarios
|
||||
across CI runners with cross-runner fail-fast.
|
||||
- **Developer tools** — environment setup, CI tool installation, test speed
|
||||
enforcement, repository configuration.
|
||||
- **i18n** — built-in translations for English, Bulgarian, German, Russian,
|
||||
and Chinese; projects can extend with their own keys.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -26,21 +71,38 @@ Or add the registry to your `pip.conf` / `pyproject.toml` and install normally:
|
||||
pip install devx
|
||||
```
|
||||
|
||||
## Usage
|
||||
### Optional extras
|
||||
|
||||
### CI/CD Automation
|
||||
devx ships optional dependency groups for different use cases:
|
||||
|
||||
devx provides CI/CD modules invoked via `python -m devx.ci.*`:
|
||||
```bash
|
||||
pip install "devx[ci,lint]" # CI runners and linting (pytest, ruff, pyright, bandit)
|
||||
pip install "devx[molecule]" # Molecule testing for Ansible projects
|
||||
pip install "devx[dev]" # Full local development (ci + lint + build + twine)
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
After installing devx, set the required environment variables (see
|
||||
[Configuration](#configuration)) and invoke modules via `python -m devx.*` or
|
||||
the `devx` CLI.
|
||||
|
||||
### CI/CD automation
|
||||
|
||||
CI/CD modules are invoked via `python -m devx.ci.*`. Each module is also
|
||||
available as a `devx ci <command>` subcommand.
|
||||
|
||||
```bash
|
||||
# Release automation (versioning, changelog, tagging)
|
||||
python -m devx.ci.release
|
||||
python -m devx.ci.release --dry-run
|
||||
python -m devx.ci.release --dry-run # preview without changes
|
||||
python -m devx.ci.release --verify # check tag/version/changelog alignment
|
||||
|
||||
# Publish a release to the Gitea PyPI registry
|
||||
python -m devx.ci.publish v1.0.0 oblachno-oss/devx
|
||||
python -m devx.ci.publish v1.0.0 oblachno-oss/devx --skip-build # Gitea release only
|
||||
|
||||
# Automated PR review
|
||||
# Automated PR review (posts inline comments and structured review)
|
||||
python -m devx.ci.pr_review 42 oblachno-oss/devx
|
||||
|
||||
# Auto-merge a PR (validates title, squash-merges)
|
||||
@@ -54,9 +116,11 @@ python -m devx.ci.sync_wiki --repo oblachno-oss/devx --strict
|
||||
|
||||
# Generate and push quality badges
|
||||
python -m devx.ci.push_badges
|
||||
python -m devx.ci.push_badges --retries 3 # retry on git push failures
|
||||
|
||||
# Check translation completeness
|
||||
python -m devx.ci.check_translations
|
||||
python -m devx.ci.check_translations --translations path/to/translations.json
|
||||
|
||||
# Documentation coverage check
|
||||
python -m devx.ci.doc_coverage --fail-on-missing
|
||||
@@ -64,56 +128,215 @@ python -m devx.ci.doc_coverage --fail-on-missing
|
||||
# Validate a commit message
|
||||
python -m devx.ci.validate_commit_msg commit-msg.txt --branch master
|
||||
|
||||
# Detect whether the latest commit is a release commit
|
||||
python -m devx.ci.detect_release_commit
|
||||
|
||||
# Notify on CI failure (creates a Gitea issue)
|
||||
python -m devx.ci.notify_failure --repo oblachno-oss/devx --run-id 123 --workflow ci --commit abc123
|
||||
python -m devx.ci.notify_failure --repo oblachno-oss/devx --run-id 123 \
|
||||
--workflow ci --commit abc123 --auto-login
|
||||
|
||||
# Discover available Gitea Actions runners
|
||||
python -m devx.ci.discover_runners --owner oblachno-oss --repo devx --indices
|
||||
|
||||
# Distribute files across parallel runners (round-robin)
|
||||
python -m devx.ci.distribute_files --pattern "tests/integration/test_*.py" \
|
||||
--runner-index 1 --max-runners 3 --github-env
|
||||
|
||||
# Merge JUnit XML reports from parallel runners
|
||||
python -m devx.ci.merge_junit --pattern "junit-results/runner-*.xml" --output junit-merged.xml
|
||||
|
||||
# Run pytest with cross-runner fail-fast and JUnit output
|
||||
python -m devx.ci.integration_guard --junit-output junit-results/runner-1.xml -- test_a.py test_b.py
|
||||
```
|
||||
|
||||
### Developer Tools
|
||||
### Developer tools
|
||||
|
||||
devx provides developer tooling invoked via `python -m devx.tools.*`:
|
||||
Developer tooling modules are invoked via `python -m devx.tools.*` or the
|
||||
`devx tools <command>` subcommand.
|
||||
|
||||
```bash
|
||||
# Set up a development environment (venv, deps, hooks)
|
||||
# Set up a development environment (venv, deps, hooks, tea login)
|
||||
python -m devx.tools.setup --bin .venv/bin
|
||||
python -m devx.tools.setup --bin .venv/bin --extras "ci,lint" --no-pre-commit
|
||||
|
||||
# Install CI tools (actionlint, git-cliff, act_runner, tea)
|
||||
python -m devx.tools.install_tools
|
||||
python -m devx.tools.install_tools --tool git-cliff --tool tea
|
||||
python -m devx.tools.install_tools --list
|
||||
|
||||
# Install checkmake (Makefile linter)
|
||||
python -m devx.tools.install_checkmake
|
||||
|
||||
# Check unit test speed
|
||||
python -m devx.tools.check_test_speed --max-seconds 10
|
||||
python -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
|
||||
|
||||
# Configure repository (branch protection, labels)
|
||||
python -m devx.tools.configure_repo
|
||||
python -m devx.tools.configure_repo --repo devx --owner oblachno-oss
|
||||
|
||||
# Generate badge SVG files locally
|
||||
python -m devx.tools.generate_badges --output-dir .badges/
|
||||
|
||||
# Generate a cliff.toml with the correct task ID prefix
|
||||
python -m devx.tools.generate_cliff_config --prefix GRM
|
||||
python -m devx.tools.generate_cliff_config --prefix GRM --force # overwrite existing
|
||||
```
|
||||
|
||||
### CLI
|
||||
### Molecule testing (optional)
|
||||
|
||||
devx also provides a `devx` CLI command:
|
||||
For projects with Ansible roles, devx provides molecule testing helpers via
|
||||
`python -m devx.molecule.*` or `devx molecule <command>`.
|
||||
|
||||
```bash
|
||||
# Distribute molecule scenarios across parallel runners
|
||||
python -m devx.molecule.distribute_molecule --runner-index 1 --max-runners 3
|
||||
python -m devx.molecule.distribute_molecule --list # list all scenarios
|
||||
python -m devx.molecule.distribute_molecule --list-platforms # list platforms
|
||||
|
||||
# Run molecule tests with cross-runner fail-fast
|
||||
python -m devx.molecule.molecule_ci_guard --junit-output junit.xml pair1 pair2
|
||||
python -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2
|
||||
|
||||
# Run all molecule scenarios locally (sequential)
|
||||
python -m devx.molecule.molecule_all
|
||||
python -m devx.molecule.molecule_all --bin .venv/bin
|
||||
|
||||
# Discover available Gitea Actions runners for molecule tests
|
||||
python -m devx.molecule.discover_runners --indices
|
||||
|
||||
# Ensure Docker is available for molecule tests in CI
|
||||
python -m devx.molecule.start_docker
|
||||
```
|
||||
|
||||
### OpenTofu helpers
|
||||
|
||||
devx provides reusable functions for extracting values from `tofu output`:
|
||||
|
||||
```python
|
||||
from devx.opentofu import get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field
|
||||
|
||||
vms = get_tofu_output("customer_vms", cwd="tofu/environments/staging",
|
||||
env={"HCLOUD_TOKEN": token})
|
||||
ip = get_tofu_vm_ip("customer_vms", "oblachno", cwd="tofu/environments/staging",
|
||||
env={"HCLOUD_TOKEN": token})
|
||||
```
|
||||
|
||||
## CLI commands overview
|
||||
|
||||
devx provides a `devx` CLI command with three command groups:
|
||||
|
||||
```bash
|
||||
devx --help
|
||||
devx --version
|
||||
```
|
||||
|
||||
### Configuration
|
||||
### `devx ci` — CI/CD automation
|
||||
|
||||
devx reads configuration from environment variables with `.env` file fallback:
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `devx ci auto-merge` | Squash-merge a PR with task ID validation |
|
||||
| `devx ci check-translations` | Check translation files for gaps and dead keys |
|
||||
| `devx ci classify-changes` | Classify git changes as user-facing or workflow-only |
|
||||
| `devx ci detect-release-commit` | Detect whether the latest commit is a release commit |
|
||||
| `devx ci discover-runners` | Discover available Gitea Actions runners |
|
||||
| `devx ci distribute-files` | Distribute files across parallel runners (round-robin) |
|
||||
| `devx ci doc-coverage` | Check documentation coverage for CLI commands and modules |
|
||||
| `devx ci integration-guard` | Run pytest with cross-runner fail-fast and JUnit output |
|
||||
| `devx ci merge-junit` | Merge JUnit XML reports from parallel runners |
|
||||
| `devx ci notify-failure` | Create a Gitea issue when a CI workflow fails |
|
||||
| `devx ci post-merge` | Update Vikunja task after a merge to master |
|
||||
| `devx ci pr-review` | Run automated PR review |
|
||||
| `devx ci publish` | Build package, publish to registry, create Gitea release |
|
||||
| `devx ci push-badges` | Generate badge SVG files and push to the badges branch |
|
||||
| `devx ci release` | Automated release: version, changelog, tag, push |
|
||||
| `devx ci sync-wiki` | Sync documentation from docs/ to the Gitea wiki |
|
||||
| `devx ci validate-commit-msg` | Validate commit messages for conventional format |
|
||||
|
||||
### `devx tools` — Developer tools
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `devx tools check-test-speed` | Run unit tests and enforce execution-time budgets |
|
||||
| `devx tools configure-repo` | Configure branch protection and labels via Gitea API |
|
||||
| `devx tools generate-badges` | Generate self-contained SVG badge files |
|
||||
| `devx tools generate-cliff-config` | Generate a cliff.toml with the correct task ID prefix |
|
||||
| `devx tools install-checkmake` | Install checkmake (Makefile linter) |
|
||||
| `devx tools install-tools` | Install actionlint, git-cliff, act_runner, tea |
|
||||
| `devx tools setup` | Project setup: install deps, hooks, tea login |
|
||||
|
||||
### `devx molecule` — Molecule testing (optional)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `devx molecule all` | Run all molecule scenarios on all supported platforms |
|
||||
| `devx molecule discover-runners` | Discover available Gitea Actions runners |
|
||||
| `devx molecule distribute` | Distribute molecule test pairs across parallel runners |
|
||||
| `devx molecule guard` | Run molecule tests with CI failure polling |
|
||||
|
||||
See [CLI Commands](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki/CLI-Commands)
|
||||
in the wiki for full command documentation with examples.
|
||||
|
||||
## Configuration
|
||||
|
||||
devx reads configuration from environment variables with `.env` file fallback.
|
||||
The config system loads `.env` automatically via `python-dotenv`.
|
||||
|
||||
### DEVX_ environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DEVX_GITEA_API_URL` | `https://git.oblachno.oblachno.fyi/api/v1` | Gitea API base URL |
|
||||
| `DEVX_VIKUNJA_API_URL` | `https://work.oblachno.oblachno.fyi/api/v1` | Vikunja API base URL |
|
||||
| `DEVX_LANG` | `en` | Language (en, bg) |
|
||||
| `DEVX_REPO_OWNER` | **(none — must be set)** | Repository owner for API calls |
|
||||
| `DEVX_REPO_NAME` | **(none — must be set)** | Repository name (or `owner/repo`) |
|
||||
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
|
||||
| `DEVX_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID |
|
||||
| `DEVX_LANG` | `en` | Language for i18n (en, bg, de, ru, zh) |
|
||||
| `DEVX_TRANSLATIONS_PATH` | — | Path to a custom JSON translations file |
|
||||
| `DEVX_VERSION_FILE` | `src/devx/__init__.py` | Version source file (used by release) |
|
||||
| `DEVX_DOCS_DIR` | `docs` | Documentation directory (used by sync_wiki) |
|
||||
| `DEVX_STATUS_CHECKS` | `CI / quality (pull_request)` | Comma-separated status check contexts |
|
||||
| `DEVX_PYPI_REGISTRY_URL` | — | Gitea PyPI registry URL (used by publish) |
|
||||
| `REPO_TOKEN` | — | Gitea API token |
|
||||
| `VIKUNJA_TOKEN` | — | Vikunja API token |
|
||||
| `PYPI_TOKEN` | — | Standard PyPI token (takes precedence over Gitea registry) |
|
||||
|
||||
Copy `.env.example` to `.env` and fill in your tokens:
|
||||
### Per-project overrides
|
||||
|
||||
Projects using devx can override the default API URLs and language by setting
|
||||
`DEVX_*` environment variables or entries in their `.env` file. Copy
|
||||
`.env.example` to `.env` and fill in your tokens:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### Change classification
|
||||
|
||||
Projects configure which file paths are infrastructure (no release needed) vs
|
||||
user-facing (release needed) in `pyproject.toml`:
|
||||
|
||||
```toml
|
||||
[tool.devx.classify]
|
||||
# Merge with DEFAULT_INFRASTRUCTURE (CI workflows, tests, docs, config)
|
||||
# use_defaults = true # (default)
|
||||
|
||||
# Project-specific infrastructure paths (merged with defaults)
|
||||
infrastructure = []
|
||||
|
||||
# Files that would default to user-facing but are actually infrastructure
|
||||
infrastructure_overrides = [
|
||||
"src/myproject/__init__.py", # only contains __version__
|
||||
]
|
||||
|
||||
# Safety override for broad infrastructure patterns
|
||||
user_facing_overrides = []
|
||||
|
||||
# Tag patterns for CI conditional execution (orthogonal to release impact)
|
||||
[tool.devx.classify.tags]
|
||||
# ansible = ["ansible/**"]
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
@@ -122,10 +345,86 @@ cd devx
|
||||
make setup # Create venv, install deps, hooks, CI tools
|
||||
make lint-all # ruff + pyright + bandit + actionlint
|
||||
make pytest-cov # Unit tests with 100% coverage
|
||||
make test-unit # Unit tests without coverage
|
||||
make workflow-check # Static + dry-run validation of workflow YAML
|
||||
make clean # Remove caches, build artifacts, coverage data
|
||||
```
|
||||
|
||||
See [AGENTS.md](AGENTS.md) for full project conventions, PR workflow, and architecture details.
|
||||
`make setup` automatically installs all development tools:
|
||||
- **Python deps** via `python -m devx.tools.setup` (pip install -e .[dev], pre-commit hooks)
|
||||
- **actionlint, git-cliff, act_runner, tea** via `python -m devx.tools.install_tools`
|
||||
- **tea CLI login** via `python -m devx.tools.setup` (configures `tea login` from `.env`)
|
||||
|
||||
### Make targets
|
||||
|
||||
| Target | Description |
|
||||
|--------|-------------|
|
||||
| `make setup` | Full local development setup (venv, deps, hooks, CI tools) |
|
||||
| `make setup-ci` | Lean setup for CI jobs (pytest + lint + runtime deps) |
|
||||
| `make setup-quality` | Setup for quality job (lint + test deps, actionlint) |
|
||||
| `make setup-release` | Setup for release jobs (git-cliff, tea, lint tools) |
|
||||
| `make install-tools` | Install actionlint, git-cliff, act_runner, tea |
|
||||
| `make install-hooks` | Install git hooks (pre-commit, pre-push) |
|
||||
| `make lint` | ruff check + ruff format check + pyright + bandit |
|
||||
| `make lint-ruff` | ruff check only |
|
||||
| `make lint-format` | ruff format check only |
|
||||
| `make typecheck` | pyright only |
|
||||
| `make lint-bandit` | bandit security scan only |
|
||||
| `make lint-all` | lint + workflow-lint (actionlint) |
|
||||
| `make lint-deps` | pip-audit dependency vulnerability scan |
|
||||
| `make test-unit` | Unit tests without coverage |
|
||||
| `make pytest-cov` | Unit tests with 100% coverage enforcement |
|
||||
| `make workflow-lint` | actionlint on .gitea/workflows/*.yml |
|
||||
| `make workflow-dryrun` | act_runner exec --dryrun on all workflows |
|
||||
| `make workflow-check` | workflow-lint + workflow-dryrun |
|
||||
| `make clean` | Remove caches, build artifacts, coverage data |
|
||||
|
||||
See [AGENTS.md](AGENTS.md) for full project conventions, PR workflow, and
|
||||
architecture details.
|
||||
|
||||
## Architecture overview
|
||||
|
||||
devx is a self-contained Python package under `src/devx/`. It never imports
|
||||
from scripts outside the package. All tools are invoked via
|
||||
`python -m devx.ci.*`, `python -m devx.tools.*`, or `python -m devx.molecule.*`.
|
||||
|
||||
```
|
||||
src/devx/
|
||||
├── __init__.py # Version (single source of truth, read by setuptools)
|
||||
├── cli.py # Click-based CLI entry point (devx command)
|
||||
├── config.py # Configuration system (DEVX_ env vars, .env loading)
|
||||
├── api_clients.py # GiteaClient, VikunjaClient — HTTP API wrappers
|
||||
├── gitea_cli.py # TeaCLI — wrapper around tea CLI with JSON parsing
|
||||
├── i18n.py # Translation system (gettext-based, translations.json)
|
||||
├── exceptions.py # Custom exception types (DevxError, APIError)
|
||||
├── opentofu.py # OpenTofu output helpers
|
||||
├── translations.json # Translation strings (en, bg, de, ru, zh)
|
||||
├── ci/ # CI/CD automation modules (run by workflows)
|
||||
├── tools/ # Developer tooling modules (run locally or by CI)
|
||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||
```
|
||||
|
||||
### Design principles
|
||||
|
||||
- **Self-contained package** — `src/devx/` never imports from scripts outside the package
|
||||
- **Module-based invocation** — All tools invoked via `python -m devx.ci.*` or `python -m devx.tools.*`
|
||||
- **PYTHONPATH: src** — Workflows set `PYTHONPATH: src` (not `.:src` since there are no scripts at repo root)
|
||||
- **Config via env vars** — `DEVX_*` environment variables with `.env` file fallback
|
||||
- **100% test coverage** — enforced by `--cov-fail-under=100`
|
||||
- **i18n by default** — all user-facing strings wrapped in `_()` for translation
|
||||
|
||||
See [Architecture](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki/Architecture)
|
||||
and [CI/CD Workflow](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki/CI-CD-Workflow)
|
||||
in the wiki for detailed documentation.
|
||||
|
||||
## Links
|
||||
|
||||
- **Wiki**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
- **Releases**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
- **Actions**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
- **Source**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx](https://git.oblachno.oblachno.fyi/oblachno-oss/devx)
|
||||
- **GRM (origin project)**: [https://git.oblachno.oblachno.fyi/oblachno-oss/grm](https://git.oblachno.oblachno.fyi/oblachno-oss/grm)
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0
|
||||
GPL-3.0 — see [LICENSE](LICENSE).
|
||||
|
||||
+126
-7
@@ -1,10 +1,48 @@
|
||||
# devx — Reusable Development & CI/CD Tools
|
||||
|
||||
A Python package providing reusable development and CI/CD automation tools for oblachno-oss projects.
|
||||
A Python package providing reusable development and CI/CD automation tools for
|
||||
oblachno-oss projects. devx consolidates release management, PR automation,
|
||||
wiki sync, badge generation, translation checks, documentation coverage,
|
||||
parallel test distribution, and more into a single installable package.
|
||||
|
||||
It was extracted from the [GRM](https://git.oblachno.oblachno.fyi/oblachno-oss/grm)
|
||||
project to be reusable across all oblachno-oss repositories.
|
||||
|
||||
> An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Overview
|
||||
|
||||
devx consolidates release management, PR automation, wiki sync, badge generation, translation checks, and more into a single installable package. It was extracted from the [GRM](https://git.oblachno.oblachno.fyi/oblachno-oss/grm) project to be reusable across all oblachno-oss projects.
|
||||
devx provides a complete, opinionated CI/CD pipeline for any project hosted on
|
||||
a Gitea instance with Gitea Actions. Install the package, declare configuration
|
||||
via environment variables and `pyproject.toml`, and inherit:
|
||||
|
||||
- **Automated releases** — git-cliff-driven semver versioning, changelog
|
||||
generation, tagging, and publishing to a Gitea PyPI registry.
|
||||
- **PR automation** — squash-merge with task ID validation, automated PR
|
||||
review with inline comments, and conventional commit enforcement.
|
||||
- **Smart change classification** — user-facing vs workflow-only change
|
||||
detection so infrastructure-only changes skip releases.
|
||||
- **Documentation sync** — push `docs/` markdown to the Gitea wiki with
|
||||
integrity verification.
|
||||
- **Quality badges** — self-contained SVG badges for coverage, tests, docs,
|
||||
quality, version, and Python version.
|
||||
- **Translation checks** — validate i18n keys against source code, detect
|
||||
dead keys and missing languages.
|
||||
- **Parallel test distribution** — split test files or molecule scenarios
|
||||
across CI runners with cross-runner fail-fast.
|
||||
- **Developer tools** — environment setup, CI tool installation, test speed
|
||||
enforcement, repository configuration.
|
||||
- **i18n** — built-in translations for English, Bulgarian, German, Russian,
|
||||
and Chinese; projects can extend with their own keys.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -14,11 +52,92 @@ Install from the Gitea PyPI registry:
|
||||
pip install devx --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
|
||||
```
|
||||
|
||||
Optional extras:
|
||||
|
||||
```bash
|
||||
pip install "devx[ci,lint]" # CI runners and linting
|
||||
pip install "devx[molecule]" # Molecule testing for Ansible projects
|
||||
pip install "devx[dev]" # Full local development
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Core modules** — config, exceptions, i18n, api_clients, gitea_cli
|
||||
- **CI automation** (`devx.ci`) — release, publish, auto_merge, pr_review, classify_changes, etc.
|
||||
- **Dev tools** (`devx.tools`) — setup, install_tools, check_test_speed, configure_repo, generate_badges
|
||||
- **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible roles
|
||||
devx is a self-contained Python package under `src/devx/`:
|
||||
|
||||
See [AGENTS.md](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/AGENTS.md) for full project conventions.
|
||||
- **Core modules** — `config.py`, `exceptions.py`, `i18n.py`, `api_clients.py`,
|
||||
`gitea_cli.py`, `cli.py`, `opentofu.py`
|
||||
- **CI automation** (`devx.ci`) — release, publish, auto_merge, pr_review,
|
||||
classify_changes, sync_wiki, push_badges, check_translations, doc_coverage,
|
||||
validate_commit_msg, detect_release_commit, notify_failure, post_merge,
|
||||
discover_runners, distribute_files, merge_junit, integration_guard
|
||||
- **Dev tools** (`devx.tools`) — setup, install_tools, check_test_speed,
|
||||
configure_repo, generate_badges, generate_cliff_config, install_checkmake
|
||||
- **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible
|
||||
roles: distribute_molecule, molecule_ci_guard, molecule_all, discover_runners,
|
||||
start_docker, platforms
|
||||
|
||||
See [Architecture](Architecture) for the full package structure, module
|
||||
descriptions, design principles, and data flow diagrams.
|
||||
|
||||
## CI/CD pipeline
|
||||
|
||||
devx uses Gitea Actions with three workflows:
|
||||
|
||||
- **CI** (`ci.yml`) — runs on pull requests: quality checks, change detection,
|
||||
release dry-run, automated PR review, and auto-merge.
|
||||
- **Post-merge** (`post-merge.yml`) — runs on every push to master: release
|
||||
versioning, wiki sync, badge generation, Vikunja task updates, and repo
|
||||
configuration.
|
||||
- **Publish** (`publish.yml`) — runs on tag pushes: builds the package,
|
||||
publishes to the Gitea PyPI registry, and creates a Gitea release.
|
||||
|
||||
See [CI/CD Workflow](CI-CD-Workflow) for the full pipeline documentation,
|
||||
including the post-merge job graph, release process, badge generation, and
|
||||
wiki sync details.
|
||||
|
||||
## CLI commands
|
||||
|
||||
devx provides a `devx` CLI with three command groups:
|
||||
|
||||
- `devx ci <command>` — CI/CD automation (17 commands)
|
||||
- `devx tools <command>` — Developer tools (7 commands)
|
||||
- `devx molecule <command>` — Molecule testing (4 commands, optional)
|
||||
|
||||
See [CLI Commands](CLI-Commands) for full command documentation with examples.
|
||||
|
||||
## Configuration
|
||||
|
||||
devx reads configuration from `DEVX_*` environment variables with `.env` file
|
||||
fallback. Key variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DEVX_GITEA_API_URL` | `https://git.oblachno.oblachno.fyi/api/v1` | Gitea API base URL |
|
||||
| `DEVX_VIKUNJA_API_URL` | `https://work.oblachno.oblachno.fyi/api/v1` | Vikunja API base URL |
|
||||
| `DEVX_REPO_OWNER` | **(must be set)** | Repository owner |
|
||||
| `DEVX_REPO_NAME` | **(must be set)** | Repository name |
|
||||
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
|
||||
| `DEVX_LANG` | `en` | Language for i18n (en, bg, de, ru, zh) |
|
||||
| `REPO_TOKEN` | — | Gitea API token |
|
||||
| `VIKUNJA_TOKEN` | — | Vikunja API token |
|
||||
|
||||
See [AGENTS.md](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/AGENTS.md)
|
||||
for the full configuration reference, PR workflow, and project conventions.
|
||||
|
||||
## Wiki pages
|
||||
|
||||
- [Home](Home) — This page
|
||||
- [CLI Commands](CLI-Commands) — Full CLI command documentation with examples
|
||||
- [Architecture](Architecture) — Package structure, module descriptions, design principles
|
||||
- [CI/CD Workflow](CI-CD-Workflow) — Pipeline documentation, workflows, and CI scripts
|
||||
|
||||
## Links
|
||||
|
||||
- **Source**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx](https://git.oblachno.oblachno.fyi/oblachno-oss/devx)
|
||||
- **Releases**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
- **Actions**: [https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
- **GRM (origin project)**: [https://git.oblachno.oblachno.fyi/oblachno-oss/grm](https://git.oblachno.oblachno.fyi/oblachno-oss/grm)
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0
|
||||
|
||||
+571
-29
@@ -1,50 +1,592 @@
|
||||
# Architecture
|
||||
|
||||
devx is a reusable Python package providing development and CI/CD tools for oblachno-oss projects.
|
||||
devx is a reusable Python package providing development and CI/CD tools for
|
||||
oblachno-oss projects. It is self-contained under `src/devx/` and never imports
|
||||
from scripts outside the package.
|
||||
|
||||
## Package Structure
|
||||
## Package structure
|
||||
|
||||
```
|
||||
src/devx/
|
||||
├── __init__.py # Version (single source of truth)
|
||||
├── cli.py # Click-based CLI entry point (devx command)
|
||||
├── config.py # Configuration system (DEVX_ env vars)
|
||||
├── api_clients.py # GiteaClient, VikunjaClient — HTTP API wrappers
|
||||
├── gitea_cli.py # TeaCLI — wrapper around tea CLI with JSON parsing
|
||||
├── i18n.py # Translation system (gettext-based, translations.json)
|
||||
├── exceptions.py # Custom exception types (DevxError, APIError)
|
||||
├── translations.json # Translation strings (en, bg, de, ru, zh)
|
||||
├── ci/ # CI/CD automation modules
|
||||
├── tools/ # Developer tooling modules
|
||||
└── molecule/ # Optional molecule testing helpers
|
||||
├── __init__.py # Version (single source of truth, read by setuptools)
|
||||
├── cli.py # Click-based CLI entry point (devx command)
|
||||
├── config.py # Configuration system (DEVX_ env vars, .env loading)
|
||||
├── api_clients.py # GiteaClient, VikunjaClient — HTTP API wrappers
|
||||
├── gitea_cli.py # TeaCLI — wrapper around tea CLI with JSON parsing
|
||||
├── i18n.py # Translation system (JSON-based, translations.json)
|
||||
├── exceptions.py # Custom exception types (DevxError, APIError)
|
||||
├── opentofu.py # OpenTofu output helpers
|
||||
├── translations.json # Translation strings (en, bg, de, ru, zh)
|
||||
├── ci/ # CI/CD automation modules (run by workflows)
|
||||
│ ├── __init__.py
|
||||
│ ├── _shared.py # Shared utilities (get_latest_tag)
|
||||
│ ├── release.py # Automated versioning, tagging, changelog
|
||||
│ ├── publish.py # Build and publish to Gitea PyPI registry
|
||||
│ ├── auto_merge.py # Squash-merge PRs with task ID validation
|
||||
│ ├── classify_changes.py # User-facing vs workflow-only change detection
|
||||
│ ├── detect_release_commit.py # Detect release commits on master
|
||||
│ ├── validate_commit_msg.py # Conventional commit validation
|
||||
│ ├── pr_review.py # Automated PR review
|
||||
│ ├── post_merge.py # Vikunja task updates after merge
|
||||
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
|
||||
│ ├── push_badges.py # Generate and push quality badges
|
||||
│ ├── notify_failure.py # Create Gitea issues on CI failures
|
||||
│ ├── merge_junit.py # Merge JUnit XML reports from parallel runners
|
||||
│ ├── distribute_files.py # Distribute files across parallel runners
|
||||
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast + JUnit
|
||||
│ ├── discover_runners.py # Dynamic Gitea runner discovery
|
||||
│ ├── check_translations.py # Translation completeness check
|
||||
│ └── doc_coverage.py # Documentation coverage check
|
||||
├── tools/ # Developer tooling modules (run locally or by CI)
|
||||
│ ├── __init__.py
|
||||
│ ├── setup.py # Environment setup (venv, deps, hooks, tea login)
|
||||
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea
|
||||
│ ├── check_test_speed.py # Measure unit test execution time
|
||||
│ ├── configure_repo.py # Branch protection and label setup
|
||||
│ ├── generate_badges.py # Badge SVG generation
|
||||
│ ├── generate_cliff_config.py # Generate cliff.toml with correct prefix
|
||||
│ └── install_checkmake.py # Install checkmake (Makefile linter)
|
||||
└── molecule/ # Optional molecule testing helpers (Ansible projects)
|
||||
├── __init__.py
|
||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||
├── distribute_molecule.py # Distribute scenarios across runners
|
||||
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast
|
||||
├── molecule_all.py # Run all molecule scenarios locally
|
||||
├── start_docker.py # Ensure Docker is available for molecule
|
||||
└── platforms.py # Supported molecule platforms
|
||||
```
|
||||
|
||||
## Core Modules
|
||||
## Core modules
|
||||
|
||||
### cli.py
|
||||
### `__init__.py`
|
||||
|
||||
Click-based CLI entry point. Provides three command groups: `devx ci`, `devx tools`, `devx molecule`. Each subcommand delegates to the corresponding module via `_run_module()`.
|
||||
Contains only `__version__`, the single source of truth for the package
|
||||
version. Read by setuptools via `dynamic = ["version"]` in `pyproject.toml`.
|
||||
Updated automatically by `devx.ci.release` during the release process. Treated
|
||||
as infrastructure (not user-facing) by the change classifier since it is a
|
||||
release artifact, not user code.
|
||||
|
||||
### i18n.py
|
||||
### `cli.py`
|
||||
|
||||
Simple i18n system using a JSON translations file. Supports en, bg, de, ru, zh. Projects can extend translations by setting `DEVX_TRANSLATIONS_PATH` to a custom JSON file.
|
||||
Click-based CLI entry point. Provides three command groups: `devx ci`,
|
||||
`devx tools`, and `devx molecule`. Each subcommand delegates to the
|
||||
corresponding module via `_run_module()`, which imports the module, sets
|
||||
`sys.argv`, and calls its `main()` function. This design keeps all logic in
|
||||
the modules themselves — `cli.py` is purely a router.
|
||||
|
||||
### exceptions.py
|
||||
The CLI is registered as a console script via `pyproject.toml`:
|
||||
```toml
|
||||
[project.scripts]
|
||||
devx = "devx.cli:cli"
|
||||
```
|
||||
|
||||
Custom exception hierarchy: `DevxError` (base), `APIError` (HTTP errors with status code and message).
|
||||
### `config.py`
|
||||
|
||||
### api_clients.py
|
||||
Shared configuration constants for all devx modules. All defaults can be
|
||||
overridden via environment variables with the `DEVX_` prefix. Provides:
|
||||
|
||||
HTTP API clients with connection pooling and retry logic:
|
||||
- `GiteaClient` — Gitea REST API (branch protection, labels, issues, PRs, releases, reviews)
|
||||
- `VikunjaClient` — Vikunja REST API (tasks, projects, comments)
|
||||
- `GITEA_API_URL` / `VIKUNJA_API_URL` — API endpoints
|
||||
- `REPO_OWNER` — repository owner (must be set per-project)
|
||||
- `TASK_PREFIX` / `TASK_ID_RE` — task ID prefix and regex (e.g., `DEVX-N`)
|
||||
- `VIKUNJA_PROJECT_ID` — Vikunja project for task tracking
|
||||
- `DEFAULT_TIMEOUT`, `DEFAULT_PER_PAGE` — HTTP client defaults
|
||||
- `MAX_RETRIES`, `RETRY_BACKOFF_BASE`, `RETRY_STATUS_CODES` — retry config
|
||||
- `CONVENTIONAL_RE` — conventional commit format regex
|
||||
|
||||
Both clients retry on transient errors (429, 5xx, connection errors) with exponential backoff.
|
||||
### `exceptions.py`
|
||||
|
||||
### config.py
|
||||
Custom exception hierarchy:
|
||||
|
||||
Configuration constants with env-var overrides (`DEVX_` prefix). Includes API URLs, timeouts, retry settings, task prefix regex, and conventional commit regex.
|
||||
- `DevxError` — base exception for all devx errors
|
||||
- `APIError(DevxError)` — raised when a REST API call returns an HTTP error.
|
||||
Carries `status` (HTTP status code) and `message` (error message).
|
||||
|
||||
### gitea_cli.py
|
||||
### `i18n.py`
|
||||
|
||||
Python wrapper around the `tea` Gitea CLI tool. Parses JSON output for structured data. Used by CI scripts for Gitea API operations (issues, labels, PRs, releases, reviews).
|
||||
Simple i18n system using a JSON translations file (`translations.json`).
|
||||
Supports five languages: `en`, `bg`, `de`, `ru`, `zh`. The `_()` function
|
||||
wraps user-facing strings for translation.
|
||||
|
||||
Projects can extend translations by setting `DEVX_TRANSLATIONS_PATH` to a
|
||||
custom JSON file. Keys from the project's file are merged on top of devx's
|
||||
built-in translations, allowing projects to override or add keys without
|
||||
modifying the package.
|
||||
|
||||
### `api_clients.py`
|
||||
|
||||
Reusable HTTP API clients with connection pooling and retry logic. Both
|
||||
clients retry on transient errors (429, 5xx, connection errors) with
|
||||
exponential backoff (2s, 4s, 8s).
|
||||
|
||||
**`GiteaClient`** — Gitea REST API wrapper:
|
||||
- Branch protection (get, create, update)
|
||||
- Labels (list, create, add to issues)
|
||||
- Issues (create, list)
|
||||
- Pull requests (get commits, merge, create review)
|
||||
- Releases (list)
|
||||
- Wiki pages (list, fetch, create, update, delete)
|
||||
|
||||
**`VikunjaClient`** — Vikunja REST API wrapper:
|
||||
- Tasks (list project tasks, get, update, mark done)
|
||||
- Comments (create)
|
||||
|
||||
### `gitea_cli.py`
|
||||
|
||||
Thin Python wrapper around the `tea` Gitea CLI tool. Parses JSON output for
|
||||
structured data. Used by CI scripts for Gitea API operations that tea handles
|
||||
well, avoiding hand-rolled HTTP requests.
|
||||
|
||||
**`TeaCLI`** operations:
|
||||
- `create_issue()` — Create issues with labels
|
||||
- `list_labels()` / `create_label()` / `add_label()` — Label management
|
||||
- `create_pr()` / `merge_pr()` / `review_pr()` — Pull request operations
|
||||
- `create_release()` / `list_releases()` — Release management
|
||||
- `list_branches()` — Branch listing
|
||||
|
||||
Operations NOT supported via tea (still use `GiteaClient`):
|
||||
- Wiki page management
|
||||
- Commit status checks
|
||||
- Runner discovery
|
||||
- PR file/commit listing (tea has limited support)
|
||||
- Branch protection with detailed config
|
||||
|
||||
### `opentofu.py`
|
||||
|
||||
OpenTofu output helpers for CI/CD deployment scripts. Provides reusable
|
||||
functions for extracting values from `tofu output` in a structured way,
|
||||
eliminating duplicated `subprocess.run` boilerplate:
|
||||
|
||||
- `get_tofu_output(output_name, cwd, env)` — Run `tofu output -json` and return parsed JSON
|
||||
- `get_tofu_vm_ip(output_name, vm_name, cwd, env)` — Extract a VM's IP address
|
||||
- `get_tofu_vm_field(output_name, vm_name, field, cwd, env)` — Extract a VM field
|
||||
|
||||
## CI/CD modules (`devx.ci`)
|
||||
|
||||
Modules in this package are run by Gitea Actions workflows. They may import
|
||||
from `devx.api_clients`, `devx.config`, `devx.gitea_cli`, and `devx.i18n`.
|
||||
|
||||
### `release.py`
|
||||
|
||||
Automated release using git-cliff. Calculates the next semver version from
|
||||
conventional commits since the last tag, updates `__version__` in
|
||||
`__init__.py` and `CHANGELOG.md`, runs lint and tests to verify the release
|
||||
is healthy, commits with `release: vX.Y.Z [skip ci]`, creates an annotated
|
||||
tag, and pushes both to master.
|
||||
|
||||
Idempotent: if there are no new conventional commits since the last tag, it
|
||||
exits without doing anything. If the tag already exists, it skips tag creation
|
||||
and only pushes. Includes a `--verify` mode that checks tag/version/changelog
|
||||
alignment without making changes.
|
||||
|
||||
### `publish.py`
|
||||
|
||||
Builds the Python package with `python -m build`, publishes to a Gitea PyPI
|
||||
registry (or standard PyPI if `PYPI_TOKEN` is set), and creates a Gitea
|
||||
release with git-cliff-generated notes. Supports `--skip-build` for non-Python
|
||||
repos that only need a Gitea release.
|
||||
|
||||
### `auto_merge.py`
|
||||
|
||||
Auto-merges a PR when all CI checks pass. Reads the task ID from the branch
|
||||
name (falling back to `.taskid` file), validates the PR title format against
|
||||
the Vikunja task title, extracts the conventional commit message from PR
|
||||
commits, and squash-merges with title `{PREFIX}-N <conventional commit>`.
|
||||
|
||||
If the head branch is behind master (HTTP 405), it automatically pulls master,
|
||||
rebases, force-pushes, and retries the merge.
|
||||
|
||||
### `classify_changes.py`
|
||||
|
||||
Classifies git changes between two refs as user-facing or workflow-only. Uses
|
||||
a layered rule system configured in `pyproject.toml` under
|
||||
`[tool.devx.classify]`:
|
||||
|
||||
1. **User-facing overrides** (highest priority — safety override)
|
||||
2. **Infrastructure overrides** (explicit per-file)
|
||||
3. **Infrastructure patterns** (DEFAULT_INFRASTRUCTURE + project-specific)
|
||||
4. **Default**: user-facing (safe default — any unknown file triggers release)
|
||||
|
||||
Also supports custom tags (orthogonal to release impact) for CI conditional
|
||||
execution (e.g., `ansible` tag to trigger molecule tests).
|
||||
|
||||
### `pr_review.py`
|
||||
|
||||
Automated PR review. Fetches the PR diff via the Gitea API and runs a series
|
||||
of checks, posting a structured review with `COMMENT` (no issues) or
|
||||
`REQUEST_CHANGES` (issues found):
|
||||
|
||||
- Architecture compliance (no subprocess in CLI, no hardcoded URLs)
|
||||
- Best practices (no `print()`, no bare `except`, no `TODO`/`FIXME`, no
|
||||
functions > 50 lines)
|
||||
- Security (no hardcoded secrets, no `shell=True`, no `eval`/`exec`)
|
||||
- i18n (no raw strings in `click.echo()` without `_()` wrapper)
|
||||
- Resource management (no `open()` without `with`, no `Popen()` without cleanup)
|
||||
- Documentation (source changes must include doc updates)
|
||||
- Test coverage (source changes must include test updates)
|
||||
- Commit conventions (conventional commit format on PR commits)
|
||||
|
||||
### `sync_wiki.py`
|
||||
|
||||
Syncs documentation from `docs/` to the Gitea wiki via the API. Reads
|
||||
`docs/mapping.json` to map file paths to wiki page titles, then creates or
|
||||
updates pages. Supports `--dry-run`, `--verify` (check content), and
|
||||
`--strict` (full integrity check: page count, missing pages, stale pages,
|
||||
content match).
|
||||
|
||||
### `push_badges.py`
|
||||
|
||||
Generates SVG badge files using `devx.tools.generate_badges`, pushes them to
|
||||
an orphan `badges` branch, and updates `README.md` and `docs/index.md` on
|
||||
master with cache-busting `raw/commit/<sha>/badge.svg` URLs (Gitea caches
|
||||
`raw/branch/` URLs for 6 hours). Fetches latest master before generating
|
||||
badges so the version badge reflects the current state. Supports `--retries`
|
||||
for retrying on git push failures.
|
||||
|
||||
### `notify_failure.py`
|
||||
|
||||
Creates a Gitea issue when a CI workflow fails. Uses the `tea` CLI for issue
|
||||
creation with failure labels. Supports `--auto-login` to configure the tea
|
||||
CLI login profile from `REPO_TOKEN` and `DEVX_GITEA_API_URL` before creating
|
||||
the issue.
|
||||
|
||||
### `post_merge.py`
|
||||
|
||||
Updates the Vikunja task after a merge to master. Extracts the task ID from
|
||||
the commit message, marks the task as done, and posts a comment with the
|
||||
merge SHA.
|
||||
|
||||
### `validate_commit_msg.py`
|
||||
|
||||
Validates commit messages. On feature branches: conventional commits only
|
||||
(no `{PREFIX}-N` prefix). On master: must have `{PREFIX}-N` prefix from
|
||||
auto-merge, followed by a conventional commit message.
|
||||
|
||||
### `detect_release_commit.py`
|
||||
|
||||
Detects whether the latest git commit is a release commit
|
||||
(`release: vX.Y.Z [skip ci]`). Writes `is-release=true` or `is-release=false`
|
||||
to `$GITHUB_OUTPUT` for use in CI workflow conditionals.
|
||||
|
||||
### `check_translations.py`
|
||||
|
||||
Validates translation files against the Python source code. Checks for
|
||||
missing keys (used in code but not in translations), dead keys (defined but
|
||||
not used), and missing languages (a key exists but is missing one of the five
|
||||
supported languages). Supports checking additional translation sets via
|
||||
`--translations`.
|
||||
|
||||
### `doc_coverage.py`
|
||||
|
||||
Checks documentation coverage for CLI commands and major modules. Parses
|
||||
Click commands from `cli.py` and verifies each has documentation in
|
||||
`docs/user/cli-commands.md`. Checks that core modules are documented in
|
||||
`architecture.md` and CI scripts in `ci-cd-workflow.md`. Supports
|
||||
`--fail-on-missing` to enforce 100% coverage.
|
||||
|
||||
### `discover_runners.py`
|
||||
|
||||
Discovers available Gitea Actions runners at three levels: repository,
|
||||
organization, and instance (admin). Falls back to the `MOLECULE_RUNNERS` repo
|
||||
variable or `DEFAULT_MAX_RUNNERS` (3). Outputs runner count or a JSON index
|
||||
array for use as a dynamic matrix in Gitea Actions.
|
||||
|
||||
### `distribute_files.py`
|
||||
|
||||
Distributes files matching a glob pattern across N parallel runners
|
||||
(round-robin). Writes the assigned file list for the current runner to
|
||||
`$GITHUB_ENV`. Used for splitting test suites across CI runners.
|
||||
|
||||
### `merge_junit.py`
|
||||
|
||||
Merges JUnit XML reports from parallel matrix runners into a single
|
||||
consolidated report. Exit code is non-zero if any merged suite reports
|
||||
failures, making it suitable as a CI gating step.
|
||||
|
||||
### `integration_guard.py`
|
||||
|
||||
Runs pytest with the same cross-runner failure detection mechanism used by
|
||||
`molecule_ci_guard`. If any other integration-tests matrix runner reports
|
||||
failure, the current pytest subprocess is killed and this runner exits early.
|
||||
Generates JUnit XML via pytest's `--junitxml` flag.
|
||||
|
||||
## Developer tools (`devx.tools`)
|
||||
|
||||
Modules in this package are run locally or by CI setup jobs. They may import
|
||||
from `devx.api_clients`, `devx.config`, and `devx.gitea_cli`.
|
||||
|
||||
### `setup.py`
|
||||
|
||||
Project setup: installs Python dependencies (editable mode with extras),
|
||||
Ansible Galaxy collections (if `ansible/requirements.yml` exists), pre-commit
|
||||
hooks (pre-commit, commit-msg, pre-push), and configures the `tea` CLI login
|
||||
profile from `.env`. Supports `--extras` to specify dependency groups,
|
||||
`--no-pre-commit` to skip hook installation, and `--no-tea-login` to skip tea
|
||||
configuration.
|
||||
|
||||
### `install_tools.py`
|
||||
|
||||
Installs CI/CD development tools that are not Python packages: actionlint,
|
||||
git-cliff, act_runner, and tea. Each tool is installed to `~/.local/bin` if
|
||||
not already on PATH. Idempotent: skips tools that are already available.
|
||||
Supports `--tool` to install specific tools and `--list` to show status.
|
||||
|
||||
### `check_test_speed.py`
|
||||
|
||||
Runs unit tests and enforces execution-time budgets. Two quality gates:
|
||||
total suite time must not exceed `--max-seconds` (default: 10s), and no
|
||||
individual test may exceed `--max-single-seconds` (default: 0.5s, 0 to
|
||||
disable). Runs `make test-unit` with `PYTEST_ADDOPTS=--durations=0`.
|
||||
|
||||
### `configure_repo.py`
|
||||
|
||||
Configures repository branch protection and labels via the Gitea REST API.
|
||||
Sets up master branch protection (required status checks, block on rejected
|
||||
reviews, block on outdated branch) and creates standard labels. Status check
|
||||
contexts are read from `DEVX_STATUS_CHECKS` or default to
|
||||
`CI / quality (pull_request)`.
|
||||
|
||||
### `generate_badges.py`
|
||||
|
||||
Generates self-contained SVG badge files from project metrics. Runs
|
||||
pytest-cov, doc-coverage, lint checks, and version extraction, then writes
|
||||
SVG files that can be served as static files from the Gitea raw file API.
|
||||
Badges generated: coverage, tests, docs, quality, version, python.
|
||||
|
||||
### `generate_cliff_config.py`
|
||||
|
||||
Generates a `cliff.toml` configuration file with the correct task ID prefix
|
||||
preprocessor. Eliminates the need to manually duplicate and maintain
|
||||
`cliff.toml` across repos that use devx. Supports `--prefix` to set the task
|
||||
ID prefix and `--force` to overwrite an existing file.
|
||||
|
||||
### `install_checkmake.py`
|
||||
|
||||
Installs checkmake (Makefile linter) if not already present. Tries
|
||||
`go install` first if Go is available, otherwise downloads the latest
|
||||
pre-built Linux binary from the official GitHub releases.
|
||||
|
||||
## Molecule modules (`devx.molecule`)
|
||||
|
||||
Optional modules for projects with Ansible roles. Requires the `molecule`
|
||||
extra (`pip install devx[molecule]`).
|
||||
|
||||
### `distribute_molecule.py`
|
||||
|
||||
Distributes molecule (scenario, platform) pairs across N parallel runners.
|
||||
Discovers scenarios under `ansible/roles/*/molecule/` and crosses them with
|
||||
the supported OS platform matrix. Supports `--roles-root` for multi-role
|
||||
repositories, `--list` to list scenarios, and `--list-platforms` to list
|
||||
platforms.
|
||||
|
||||
### `molecule_ci_guard.py`
|
||||
|
||||
Runs molecule tests sequentially while polling the Gitea API for other runner
|
||||
failures. If any other molecule matrix runner reports failure, the current
|
||||
molecule subprocess is killed and this runner exits early. Generates JUnit
|
||||
XML when `--junit-output` is provided. Supports both single-role (4-part) and
|
||||
multi-role (5-part) pair encoding.
|
||||
|
||||
### `molecule_all.py`
|
||||
|
||||
Runs all molecule scenarios on all supported OS platforms sequentially.
|
||||
Intended for local development; CI uses the parallel matrix instead.
|
||||
|
||||
### `discover_runners.py`
|
||||
|
||||
Discovers available Gitea Actions runners for molecule tests. Same logic as
|
||||
`devx.ci.discover_runners` but intended for molecule-specific workflows.
|
||||
|
||||
### `start_docker.py`
|
||||
|
||||
Ensures Docker is available for molecule tests in CI. Verifies Docker is
|
||||
accessible and sets `DOCKER_HOST` explicitly. If the host socket is not
|
||||
available, tries the rootless socket, then starts a local `dockerd` with the
|
||||
vfs storage driver (requires privileged container).
|
||||
|
||||
### `platforms.py`
|
||||
|
||||
Single source of truth for the supported OS platform matrix. Each entry maps
|
||||
a short name to (image, command). Uses the project's pre-built
|
||||
molecule-test-base image with `sleep infinity` (not systemd) to avoid cgroup
|
||||
v2 failures. Supports loading custom platforms from a JSON file.
|
||||
|
||||
## Design principles
|
||||
|
||||
- **Self-contained package** — `src/devx/` never imports from scripts outside
|
||||
the package. This allows devx to be installed and used as a dependency
|
||||
without requiring a specific repo layout in the consumer.
|
||||
- **Module-based invocation** — All tools invoked via `python -m devx.ci.*`,
|
||||
`python -m devx.tools.*`, or `python -m devx.molecule.*`. The `devx` CLI is
|
||||
a thin router that delegates to module `main()` functions.
|
||||
- **PYTHONPATH: src** — Workflows set `PYTHONPATH: src` (not `.:src` since
|
||||
there are no scripts at repo root). The `src` directory is the sole import
|
||||
root.
|
||||
- **Config via env vars** — `DEVX_*` environment variables with `.env` file
|
||||
fallback. Projects override defaults via environment or `.env`, never by
|
||||
editing package code.
|
||||
- **100% test coverage** — enforced by `--cov-fail-under=100` in pytest.
|
||||
- **i18n by default** — all user-facing strings wrapped in `_()` for
|
||||
translation. Five languages supported out of the box.
|
||||
- **Safe-by-default classification** — any file that doesn't match an
|
||||
infrastructure pattern defaults to user-facing, triggering a release. This
|
||||
prevents new file types from accidentally skipping releases.
|
||||
- **Secrets via environment** — secrets are passed via environment variables,
|
||||
never on the command line.
|
||||
|
||||
## Import rules
|
||||
|
||||
1. **`src/devx/` is self-contained** — the package never imports from outside `src/`
|
||||
2. **CI modules** (`devx.ci.*`) may import from `devx.api_clients`,
|
||||
`devx.config`, `devx.gitea_cli`, `devx.i18n`
|
||||
3. **Tool modules** (`devx.tools.*`) may import from `devx.api_clients`,
|
||||
`devx.config`, `devx.gitea_cli`
|
||||
4. **Cross-module imports** within `devx.ci.*` or `devx.tools.*` are allowed
|
||||
but must be documented (e.g., `release.py` imports from
|
||||
`classify_changes.py`)
|
||||
|
||||
## Data flow
|
||||
|
||||
### PR lifecycle
|
||||
|
||||
```
|
||||
Developer creates Vikunja task (DEVX-N)
|
||||
│
|
||||
▼
|
||||
Developer creates branch (DEVX-N-short-description)
|
||||
│
|
||||
▼
|
||||
Developer commits (conventional commits, no DEVX-N prefix)
|
||||
│
|
||||
▼
|
||||
Developer pushes and creates PR (title: "DEVX-N: <vikunja task title>")
|
||||
│
|
||||
▼
|
||||
CI workflow (ci.yml) triggers:
|
||||
│
|
||||
├── quality (lint, tests, coverage, test speed, doc coverage,
|
||||
│ translation check, dependency scan, workflow dry-run)
|
||||
│
|
||||
├── detect-changes (classify_changes.py → user-facing or workflow-only)
|
||||
│ └── if user-facing → release-dry-run (release.py --dry-run)
|
||||
│
|
||||
├── pr-review (pr_review.py → posts COMMENT or REQUEST_CHANGES)
|
||||
│
|
||||
└── auto-merge (auto_merge.py)
|
||||
├── validate PR title format
|
||||
├── validate PR title matches Vikunja task title
|
||||
├── extract conventional commit message from PR commits
|
||||
├── squash-merge with "DEVX-N <conventional commit>" title
|
||||
└── push to master
|
||||
│
|
||||
▼
|
||||
Post-merge workflow triggers (see below)
|
||||
```
|
||||
|
||||
### Post-merge flow
|
||||
|
||||
```
|
||||
Push to master (squash-merge commit: "DEVX-N <conventional commit>")
|
||||
│
|
||||
▼
|
||||
Post-merge workflow (post-merge.yml) triggers:
|
||||
│
|
||||
├── detect-type (detect_release_commit.py)
|
||||
│ └── is-release? → skip all jobs except badges
|
||||
│
|
||||
├── validate-commit-msg (validate_commit_msg.py --branch master)
|
||||
│
|
||||
├── release (release.py)
|
||||
│ ├── classify_changes.py → skip if workflow-only
|
||||
│ ├── git-cliff → calculate next version
|
||||
│ ├── update __version__ in __init__.py
|
||||
│ ├── update CHANGELOG.md
|
||||
│ ├── run make lint-ruff && make pytest-cov
|
||||
│ ├── commit "release: vX.Y.Z [skip ci]"
|
||||
│ ├── create annotated tag vX.Y.Z
|
||||
│ └── push commit + tag to master
|
||||
│ │
|
||||
│ ▼
|
||||
│ Tag push triggers publish workflow (see below)
|
||||
│
|
||||
├── sync-wiki (sync_wiki.py --strict)
|
||||
│ └── sync docs/ to Gitea wiki with integrity check
|
||||
│
|
||||
├── badges (push_badges.py) [ALWAYS runs, even on release commits]
|
||||
│ ├── fetch latest master
|
||||
│ ├── generate_badges.py → SVG files
|
||||
│ ├── push to orphan badges branch
|
||||
│ └── update README.md + docs/index.md with cache-busting URLs
|
||||
│
|
||||
├── vikunja (post_merge.py)
|
||||
│ ├── extract task ID from commit message
|
||||
│ ├── mark Vikunja task as done
|
||||
│ └── post comment with merge SHA
|
||||
│
|
||||
└── configure-repo (configure_repo.py)
|
||||
└── ensure branch protection and labels
|
||||
```
|
||||
|
||||
### Publish flow
|
||||
|
||||
```
|
||||
Tag push (vX.Y.Z) triggers publish workflow (publish.yml):
|
||||
│
|
||||
▼
|
||||
├── install build, twine, git-cliff, tea
|
||||
├── configure tea login
|
||||
│
|
||||
└── publish (publish.py)
|
||||
├── build package (python -m build)
|
||||
├── publish to Gitea PyPI registry (twine upload)
|
||||
│ OR publish to standard PyPI (if PYPI_TOKEN set)
|
||||
│ OR skip publish (if --skip-build)
|
||||
└── create Gitea release with git-cliff notes
|
||||
```
|
||||
|
||||
### Badge generation flow
|
||||
|
||||
```
|
||||
push_badges.py:
|
||||
│
|
||||
├── fetch_latest_master() → git fetch + reset --hard origin/master
|
||||
│
|
||||
├── generate_badges() → devx.tools.generate_badges
|
||||
│ ├── run pytest-cov → parse coverage %
|
||||
│ ├── run pytest → parse test count
|
||||
│ ├── run doc_coverage → parse doc coverage %
|
||||
│ ├── run lint → quality status
|
||||
│ ├── read __version__ from __init__.py
|
||||
│ └── write SVG files to .badges/
|
||||
│
|
||||
├── push_to_badges_branch()
|
||||
│ ├── git checkout --orphan badges
|
||||
│ ├── git rm -rf .
|
||||
│ ├── copy SVG files to root
|
||||
│ ├── git commit "Update badges [skip ci]"
|
||||
│ ├── git push origin badges --force
|
||||
│ └── return commit SHA
|
||||
│
|
||||
└── update_readme_with_badge_sha()
|
||||
├── git checkout master
|
||||
├── replace raw/branch/badges/ URLs with raw/commit/<sha>/ URLs
|
||||
├── git commit "chore: update badge URLs [skip ci]"
|
||||
└── git push origin master
|
||||
```
|
||||
|
||||
## tea CLI integration
|
||||
|
||||
The `tea` Gitea CLI tool is used for Gitea API interactions where tea provides
|
||||
reliable, official support. It is installed by
|
||||
`python -m devx.tools.install_tools` and configured by
|
||||
`python -m devx.tools.setup` (login profile from `.env` `REPO_TOKEN`).
|
||||
|
||||
`devx.gitea_cli.TeaCLI` wraps tea with JSON output parsing. Operations that
|
||||
tea does not support (wiki management, commit status, runner discovery,
|
||||
detailed branch protection) fall back to `GiteaClient` (direct HTTP).
|
||||
|
||||
## Version source
|
||||
|
||||
The version source is `__version__` in `src/devx/__init__.py`, read by
|
||||
setuptools via `dynamic = ["version"]` in `pyproject.toml`. The release
|
||||
script updates this file, commits it, and tags the commit. This ensures the
|
||||
package version, git tag, and changelog always stay aligned.
|
||||
|
||||
+519
-47
@@ -1,85 +1,557 @@
|
||||
# CI/CD Workflow
|
||||
|
||||
devx uses Gitea Actions for CI/CD automation. The workflow replicates GRM's automated pipeline but without molecule tests.
|
||||
devx uses Gitea Actions for CI/CD automation. Three workflows implement a
|
||||
complete pipeline: pull request validation, post-merge release automation, and
|
||||
tag-triggered publishing.
|
||||
|
||||
## Workflows
|
||||
## Workflow overview
|
||||
|
||||
### CI (`ci.yml`)
|
||||
```
|
||||
PR opened/synchronized ──► CI (ci.yml)
|
||||
│ ├── quality
|
||||
│ ├── detect-changes
|
||||
│ ├── release-dry-run (if user-facing)
|
||||
│ ├── pr-review
|
||||
│ └── auto-merge ──► squash-merge to master
|
||||
│ │
|
||||
▼ ▼
|
||||
Push to master ──► Post-merge (post-merge.yml)
|
||||
├── detect-type
|
||||
├── validate-commit-msg
|
||||
├── release ──► tag vX.Y.Z
|
||||
├── sync-wiki │
|
||||
├── badges │
|
||||
├── vikunja │
|
||||
└── configure-repo │
|
||||
│
|
||||
▼
|
||||
Tag push (v*) ──► Publish (publish.yml)
|
||||
└── publish ──► Gitea PyPI registry + Gitea release
|
||||
```
|
||||
|
||||
Runs on pull requests. Jobs:
|
||||
## CI workflow (`ci.yml`)
|
||||
|
||||
1. **quality** — lint (ruff, pyright, bandit, actionlint), unit tests with 100% coverage, test speed check, doc coverage, translation check, dependency scan
|
||||
2. **detect-changes** — classify changes as user-facing or workflow-only
|
||||
3. **release-dry-run** — dry-run the release script (only if user-facing changes)
|
||||
4. **pr-review** — automated PR review
|
||||
5. **auto-merge** — squash-merge PR when all checks pass
|
||||
Runs on pull requests (opened and synchronize) and manual dispatch.
|
||||
|
||||
### Post-merge (`post-merge.yml`)
|
||||
### Jobs
|
||||
|
||||
Runs on every push to master. Jobs:
|
||||
#### `quality`
|
||||
|
||||
1. **detect-type** — check if commit is a release commit
|
||||
2. **validate-commit-msg** — validate conventional commit format
|
||||
3. **release** — calculate next version, update changelog, tag, push
|
||||
4. **sync-wiki** — sync docs to Gitea wiki
|
||||
5. **badges** — generate and push quality badges
|
||||
6. **vikunja** — mark Vikunja task as done
|
||||
7. **configure-repo** — ensure branch protection and labels
|
||||
The main quality gate. Runs on every PR:
|
||||
|
||||
### Publish (`publish.yml`)
|
||||
1. **Lint all** — ruff check, ruff format check, pyright, bandit, actionlint
|
||||
(via `make lint-all`)
|
||||
2. **Unit tests with 100% coverage** — `make pytest-cov`
|
||||
3. **Check unit test speed** — `python -m devx.tools.check_test_speed
|
||||
--max-seconds 4 --max-single-seconds 0.5`
|
||||
4. **Documentation coverage check** — `python -m devx.ci.doc_coverage
|
||||
--fail-on-missing`
|
||||
5. **Translation completeness check** — `python -m devx.ci.check_translations`
|
||||
6. **Dependency security scan** — `pip-audit --desc --skip-editable`
|
||||
(best-effort, non-blocking)
|
||||
7. **Workflow dry-run validation** — `make workflow-dryrun` via act_runner
|
||||
(best-effort, skipped if act_runner is not installed)
|
||||
|
||||
Runs on tag pushes (`v*`). Builds the package, publishes to Gitea PyPI registry, and creates a Gitea release.
|
||||
#### `detect-changes`
|
||||
|
||||
## CI Scripts
|
||||
Classifies changes between `origin/master` and the PR head as user-facing or
|
||||
workflow-only using `python -m devx.ci.classify_changes --github-output`.
|
||||
Writes `user-facing-changed=true|false` to the job output for use by
|
||||
downstream jobs.
|
||||
|
||||
### auto_merge.py
|
||||
#### `release-dry-run`
|
||||
|
||||
Auto-merge PR when all CI checks pass. Reads task ID from `.taskid`, validates PR title format, checks Vikunja task exists, squash-merges with `DEVX-N <conventional commit>` title.
|
||||
Depends on `quality` and `detect-changes`. Only runs if user-facing changes
|
||||
are detected. Runs `python -m devx.ci.release --dry-run` to validate that
|
||||
the release script can calculate the next version and generate the changelog
|
||||
without making changes. Non-blocking (uses `|| true`).
|
||||
|
||||
### release.py
|
||||
#### `pr-review`
|
||||
|
||||
Automated release using git-cliff. Calculates next semver version from conventional commits, updates `__version__` in `__init__.py`, updates `CHANGELOG.md`, runs lint and tests, commits with `release: vX.Y.Z [skip ci]`, creates annotated tag, pushes.
|
||||
Runs on every pull request. Executes `python -m devx.ci.pr_review` with the
|
||||
PR number and repository. Fetches the PR diff via the Gitea API and runs
|
||||
automated checks, posting a structured review:
|
||||
|
||||
### publish.py
|
||||
- `COMMENT` — no issues found
|
||||
- `REQUEST_CHANGES` — issues found that must be addressed
|
||||
|
||||
Builds package with `python -m build`, publishes to Gitea PyPI registry via twine, creates Gitea release with git-cliff-generated notes.
|
||||
Checks performed:
|
||||
1. Architecture compliance — no subprocess in CLI, no hardcoded URLs
|
||||
2. Best practices — no `print()`, no bare `except`, no `TODO`/`FIXME`,
|
||||
no functions > 50 lines
|
||||
3. Security — no hardcoded secrets, no `shell=True`, no `eval`/`exec`
|
||||
4. i18n — no raw strings in `click.echo()` without `_()` wrapper
|
||||
5. Resource management — no `open()` without `with`, no `Popen()` without
|
||||
cleanup
|
||||
6. Documentation — source changes must include doc updates
|
||||
7. Test coverage — source changes must include test updates
|
||||
8. Commit conventions — conventional commit format on PR commits
|
||||
|
||||
### pr_review.py
|
||||
#### `auto-merge`
|
||||
|
||||
Automated PR review. Checks architecture compliance, best practices, security, i18n, resource management, documentation, test coverage, and commit conventions. Posts inline comments and structured review.
|
||||
Depends on `quality`, `detect-changes`, and `pr-review`. The final job in the
|
||||
CI workflow. Runs `python -m devx.ci.auto_merge` with the branch name, PR
|
||||
title, repository, and PR number:
|
||||
|
||||
### notify_failure.py
|
||||
1. **Read task ID** from branch name (e.g., `DEVX-12-fix-foo` → `DEVX-12`),
|
||||
falling back to `.taskid` file for branches without a task ID prefix
|
||||
2. **Validate PR title format** — must be `{PREFIX}-N: <vikunja task title>`
|
||||
3. **Validate PR title matches Vikunja task** — fetches the Vikunja task and
|
||||
compares the title
|
||||
4. **Extract conventional commit message** from PR commits (newest matching
|
||||
conventional format)
|
||||
5. **Squash-merge** with title `{PREFIX}-N <conventional commit message>`
|
||||
6. If the head branch is behind master (HTTP 405), automatically pulls master,
|
||||
rebases, force-pushes, and retries the merge
|
||||
|
||||
Creates a Gitea issue when a CI workflow fails. Uses tea CLI for issue creation with failure labels.
|
||||
The merge commit push to master triggers the post-merge workflow.
|
||||
|
||||
### post_merge.py
|
||||
### Smart CI: user-facing vs workflow-only changes
|
||||
|
||||
Updates Vikunja task after a merge to master. Extracts task ID from commit message, marks task as done, posts a comment with the merge SHA.
|
||||
Not all changes require a new release. The `detect-changes` job classifies
|
||||
changes using `python -m devx.ci.classify_changes`:
|
||||
|
||||
### classify_changes.py
|
||||
**Workflow-only paths** (infrastructure — no release needed):
|
||||
- `.gitea/**` — Gitea Actions workflows
|
||||
- `tests/**` — Test files
|
||||
- `AGENTS.md`, `README.md`, `CHANGELOG.md` — Project docs
|
||||
- `Makefile`, `cliff.toml`, `.pre-commit-config.yaml` — Config
|
||||
- `.env.example`, `.gitignore` — Config
|
||||
- `hooks/**` — Git hooks
|
||||
- `src/devx/__init__.py` — Only contains `__version__` (release artifact)
|
||||
|
||||
Classifies git changes as user-facing or workflow-only. Used to skip releases for infrastructure-only changes. Patterns are configurable.
|
||||
**User-facing paths** (tool changes — release needed) — everything else:
|
||||
- `src/devx/**` — Python package source (except `__init__.py`)
|
||||
- `pyproject.toml` — Package metadata
|
||||
- Any new file type not in the allowlist
|
||||
|
||||
### discover_runners.py
|
||||
Classification is configured in `pyproject.toml` under
|
||||
`[tool.devx.classify]`. The framework provides `DEFAULT_INFRASTRUCTURE` — a
|
||||
curated list of paths that are infrastructure for any Python project. Projects
|
||||
inherit these automatically and only specify what is different.
|
||||
|
||||
Discovers available Gitea Actions runners at repo, org, and instance levels. Generates a dynamic matrix for parallel job distribution.
|
||||
Rule priority (first match wins):
|
||||
1. `user_facing_overrides` — safety override (highest priority)
|
||||
2. `infrastructure_overrides` — explicit per-file
|
||||
3. `infrastructure` — DEFAULT_INFRASTRUCTURE + project-specific patterns
|
||||
4. Default: user-facing (safe — any unknown file triggers release)
|
||||
|
||||
### detect_release_commit.py
|
||||
## Post-merge workflow (`post-merge.yml`)
|
||||
|
||||
Detects whether the latest git commit is a release commit. Writes `is-release=true` or `is-release=false` to GitHub output.
|
||||
Runs on every push to master. A single workflow with conditional jobs
|
||||
replaces separate workflows for release, wiki sync, badges, and Vikunja task
|
||||
updates.
|
||||
|
||||
### push_badges.py
|
||||
### Job dependency graph
|
||||
|
||||
Generates SVG badge files from project metrics (tests, coverage, quality, version). Pushes to `badges` branch and updates README with cache-busting commit SHA URLs.
|
||||
```
|
||||
detect-type ──┬── validate-commit-msg (skip if release commit)
|
||||
├── release (skip if release commit)
|
||||
│ │
|
||||
│ ├── sync-wiki (needs release)
|
||||
│ ├── badges (needs release, ALWAYS runs)
|
||||
│ └── vikunja (needs release)
|
||||
└── configure-repo (independent, skip if release commit)
|
||||
```
|
||||
|
||||
### distribute_molecule.py
|
||||
`sync-wiki` and `vikunja` depend on `release` succeeding so that the wiki and
|
||||
task tracker are only updated when the code is actually released. If release
|
||||
fails, they are skipped to avoid leaving the wiki or Vikunja in an
|
||||
inconsistent state.
|
||||
|
||||
Distributes molecule (scenario, platform) pairs across N parallel runners. Discovers scenarios under `ansible/roles/*/molecule/`.
|
||||
The `badges` job uses `if: always()` with no is-release condition so it runs
|
||||
on every push to master, including release commits. This ensures badges
|
||||
(tests, coverage, version, etc.) are always current.
|
||||
|
||||
### molecule_ci_guard.py
|
||||
When `release` creates a `release: vX.Y.Z` commit, the release commit's
|
||||
post-merge run still updates badges (the version badge picks up the new
|
||||
version). Other jobs skip. The tag push triggers `publish.yml`.
|
||||
|
||||
Runs molecule tests sequentially while polling Gitea for other runner failures. Aborts if another runner fails the same job.
|
||||
### Jobs
|
||||
|
||||
### validate_commit_msg.py
|
||||
#### `detect-type`
|
||||
|
||||
Validates commit messages. On feature branches: conventional commits only (no `DEVX-N` prefix). On master: must have `DEVX-N` prefix from auto-merge.
|
||||
Checks if the latest commit is a release commit (`release: vX.Y.Z [skip ci]`)
|
||||
using `python -m devx.ci.detect_release_commit`. Writes `is-release=true` or
|
||||
`is-release=false` to the job output. All subsequent jobs use this to
|
||||
conditionally skip for release commits.
|
||||
|
||||
#### `validate-commit-msg`
|
||||
|
||||
Depends on `detect-type`. Skips for release commits. Validates the latest
|
||||
commit message using `python -m devx.ci.validate_commit_msg --branch master`.
|
||||
On master, commits must follow `{PREFIX}-N: <conventional commit>` format
|
||||
(added by auto-merge).
|
||||
|
||||
#### `release`
|
||||
|
||||
Depends on `detect-type`. Skips for release commits. The core release
|
||||
automation job. Runs `python -m devx.ci.release`:
|
||||
|
||||
1. **Classify changes** — calls `classify_changes.py` to check for user-facing
|
||||
changes. If only infrastructure files changed, exits without releasing.
|
||||
2. **Calculate next version** — uses git-cliff to determine the next semver
|
||||
version from conventional commits since the last tag
|
||||
3. **Update version file** — updates `__version__` in `src/devx/__init__.py`
|
||||
4. **Update changelog** — prepends the new version section to `CHANGELOG.md`
|
||||
using git-cliff output
|
||||
5. **Run tests** — executes `make lint-ruff` and `make pytest-cov` to verify
|
||||
the release is healthy. If either fails, the release is aborted — no
|
||||
commit, no tag. Use `--skip-tests` only for emergency releases.
|
||||
6. **Commit** — stages the version file and changelog, commits with
|
||||
`release: vX.Y.Z [skip ci]` (uses `--no-verify` to bypass the commit-msg
|
||||
hook since release commits are a special case)
|
||||
7. **Create tag** — creates an annotated tag `vX.Y.Z` with the changelog as
|
||||
the tag message
|
||||
8. **Push** — pushes both the commit and tag to master
|
||||
|
||||
The script is idempotent: if there are no new conventional commits since the
|
||||
last tag, it exits without doing anything. If the tag already exists (e.g.,
|
||||
from a partial previous run), it skips tag creation and only pushes.
|
||||
|
||||
**Tag consistency**: Before releasing, the script fetches remote tags and
|
||||
verifies all existing tags point to commits whose message matches the tag
|
||||
version. This prevents duplicate release commits and ensures
|
||||
tag/version/commit alignment.
|
||||
|
||||
**Version bumping rules** (git-cliff):
|
||||
|
||||
| Commit type | Version bump |
|
||||
|-------------|-------------|
|
||||
| `feat:` | minor (0.X.0) |
|
||||
| `fix:` | patch (0.0.X) |
|
||||
| `feat!:` or `BREAKING CHANGE` | minor (pre-1.0) |
|
||||
| `chore:`, `ci:`, `docs:` | no bump (excluded by cliff.toml) |
|
||||
|
||||
On failure, the `notify_failure` step creates a Gitea issue via
|
||||
`python -m devx.ci.notify_failure`.
|
||||
|
||||
#### `sync-wiki`
|
||||
|
||||
Depends on `detect-type` and `release`. Skips for release commits. Syncs
|
||||
documentation from `docs/` to the Gitea wiki using
|
||||
`python -m devx.ci.sync_wiki --repo <owner/repo> --strict`:
|
||||
|
||||
1. Reads `docs/mapping.json` to map file paths to wiki page titles
|
||||
2. Lists existing wiki pages via the Gitea API
|
||||
3. For each mapped file, reads content and creates or updates the wiki page
|
||||
4. `--strict` runs a full integrity check: verifies page count, missing
|
||||
pages, stale pages, and content match. Fails if any page is empty or
|
||||
content doesn't match.
|
||||
|
||||
Pages that exist in the wiki but not in the mapping are left untouched (not
|
||||
deleted).
|
||||
|
||||
On failure, the `notify_failure` step creates a Gitea issue.
|
||||
|
||||
#### `badges`
|
||||
|
||||
Depends on `detect-type` and `release`. Uses `if: always()` so it runs on
|
||||
every push to master, including release commits. Generates and pushes quality
|
||||
badges using `python -m devx.ci.push_badges`:
|
||||
|
||||
1. **Fetch latest master** — `git fetch origin master && git reset --hard
|
||||
origin/master` (ensures the version badge reflects the current state,
|
||||
even if the release job just pushed a new version)
|
||||
2. **Generate badges** — calls `devx.tools.generate_badges` which runs
|
||||
pytest-cov, doc-coverage, lint checks, and version extraction, then writes
|
||||
SVG files: `coverage.svg`, `tests.svg`, `docs.svg`, `quality.svg`,
|
||||
`version.svg`, `python.svg`
|
||||
3. **Push to badges branch** — creates an orphan `badges` branch, copies SVG
|
||||
files, commits, and force-pushes
|
||||
4. **Update README/docs** — switches back to master, replaces
|
||||
`raw/branch/badges/<name>.svg` URLs with `raw/commit/<sha>/<name>.svg`
|
||||
URLs (cache-busting — Gitea caches `raw/branch/` URLs for 6 hours),
|
||||
commits, and pushes
|
||||
|
||||
Supports `--retries` for retrying on git push failures (fetches latest master
|
||||
and waits 10s between attempts).
|
||||
|
||||
On failure, the `notify_failure` step creates a Gitea issue.
|
||||
|
||||
#### `vikunja`
|
||||
|
||||
Depends on `detect-type` and `release`. Skips for release commits. Updates
|
||||
the Vikunja task after a merge using `python -m devx.ci.post_merge --git-sha
|
||||
<sha>`:
|
||||
|
||||
1. Extracts the task ID from the first line of the commit message
|
||||
2. Marks the corresponding Vikunja task as done
|
||||
3. Posts a comment with the merge SHA
|
||||
|
||||
On failure, the `notify_failure` step creates a Gitea issue.
|
||||
|
||||
#### `configure-repo`
|
||||
|
||||
Depends on `detect-type`. Skips for release commits. Ensures branch
|
||||
protection and labels are configured using
|
||||
`python -m devx.tools.configure_repo --repo <name> --owner <owner>`:
|
||||
|
||||
- Sets up master branch protection (required status checks, block on rejected
|
||||
reviews, block on outdated branch)
|
||||
- Creates standard labels
|
||||
- Status check contexts read from `DEVX_STATUS_CHECKS` or default to
|
||||
`CI / quality (pull_request)`
|
||||
|
||||
On failure, the `notify_failure` step creates a Gitea issue.
|
||||
|
||||
## Publish workflow (`publish.yml`)
|
||||
|
||||
Runs on tag pushes matching `v*`. Triggered by the `release` job in the
|
||||
post-merge workflow when it creates and pushes a new version tag.
|
||||
|
||||
### Job: `publish`
|
||||
|
||||
1. **Install dependencies** — build, twine, requests, python-dotenv, click,
|
||||
and the project itself
|
||||
2. **Install CI tools** — git-cliff and tea via
|
||||
`python -m devx.tools.install_tools`
|
||||
3. **Configure tea login** — `tea login add` using `REPO_TOKEN`
|
||||
4. **Build and publish** — `python -m devx.ci.publish <tag> <owner/repo>`:
|
||||
- Build the package with `python -m build`
|
||||
- Publish to the Gitea PyPI registry (default) using `twine upload
|
||||
--repository-url <url> -u <token> -p <token>`
|
||||
- OR publish to standard PyPI if `PYPI_TOKEN` is set
|
||||
- OR skip publishing if `--skip-build` is passed (non-Python repos)
|
||||
- Create a Gitea release with git-cliff-generated release notes via
|
||||
`tea create release`
|
||||
|
||||
Publishing destination resolution (checked in order):
|
||||
1. **Gitea PyPI registry** — if `--registry-url` is given, or
|
||||
`DEVX_PYPI_REGISTRY_URL` env var is set, or derived from `GITEA_API_URL`
|
||||
2. **Standard PyPI** — if `PYPI_TOKEN` is set (takes precedence over Gitea
|
||||
registry)
|
||||
3. **Skip** — if neither is configured, only the Gitea release is created
|
||||
|
||||
On failure, the `notify_failure` step creates a Gitea issue.
|
||||
|
||||
## CI scripts
|
||||
|
||||
### `auto_merge.py`
|
||||
|
||||
Auto-merge PR when all CI checks pass. Reads task ID from the branch name
|
||||
(e.g., `DEVX-12-fix-foo` → `DEVX-12`), falling back to `.taskid` file for
|
||||
branches without a task ID prefix. Validates PR title format, checks the
|
||||
Vikunja task exists and the title matches, extracts the conventional commit
|
||||
message from PR commits, and squash-merges with
|
||||
`{PREFIX}-N <conventional commit>` title.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.auto_merge <branch> <pr_title> <owner/repo> <pr_number>
|
||||
```
|
||||
|
||||
### `release.py`
|
||||
|
||||
Automated release using git-cliff. Calculates next semver version from
|
||||
conventional commits, updates `__version__` and `CHANGELOG.md`, runs lint and
|
||||
tests, commits with `release: vX.Y.Z [skip ci]`, creates annotated tag, and
|
||||
pushes. Idempotent — exits if no unreleased changes.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.release [--dry-run] [--skip-tests] [--verify]
|
||||
```
|
||||
|
||||
- `--dry-run` — preview without making changes
|
||||
- `--skip-tests` — skip lint and test verification (emergency only)
|
||||
- `--verify` — check tag/version/changelog alignment and exit
|
||||
|
||||
### `publish.py`
|
||||
|
||||
Builds package, publishes to Gitea PyPI registry or standard PyPI, and
|
||||
creates a Gitea release with git-cliff-generated notes.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.publish <tag> <owner/repo> [--registry-url <url>] [--skip-build]
|
||||
```
|
||||
|
||||
### `pr_review.py`
|
||||
|
||||
Automated PR review. Fetches the PR diff via the Gitea API, runs automated
|
||||
checks (architecture, best practices, security, i18n, resource management,
|
||||
documentation, test coverage, commit conventions), and posts a structured
|
||||
review with inline comments.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.pr_review <pr_number> <owner/repo>
|
||||
```
|
||||
|
||||
### `notify_failure.py`
|
||||
|
||||
Creates a Gitea issue when a CI workflow fails. Uses the tea CLI for issue
|
||||
creation with failure labels. Supports `--auto-login` to configure the tea
|
||||
CLI login profile from `REPO_TOKEN`.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.notify_failure --repo <owner/repo> --run-id <id> \
|
||||
--workflow <name> --commit <sha> [--auto-login]
|
||||
```
|
||||
|
||||
### `post_merge.py`
|
||||
|
||||
Updates Vikunja task after a merge to master. Extracts task ID from the
|
||||
commit message, marks the task as done, and posts a comment with the merge SHA.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.post_merge <commit_msg> [--commit-sha <sha>] [--git-sha <sha>]
|
||||
```
|
||||
|
||||
### `classify_changes.py`
|
||||
|
||||
Classifies git changes as user-facing or workflow-only. Uses a layered rule
|
||||
system configured in `pyproject.toml`. Safe-by-default: any unknown file
|
||||
defaults to user-facing.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.classify_changes [--base <ref>] [--head <ref>] \
|
||||
[--quiet] [--check <category>] [--github-output]
|
||||
```
|
||||
|
||||
### `discover_runners.py`
|
||||
|
||||
Discovers available Gitea Actions runners at repository, organization, and
|
||||
instance levels. Falls back to `MOLECULE_RUNNERS` repo variable or
|
||||
`DEFAULT_MAX_RUNNERS` (3).
|
||||
|
||||
```bash
|
||||
python -m devx.ci.discover_runners --owner <owner> --repo <repo> [--count] [--indices]
|
||||
```
|
||||
|
||||
### `detect_release_commit.py`
|
||||
|
||||
Detects whether the latest git commit is a release commit. Writes
|
||||
`is-release=true|false` to `$GITHUB_OUTPUT`.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.detect_release_commit
|
||||
```
|
||||
|
||||
### `push_badges.py`
|
||||
|
||||
Generates SVG badge files, pushes them to the `badges` branch, and updates
|
||||
README.md and docs/index.md with cache-busting `raw/commit/<sha>/` URLs.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.push_badges [--output-dir <dir>] [--branch <branch>] \
|
||||
[--no-readme-update] [--retries <n>]
|
||||
```
|
||||
|
||||
### `distribute_molecule.py`
|
||||
|
||||
Distributes molecule (scenario, platform) pairs across N parallel runners.
|
||||
Discovers scenarios under `ansible/roles/*/molecule/`.
|
||||
|
||||
```bash
|
||||
python -m devx.molecule.distribute_molecule --runner-index <i> --max-runners <n>
|
||||
python -m devx.molecule.distribute_molecule --list
|
||||
python -m devx.molecule.distribute_molecule --list-platforms
|
||||
```
|
||||
|
||||
### `molecule_ci_guard.py`
|
||||
|
||||
Runs molecule tests sequentially while polling the Gitea API for other runner
|
||||
failures. Aborts early if another runner fails the same job. Generates JUnit
|
||||
XML when `--junit-output` is provided.
|
||||
|
||||
```bash
|
||||
python -m devx.molecule.molecule_ci_guard [--roles-root <dir>] \
|
||||
[--junit-output <file>] pair1 pair2 ...
|
||||
```
|
||||
|
||||
### `validate_commit_msg.py`
|
||||
|
||||
Validates commit messages. On feature branches: conventional commits only
|
||||
(no `{PREFIX}-N` prefix). On master: must have `{PREFIX}-N` prefix from
|
||||
auto-merge, followed by a conventional commit message.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.validate_commit_msg <commit_msg_file> [--branch <branch>]
|
||||
```
|
||||
|
||||
### `sync_wiki.py`
|
||||
|
||||
Syncs documentation from `docs/` to the Gitea wiki via the API. Reads
|
||||
`docs/mapping.json` for file-to-page mapping. Supports `--dry-run`,
|
||||
`--verify`, and `--strict` (full integrity check).
|
||||
|
||||
```bash
|
||||
python -m devx.ci.sync_wiki [--dry-run] [--repo <owner/repo>] [--verify] [--strict]
|
||||
```
|
||||
|
||||
### `check_translations.py`
|
||||
|
||||
Validates translation files against the Python source code. Checks for
|
||||
missing keys, dead keys, and missing languages.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.check_translations [--translations <file>]...
|
||||
```
|
||||
|
||||
### `doc_coverage.py`
|
||||
|
||||
Checks documentation coverage for CLI commands and major modules. Parses
|
||||
Click commands from `cli.py` and verifies documentation exists.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.doc_coverage [--docs-dir <dir>] [--fail-on-missing]
|
||||
```
|
||||
|
||||
### `distribute_files.py`
|
||||
|
||||
Distributes files matching a glob pattern across N parallel runners
|
||||
(round-robin). Writes the assigned file list to `$GITHUB_ENV`.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.distribute_files --pattern <glob> --runner-index <i> \
|
||||
--max-runners <n> [--github-env] [--skip-if-excess]
|
||||
```
|
||||
|
||||
### `merge_junit.py`
|
||||
|
||||
Merges JUnit XML reports from parallel matrix runners into a single
|
||||
consolidated report. Exit code is non-zero if any merged suite reports
|
||||
failures.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.merge_junit --pattern <glob> --output <file>
|
||||
```
|
||||
|
||||
### `integration_guard.py`
|
||||
|
||||
Runs pytest with cross-runner failure detection. If any other
|
||||
integration-tests matrix runner reports failure, the current pytest
|
||||
subprocess is killed and this runner exits early.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.integration_guard --junit-output <file> -- <pytest args>
|
||||
```
|
||||
|
||||
## Release process summary
|
||||
|
||||
The complete release process from PR to published package:
|
||||
|
||||
1. **PR merged** — `auto-merge` squash-merges the PR to master with
|
||||
`{PREFIX}-N <conventional commit>` title
|
||||
2. **Post-merge triggers** — the merge push triggers `post-merge.yml`
|
||||
3. **detect-type** — confirms the commit is not a release commit
|
||||
4. **release** — `release.py` calculates the next version, updates files,
|
||||
runs tests, commits `release: vX.Y.Z [skip ci]`, creates tag `vX.Y.Z`,
|
||||
and pushes to master
|
||||
5. **Tag push triggers publish** — the tag push triggers `publish.yml`
|
||||
6. **publish** — `publish.py` builds the package, publishes to the Gitea PyPI
|
||||
registry, and creates a Gitea release with git-cliff notes
|
||||
7. **sync-wiki** — documentation is synced to the Gitea wiki
|
||||
8. **badges** — quality badges are regenerated and pushed to the `badges`
|
||||
branch; README and docs/index.md are updated with cache-busting URLs
|
||||
9. **vikunja** — the corresponding Vikunja task is marked as done
|
||||
10. **configure-repo** — branch protection and labels are ensured
|
||||
|
||||
The release commit's post-merge run skips all jobs except `badges` (which
|
||||
picks up the new version number). This prevents infinite loops.
|
||||
|
||||
## Failure handling
|
||||
|
||||
Every job in the post-merge and publish workflows has a `notify_failure` step
|
||||
that runs `if: failure()`. This creates a Gitea issue with the workflow name,
|
||||
run ID, and commit SHA, ensuring failures that would otherwise go unnoticed
|
||||
in the Actions tab are surfaced as issues. The issue is created via the tea
|
||||
CLI with a `bug` label if available.
|
||||
|
||||
+389
-42
@@ -1,134 +1,481 @@
|
||||
# CLI Commands
|
||||
|
||||
devx provides a CLI with three command groups: `ci`, `tools`, and `molecule`.
|
||||
Each subcommand delegates to the corresponding Python module via
|
||||
`python -m devx.*`, so `devx ci release` is equivalent to
|
||||
`python -m devx.ci.release`.
|
||||
|
||||
```bash
|
||||
devx --help # show all command groups
|
||||
devx --version # show package version
|
||||
devx ci --help # show CI commands
|
||||
devx tools --help # show tools commands
|
||||
devx molecule --help # show molecule commands
|
||||
```
|
||||
|
||||
## CI Commands
|
||||
|
||||
### `devx ci auto-merge`
|
||||
|
||||
Auto-merge a PR when all CI checks pass. Validates PR title, checks Vikunja task, squash-merges.
|
||||
Auto-merge a PR when all CI checks pass. Reads the task ID from the branch
|
||||
name (falling back to `.taskid` file), validates the PR title format against
|
||||
the Vikunja task title, extracts the conventional commit message from PR
|
||||
commits, and squash-merges with `{PREFIX}-N <conventional commit>` title.
|
||||
|
||||
If the head branch is behind master (HTTP 405), automatically pulls master,
|
||||
rebases, force-pushes, and retries the merge.
|
||||
|
||||
```bash
|
||||
devx ci auto-merge <branch> <pr_title> <owner/repo> <pr_number>
|
||||
# Example:
|
||||
devx ci auto-merge DEVX-12-add-feature "DEVX-12: Add feature" oblachno-oss/devx 42
|
||||
```
|
||||
|
||||
### `devx ci check-translations`
|
||||
|
||||
Check translation files for gaps, dead keys, and missing languages.
|
||||
Check translation files for gaps, dead keys, and missing languages. Validates
|
||||
translation files against the Python source code that uses them. By default,
|
||||
checks `src/devx/translations.json` against `src/devx/**/*.py`.
|
||||
|
||||
Checks performed:
|
||||
- **Missing keys** — a `_()` call in code has no entry in the translations file
|
||||
- **Dead keys** — a key in the translations file is not used in any code
|
||||
- **Missing languages** — a key exists but is missing one of the five
|
||||
supported languages (en, bg, de, ru, zh)
|
||||
|
||||
```bash
|
||||
devx ci check-translations
|
||||
devx ci check-translations --translations path/to/translations.json
|
||||
```
|
||||
|
||||
### `devx ci classify-changes`
|
||||
|
||||
Classify git changes as user-facing or workflow-only. Used to skip releases for infrastructure-only changes.
|
||||
Classify git changes as user-facing or workflow-only. Used to skip releases
|
||||
for infrastructure-only changes. Classification rules are configured in
|
||||
`pyproject.toml` under `[tool.devx.classify]`.
|
||||
|
||||
```bash
|
||||
devx ci classify-changes --base origin/master --head HEAD
|
||||
devx ci classify-changes --base origin/master --head HEAD --github-output
|
||||
devx ci classify-changes --quiet --check user-facing
|
||||
devx ci classify-changes --check ansible # custom tag from pyproject.toml
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--base <ref>` — base ref (default: latest tag)
|
||||
- `--head <ref>` — head ref (default: HEAD)
|
||||
- `--quiet` — only output true/false
|
||||
- `--check <category>` — check specific category: `all` (default),
|
||||
`user-facing`, or any tag name defined in `[tool.devx.classify.tags]`
|
||||
- `--github-output` — write results to `$GITHUB_OUTPUT` for CI workflow steps
|
||||
|
||||
Exit code 2 indicates workflow-only changes (no release needed).
|
||||
|
||||
### `devx ci detect-release-commit`
|
||||
|
||||
Detect whether the latest git commit is a release commit (`release: vX.Y.Z [skip ci]`).
|
||||
Detect whether the latest git commit is a release commit
|
||||
(`release: vX.Y.Z [skip ci]`). Writes `is-release=true` or `is-release=false`
|
||||
to `$GITHUB_OUTPUT` for use in CI workflow conditionals.
|
||||
|
||||
```bash
|
||||
devx ci detect-release-commit
|
||||
```
|
||||
|
||||
### `devx ci discover-runners`
|
||||
|
||||
Discover available Gitea Actions runners for dynamic job distribution.
|
||||
Queries the Gitea API for registered runners at repository, organization, and
|
||||
instance (admin) levels. Falls back to `MOLECULE_RUNNERS` repo variable or
|
||||
`DEFAULT_MAX_RUNNERS` (3).
|
||||
|
||||
```bash
|
||||
devx ci discover-runners --owner oblachno-oss --repo devx
|
||||
devx ci discover-runners --owner oblachno-oss --repo devx --count
|
||||
devx ci discover-runners --owner oblachno-oss --repo devx --indices
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--count` — print the number of available runners
|
||||
- `--indices` — print a JSON array `[0, 1, ..., N-1]` for use as a dynamic
|
||||
matrix in Gitea Actions
|
||||
|
||||
### `devx ci distribute-files`
|
||||
|
||||
Distribute files across parallel runners (round-robin). Used for splitting test suites or workloads across CI runners.
|
||||
Distribute files across parallel runners (round-robin). Discovers files
|
||||
matching a glob pattern, sorts them for deterministic ordering, then assigns
|
||||
them round-robin to `max_runners` groups. The assigned group for
|
||||
`runner_index` is written to `$GITHUB_ENV`.
|
||||
|
||||
```bash
|
||||
devx ci distribute-files --pattern "tests/integration/test_*.py" \
|
||||
--runner-index 1 --max-runners 3 --github-env
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--pattern <glob>` — glob pattern for files to distribute
|
||||
- `--runner-index <i>` — current runner index (0-based)
|
||||
- `--max-runners <n>` — total number of runners (default: 3)
|
||||
- `--github-env` — write file list to `$GITHUB_ENV`
|
||||
- `--skip-if-excess` — skip if fewer files than runners
|
||||
|
||||
### `devx ci doc-coverage`
|
||||
|
||||
Check documentation coverage for CLI commands and major modules.
|
||||
Check documentation coverage for CLI commands and major modules. Parses
|
||||
Click commands from `cli.py` and checks if each has documentation in
|
||||
`docs/user/cli-commands.md`. Verifies core modules are documented in
|
||||
`architecture.md` and CI scripts in `ci-cd-workflow.md`.
|
||||
|
||||
```bash
|
||||
devx ci doc-coverage
|
||||
devx ci doc-coverage --docs-dir docs/ --fail-on-missing
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--docs-dir <dir>` — path to the docs directory (default: `docs/`)
|
||||
- `--fail-on-missing` — exit with non-zero status if any documentation is
|
||||
missing
|
||||
|
||||
### `devx ci integration-guard`
|
||||
|
||||
Run pytest with cross-runner failure detection and JUnit XML output. Monitors other runners for failures and aborts early if a critical failure is detected.
|
||||
Run pytest with cross-runner failure detection and JUnit XML output. If any
|
||||
other integration-tests matrix runner reports failure, the current pytest
|
||||
subprocess is killed and this runner exits early with code 1.
|
||||
|
||||
```bash
|
||||
devx ci integration-guard --junit-output junit-results/runner-1.xml -- test_a.py test_b.py
|
||||
devx ci integration-guard --junit-output junit-results/runner-1.xml -- -x -v --tb=short test_a.py
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
- `GITEA_URL` — base URL of the Gitea instance
|
||||
- `REPO_TOKEN` — API token with repo access
|
||||
- `RUN_ID` — workflow run ID (`GITHUB_RUN_ID`)
|
||||
- `JOB_NAME` — base job name (`GITHUB_JOB`)
|
||||
- `MATRIX_INDEX` — current matrix index (runner-index)
|
||||
- `GITEA_REPOSITORY` — repository in `owner/repo` format
|
||||
|
||||
### `devx ci merge-junit`
|
||||
|
||||
Merge multiple JUnit XML reports from parallel runners into a single consolidated report.
|
||||
Merge multiple JUnit XML reports from parallel runners into a single
|
||||
consolidated report. Exit code is non-zero if any merged test suite reports
|
||||
failures, making it suitable as a CI gating step after matrix jobs.
|
||||
|
||||
```bash
|
||||
devx ci merge-junit --pattern "junit-results/runner-*.xml" --output junit-merged.xml
|
||||
```
|
||||
|
||||
### `devx ci notify-failure`
|
||||
|
||||
Create a Gitea issue when a CI workflow fails.
|
||||
Create a Gitea issue when a CI workflow fails. Uses the tea CLI for issue
|
||||
creation with a `bug` label if available.
|
||||
|
||||
```bash
|
||||
devx ci notify-failure --repo oblachno-oss/devx --run-id 123 \
|
||||
--workflow ci --commit abc123def456
|
||||
devx ci notify-failure --repo oblachno-oss/devx --run-id 123 \
|
||||
--workflow post-merge/release --commit abc123def456 --auto-login
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--repo <owner/repo>` — repository (required)
|
||||
- `--run-id <id>` — CI run ID (required)
|
||||
- `--workflow <name>` — workflow name (required)
|
||||
- `--commit <sha>` — commit SHA (required)
|
||||
- `--auto-login` — configure tea CLI login from `REPO_TOKEN` before creating
|
||||
the issue
|
||||
|
||||
### `devx ci post-merge`
|
||||
|
||||
Update Vikunja task after a merge to master.
|
||||
Update Vikunja task after a merge to master. Extracts the task ID from the
|
||||
commit message, marks the task as done, and posts a comment with the merge SHA.
|
||||
|
||||
```bash
|
||||
devx ci post-merge "DEVX-12 feat: add feature" --git-sha abc123def456
|
||||
```
|
||||
|
||||
### `devx ci pr-review`
|
||||
|
||||
Run automated PR review: check architecture compliance, best practices, and quality.
|
||||
Run automated PR review. Fetches the PR diff via the Gitea API and runs a
|
||||
series of checks, posting a structured review (`COMMENT` or
|
||||
`REQUEST_CHANGES`).
|
||||
|
||||
Checks: architecture compliance, best practices, security, i18n, resource
|
||||
management, documentation, test coverage, and commit conventions.
|
||||
|
||||
```bash
|
||||
devx ci pr-review 42 oblachno-oss/devx
|
||||
```
|
||||
|
||||
### `devx ci publish`
|
||||
|
||||
Build package, publish to Gitea PyPI registry, and create Gitea release.
|
||||
Build package, publish to Gitea PyPI registry (or standard PyPI), and create
|
||||
a Gitea release with git-cliff-generated notes.
|
||||
|
||||
```bash
|
||||
devx ci publish v1.0.0 oblachno-oss/devx
|
||||
devx ci publish v1.0.0 oblachno-oss/devx --registry-url https://git.example.com/api/packages/owner/pypi
|
||||
devx ci publish v1.0.0 oblachno-oss/devx --skip-build # Gitea release only
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--registry-url <url>` — Gitea PyPI registry URL. Defaults to
|
||||
`DEVX_PYPI_REGISTRY_URL` env var or a URL derived from `GITEA_API_URL`.
|
||||
When set, publishes to Gitea PyPI instead of standard PyPI (unless
|
||||
`PYPI_TOKEN` is also set).
|
||||
- `--skip-build` — skip package build and PyPI publish (for non-Python repos
|
||||
that only need a Gitea release)
|
||||
|
||||
### `devx ci push-badges`
|
||||
|
||||
Generate badge SVG files and push them to the `badges` branch.
|
||||
Generate badge SVG files and push them to the `badges` branch. Also updates
|
||||
`README.md` and `docs/index.md` on master with cache-busting
|
||||
`raw/commit/<sha>/` URLs.
|
||||
|
||||
```bash
|
||||
devx ci push-badges
|
||||
devx ci push-badges --output-dir .badges/ --branch master
|
||||
devx ci push-badges --no-readme-update # skip README update (local testing)
|
||||
devx ci push-badges --retries 3 # retry on git push failures
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--output-dir <dir>` — temporary directory for badge files (default:
|
||||
`.badges/`)
|
||||
- `--branch <branch>` — branch to sync before generating badges (default:
|
||||
`master`)
|
||||
- `--no-readme-update` — skip updating README with cache-busting URLs
|
||||
- `--retries <n>` — number of attempts on git push failures (default: 1).
|
||||
Between attempts, fetches latest master and waits 10s.
|
||||
|
||||
### `devx ci release`
|
||||
|
||||
Automated release: calculate next version, update files, tag, and push.
|
||||
Automated release: calculate next version, update files, tag, and push. Uses
|
||||
git-cliff to determine the next semver version from conventional commits.
|
||||
|
||||
```bash
|
||||
devx ci release
|
||||
devx ci release --dry-run # preview without making changes
|
||||
devx ci release --skip-tests # skip lint and tests (emergency only)
|
||||
devx ci release --verify # check tag/version/changelog alignment
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--dry-run` — show what would happen without making changes
|
||||
- `--skip-tests` — skip lint and test verification (NOT recommended — only
|
||||
for emergency releases)
|
||||
- `--verify` — verify tag/version/changelog alignment and exit (no changes
|
||||
made)
|
||||
|
||||
### `devx ci sync-wiki`
|
||||
|
||||
Sync documentation from `docs/` to the Gitea wiki.
|
||||
Sync documentation from `docs/` to the Gitea wiki. Reads `docs/mapping.json`
|
||||
for file-to-page mapping. Pages that exist in the wiki but not in the mapping
|
||||
are left untouched.
|
||||
|
||||
```bash
|
||||
devx ci sync-wiki --repo oblachno-oss/devx
|
||||
devx ci sync-wiki --repo oblachno-oss/devx --dry-run
|
||||
devx ci sync-wiki --repo oblachno-oss/devx --verify
|
||||
devx ci sync-wiki --repo oblachno-oss/devx --strict
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--dry-run` — show what would happen without making changes
|
||||
- `--repo <owner/repo>` — repository (auto-detected if omitted)
|
||||
- `--verify` — after syncing, verify each page has non-empty content. Exit 1
|
||||
if any page is empty or mismatched.
|
||||
- `--strict` — full integrity check: verify page count, missing pages, stale
|
||||
pages, and content. Implies `--verify`.
|
||||
|
||||
### `devx ci validate-commit-msg`
|
||||
|
||||
Validate commit messages for conventional commit format.
|
||||
Validate commit messages for conventional commit format. On feature branches:
|
||||
conventional commits only (no `{PREFIX}-N` prefix). On master: must have
|
||||
`{PREFIX}-N` prefix from auto-merge, followed by a conventional commit
|
||||
message.
|
||||
|
||||
```bash
|
||||
devx ci validate-commit-msg commit-msg.txt
|
||||
devx ci validate-commit-msg commit-msg.txt --branch master
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--branch <branch>` — override branch detection (for CI use)
|
||||
|
||||
## Tools Commands
|
||||
|
||||
### `devx tools check-test-speed`
|
||||
|
||||
Run unit tests and enforce execution-time budgets:
|
||||
- **Total suite time** must not exceed `--max-seconds` (default: 10s).
|
||||
- **Per-test time** — no individual test may exceed `--max-single-seconds` (default: 0.5s, 0 to disable).
|
||||
Run unit tests and enforce execution-time budgets. Two quality gates:
|
||||
|
||||
- **Total suite time** must not exceed `--max-seconds` (default: 10s)
|
||||
- **Per-test time** — no individual test may exceed `--max-single-seconds`
|
||||
(default: 0.5s, 0 to disable)
|
||||
|
||||
Runs `make test-unit` with `PYTEST_ADDOPTS=--durations=0` so pytest emits
|
||||
per-test timing lines.
|
||||
|
||||
```bash
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 10 --max-single-seconds 0.5
|
||||
devx tools check-test-speed
|
||||
devx tools check-test-speed --max-seconds 10
|
||||
devx tools check-test-speed --max-seconds 4 --max-single-seconds 0.5
|
||||
```
|
||||
|
||||
### `devx tools configure-repo`
|
||||
|
||||
Configure repository: branch protection + labels via Gitea API.
|
||||
Configure repository: branch protection and labels via the Gitea REST API.
|
||||
Sets up master branch protection (required status checks, block on rejected
|
||||
reviews, block on outdated branch) and creates standard labels.
|
||||
|
||||
```bash
|
||||
devx tools configure-repo --repo devx --owner oblachno-oss
|
||||
```
|
||||
|
||||
Status check contexts are read from `DEVX_STATUS_CHECKS` (comma-separated) or
|
||||
default to `CI / quality (pull_request)`.
|
||||
|
||||
### `devx tools generate-badges`
|
||||
|
||||
Generate self-contained SVG badge files from project metrics.
|
||||
Generate self-contained SVG badge files from project metrics. Runs
|
||||
pytest-cov, doc-coverage, lint checks, and version extraction, then writes
|
||||
SVG files that can be served as static files from the Gitea raw file API.
|
||||
|
||||
Badges generated: `coverage.svg`, `tests.svg`, `docs.svg`, `quality.svg`,
|
||||
`version.svg`, `python.svg`.
|
||||
|
||||
```bash
|
||||
devx tools generate-badges
|
||||
devx tools generate-badges --output-dir .badges/
|
||||
```
|
||||
|
||||
### `devx tools generate-cliff-config`
|
||||
|
||||
Generate a `cliff.toml` configuration file with the correct task ID prefix.
|
||||
Eliminates the need to manually duplicate and maintain cliff.toml across
|
||||
repos that use devx.
|
||||
Generate a `cliff.toml` configuration file with the correct task ID prefix
|
||||
preprocessor. Eliminates the need to manually duplicate and maintain
|
||||
`cliff.toml` across repos that use devx.
|
||||
|
||||
```bash
|
||||
python -m devx.tools.generate_cliff_config --prefix GRM
|
||||
python -m devx.tools.generate_cliff_config --prefix GRM --force # overwrite existing
|
||||
devx tools generate-cliff-config --prefix GRM
|
||||
devx tools generate-cliff-config --prefix GRM --output cliff.toml
|
||||
devx tools generate-cliff-config --prefix GRM --force # overwrite existing
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--prefix <prefix>` — task ID prefix (default: `DEVX_TASK_PREFIX` env var
|
||||
or `DEVX`)
|
||||
- `--output <file>` — output file path (default: `cliff.toml`)
|
||||
- `--force` — overwrite existing file
|
||||
|
||||
### `devx tools install-checkmake`
|
||||
|
||||
Install checkmake (Makefile linter) if not already present.
|
||||
Install checkmake (Makefile linter) if not already present. Tries
|
||||
`go install` first if Go is available, otherwise downloads the latest
|
||||
pre-built Linux binary from the official GitHub releases.
|
||||
|
||||
```bash
|
||||
devx tools install-checkmake
|
||||
```
|
||||
|
||||
### `devx tools install-tools`
|
||||
|
||||
Install CI/CD development tools: actionlint, git-cliff, act_runner, tea.
|
||||
Install CI/CD development tools that are not Python packages: actionlint,
|
||||
git-cliff, act_runner, and tea. Each tool is installed to `~/.local/bin` if
|
||||
not already on PATH. Idempotent: skips tools that are already available.
|
||||
|
||||
```bash
|
||||
devx tools install-tools # install all
|
||||
devx tools install-tools --tool actionlint # install one
|
||||
devx tools install-tools --tool git-cliff --tool tea # install specific
|
||||
devx tools install-tools --list # list status
|
||||
```
|
||||
|
||||
### `devx tools setup`
|
||||
|
||||
Project setup: install Python deps and pre-commit hooks.
|
||||
Project setup: install Python dependencies (editable mode with extras),
|
||||
Ansible Galaxy collections (if `ansible/requirements.yml` exists), pre-commit
|
||||
hooks (pre-commit, commit-msg, pre-push), and configure the tea CLI login
|
||||
profile from `.env`.
|
||||
|
||||
```bash
|
||||
devx tools setup --bin .venv/bin
|
||||
devx tools setup --bin .venv/bin --extras "ci,lint"
|
||||
devx tools setup --bin .venv/bin --no-pre-commit --no-tea-login
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--bin <dir>` — virtualenv bin directory (required)
|
||||
- `--extras <groups>` — pip extras to install (default: `dev`)
|
||||
- `--no-pre-commit` — skip pre-commit hook installation
|
||||
- `--no-tea-login` — skip tea CLI login configuration
|
||||
|
||||
## Molecule Commands
|
||||
|
||||
### `devx molecule distribute`
|
||||
|
||||
Distribute molecule test pairs across parallel runners.
|
||||
|
||||
### `devx molecule discover-runners`
|
||||
|
||||
Discover available Gitea Actions runners for molecule tests.
|
||||
|
||||
### `devx molecule guard`
|
||||
|
||||
Run molecule tests sequentially with CI failure polling.
|
||||
Molecule commands require the `molecule` extra (`pip install devx[molecule]`).
|
||||
|
||||
### `devx molecule all`
|
||||
|
||||
Run all molecule scenarios on all supported OS platforms.
|
||||
Run all molecule scenarios on all supported OS platforms. Sequential
|
||||
execution — CI uses the parallel matrix instead.
|
||||
|
||||
```bash
|
||||
devx molecule all
|
||||
devx molecule all --bin .venv/bin
|
||||
```
|
||||
|
||||
### `devx molecule discover-runners`
|
||||
|
||||
Discover available Gitea Actions runners for molecule tests. Same logic as
|
||||
`devx ci discover-runners` but intended for molecule-specific workflows.
|
||||
|
||||
```bash
|
||||
devx molecule discover-runners --owner oblachno-oss --repo devx --indices
|
||||
```
|
||||
|
||||
### `devx molecule distribute`
|
||||
|
||||
Distribute molecule (scenario, platform) pairs across N parallel runners.
|
||||
Discovers scenarios under `ansible/roles/*/molecule/` and crosses them with
|
||||
the supported OS platform matrix.
|
||||
|
||||
```bash
|
||||
devx molecule distribute --runner-index 1 --max-runners 3
|
||||
devx molecule distribute --list # list all scenarios
|
||||
devx molecule distribute --list-platforms # list platforms
|
||||
devx molecule distribute --roles-root ansible/roles # multi-role repos
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--runner-index <i>` — current runner index (0-based)
|
||||
- `--max-runners <n>` — total number of runners (default: 3)
|
||||
- `--list` — list all scenarios, one per line
|
||||
- `--list-platforms` — list all platforms, one per line
|
||||
- `--roles-root <dir>` — roles root directory for multi-role repos (default:
|
||||
`ansible/roles`)
|
||||
|
||||
### `devx molecule guard`
|
||||
|
||||
Run molecule tests sequentially with CI failure polling. A background thread
|
||||
polls the Gitea API. If any other molecule matrix runner reports failure, the
|
||||
current molecule subprocess is killed and this runner exits early with code 1.
|
||||
|
||||
```bash
|
||||
devx molecule guard pair1 pair2 pair3
|
||||
devx molecule guard --roles-root ansible/roles pair1 pair2
|
||||
devx molecule guard --junit-output junit-results/runner-1.xml pair1 pair2
|
||||
```
|
||||
|
||||
Each pair is encoded as:
|
||||
- **Single-role (4-part):** `scenario|platform_name|platform_image|platform_command`
|
||||
- **Multi-role (5-part):** `role|scenario|platform_name|platform_image|platform_command`
|
||||
|
||||
Options:
|
||||
- `--roles-root <dir>` — roles root directory for multi-role repos
|
||||
- `--junit-output <file>` — generate JUnit XML report
|
||||
|
||||
Environment variables:
|
||||
- `GITEA_URL` — base URL of the Gitea instance
|
||||
- `REPO_TOKEN` — API token with repo access
|
||||
- `RUN_ID` — workflow run ID (`GITHUB_RUN_ID`)
|
||||
- `JOB_NAME` — base job name (`GITHUB_JOB`)
|
||||
- `MATRIX_INDEX` — current matrix index (runner-index)
|
||||
- `GITEA_REPOSITORY` — repository in `owner/repo` format
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.9.6"
|
||||
__version__ = "0.10.2"
|
||||
|
||||
+31
-18
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Auto-merge PR when all CI checks pass.
|
||||
|
||||
Runs as the final job in ci.yml. Reads the task ID from ``.taskid`` file
|
||||
(falling back to branch name extraction for backwards compatibility),
|
||||
validates the PR title, and squash-merges with a conventional commit
|
||||
message prefixed by the task ID.
|
||||
Runs as the final job in ci.yml. Reads the task ID from the branch name
|
||||
(e.g., ``DEVX-31-fix-foo`` → ``DEVX-31``), validates the PR title against
|
||||
the Vikunja task, and squash-merges with a conventional commit message
|
||||
prefixed by the task ID.
|
||||
|
||||
PR title format: ``{PREFIX}-N: <vikunja task title>``
|
||||
Merge commit format: ``{PREFIX}-N <conventional commit message>``
|
||||
@@ -42,7 +42,7 @@ from devx.config import (
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
TASKID_FILE = ".taskid"
|
||||
TASKID_FILE = ".taskid" # Deprecated, kept for backward-compat warnings
|
||||
PR_TITLE_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+")
|
||||
|
||||
load_dotenv()
|
||||
@@ -63,20 +63,32 @@ def run_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess[
|
||||
|
||||
|
||||
def read_taskid(branch: str) -> str:
|
||||
"""Read task ID from .taskid file, falling back to branch name extraction.
|
||||
"""Read task ID from branch name.
|
||||
|
||||
The .taskid file is a simple text file containing just the task ID
|
||||
(e.g., ``DEVX-60``). If the file doesn't exist, extract from the
|
||||
branch name as a backwards-compatibility fallback.
|
||||
The branch name is the sole source of truth for the task ID
|
||||
(e.g., ``DEVX-31-fix-foo`` → ``DEVX-31``). Branches must include
|
||||
the task ID prefix — there is no ``.taskid`` file fallback.
|
||||
|
||||
If a stale ``.taskid`` file exists and disagrees with the branch
|
||||
name, a deprecation warning is printed advising its removal.
|
||||
"""
|
||||
path = Path(TASKID_FILE)
|
||||
if path.exists():
|
||||
task_id = path.read_text(encoding="utf-8").strip()
|
||||
if task_id:
|
||||
return task_id
|
||||
# Fallback: extract from branch name
|
||||
match = TASK_ID_RE.search(branch)
|
||||
return match.group(0) if match else ""
|
||||
branch_task_id = extract_task_id(branch)
|
||||
if branch_task_id:
|
||||
# Warn about stale .taskid file if it exists and disagrees
|
||||
path = Path(TASKID_FILE)
|
||||
if path.exists():
|
||||
file_task_id = path.read_text(encoding="utf-8").strip()
|
||||
if file_task_id and file_task_id != branch_task_id:
|
||||
click.echo(
|
||||
_(
|
||||
"WARNING: .taskid file ({file_id}) is deprecated and disagrees with branch name ({branch_id}). "
|
||||
"Delete .taskid from the repo — branch name is the sole source of truth.",
|
||||
file_id=file_task_id,
|
||||
branch_id=branch_task_id,
|
||||
)
|
||||
)
|
||||
return branch_task_id
|
||||
return ""
|
||||
|
||||
|
||||
def extract_task_id(branch: str) -> str:
|
||||
@@ -204,7 +216,8 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
||||
if not task_id:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! No task ID found in .taskid file or branch name '{branch}'.",
|
||||
"Oops! No task ID found in branch name '{branch}'. "
|
||||
"Branch names must include the task ID prefix (e.g., DEVX-31-fix-bug).",
|
||||
branch=branch,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -298,8 +298,6 @@ DEFAULT_INFRASTRUCTURE: list[str] = [
|
||||
"activate.sh",
|
||||
"activate.fish",
|
||||
"activate.zsh",
|
||||
# CI task tracking file (written by CI, not by developers)
|
||||
".taskid",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import os
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
@@ -60,6 +61,12 @@ def generate_release_notes(tag: str) -> str:
|
||||
|
||||
def build_package() -> None:
|
||||
"""Build the Python package using python -m build."""
|
||||
# Clean dist/ to avoid uploading stale packages from previous builds
|
||||
# (Gitea PyPI returns 409 Conflict for already-published versions).
|
||||
dist_dir = Path("dist")
|
||||
if dist_dir.exists():
|
||||
shutil.rmtree(dist_dir)
|
||||
|
||||
result = subprocess.run( # nosec B603
|
||||
[sys.executable, "-m", "build"],
|
||||
capture_output=True,
|
||||
|
||||
@@ -18,6 +18,7 @@ Usage::
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
@@ -27,7 +28,16 @@ from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
|
||||
def _repo_root() -> Path:
|
||||
"""Resolve repo root from GITHUB_WORKSPACE or cwd."""
|
||||
workspace = os.environ.get("GITHUB_WORKSPACE")
|
||||
if workspace:
|
||||
path = Path(workspace)
|
||||
if path.is_dir():
|
||||
return path
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
# Badge filenames that get pushed to the badges branch
|
||||
BADGE_FILES = ["coverage.svg", "tests.svg", "docs.svg", "quality.svg", "version.svg", "python.svg"]
|
||||
@@ -116,7 +126,7 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
|
||||
Switches back to master, replaces ``raw/branch/badges/`` URLs with
|
||||
``raw/commit/<sha>/`` URLs, commits and pushes.
|
||||
"""
|
||||
root = repo_root or REPO_ROOT
|
||||
root = repo_root or _repo_root()
|
||||
|
||||
# Switch back to master
|
||||
_run(["git", "checkout", "master"]) # nosec B607
|
||||
|
||||
+13
-6
@@ -136,10 +136,14 @@ def verify_tag_consistency() -> list[str]:
|
||||
"""
|
||||
errors: list[str] = []
|
||||
tags = get_all_tags()
|
||||
# Sort oldest first to identify the first tag
|
||||
sorted_tags = sorted(tags, key=lambda t: [int(x) for x in t.lstrip("v").split(".")])
|
||||
# Filter to version tags (vX.Y.Z) and sort oldest first
|
||||
version_tags = [t for t in tags if re.match(r"^v\d+\.\d+\.\d+$", t)]
|
||||
sorted_tags = sorted(version_tags, key=lambda t: [int(x) for x in t.lstrip("v").split(".")])
|
||||
first_tag = sorted_tags[0] if sorted_tags else None
|
||||
for tag in tags:
|
||||
# Skip non-version tags (e.g., branch names like "master")
|
||||
if not re.match(r"^v\d+\.\d+\.\d+$", tag):
|
||||
continue
|
||||
tag_version = tag.lstrip("v")
|
||||
commit_version = get_commit_version(tag)
|
||||
if commit_version is None:
|
||||
@@ -340,14 +344,14 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
||||
click.echo(_("Tag {tag} already exists and points to HEAD. Skipping creation.", tag=tag))
|
||||
if not dry_run:
|
||||
# Ensure the existing tag is pushed
|
||||
run_cmd(["git", "push", "origin", tag], check=False)
|
||||
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"], check=False)
|
||||
return False
|
||||
tag_msg = f"Release v{new_version}\n\n{changelog}"
|
||||
if dry_run:
|
||||
click.echo(_("[dry-run] Would create tag: {tag}", tag=tag))
|
||||
return True
|
||||
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
|
||||
run_cmd(["git", "push", "origin", tag])
|
||||
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"])
|
||||
return True
|
||||
|
||||
|
||||
@@ -478,7 +482,7 @@ def verify_alignment() -> int:
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
all_release_commits = result.stdout.strip().split("\n")
|
||||
all_tags_set = {t.lstrip("v") for t in get_all_tags()}
|
||||
all_tags_set = {t.lstrip("v") for t in get_all_tags() if re.match(r"^v\d+\.\d+\.\d+$", t)}
|
||||
truly_untagged: list[str] = []
|
||||
duplicates: list[str] = []
|
||||
for line in all_release_commits:
|
||||
@@ -547,6 +551,8 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
||||
|
||||
# Ensure we're on master (skip this check in dry-run mode for PR validation)
|
||||
branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
|
||||
# Some git versions return "heads/master" instead of "master"
|
||||
branch = branch.removeprefix("heads/")
|
||||
if branch != "master" and not dry_run:
|
||||
raise click.ClickException(_("Release must be run on master, currently on '{branch}'.", branch=branch))
|
||||
if branch != "master" and dry_run:
|
||||
@@ -694,7 +700,8 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
||||
# Pull --rebase before push to handle the case where master
|
||||
# advanced between checkout and commit (e.g., another merge).
|
||||
run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
|
||||
run_cmd(["git", "push", "origin", "master"])
|
||||
# Use refs/heads/master to avoid ambiguity with a 'master' tag
|
||||
run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"])
|
||||
click.echo(_("Pushed release commit to master."))
|
||||
else:
|
||||
click.echo(_("Skipping commit push — no staged changes."))
|
||||
|
||||
+3
-2
@@ -13,8 +13,9 @@ import re
|
||||
GITEA_API_URL = os.getenv("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
|
||||
VIKUNJA_API_URL = os.getenv("DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
|
||||
|
||||
# Organization defaults
|
||||
REPO_OWNER = os.getenv("DEVX_REPO_OWNER", "oblachno-oss")
|
||||
# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
|
||||
# No default: prevents silent 404s when the wrong owner is used.
|
||||
REPO_OWNER = os.getenv("DEVX_REPO_OWNER", "")
|
||||
|
||||
# Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
|
||||
TASK_PREFIX = os.getenv("DEVX_TASK_PREFIX", "DEVX")
|
||||
|
||||
@@ -219,7 +219,10 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
|
||||
if not all([gitea_url, token, run_id]):
|
||||
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
|
||||
|
||||
repo_root = Path(__file__).resolve().parent.parent.parent.parent
|
||||
# When devx is installed as a pip package, __file__ resolves to the
|
||||
# site-packages directory, not the repo root. Use GITHUB_WORKSPACE
|
||||
# (set by Gitea Actions) or cwd as the repo root.
|
||||
repo_root = Path(os.environ.get("GITHUB_WORKSPACE", os.getcwd())).resolve()
|
||||
|
||||
base_env = os.environ.copy()
|
||||
base_env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock")
|
||||
@@ -321,6 +324,17 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
|
||||
|
||||
click.echo(_("PASSED: {pair}", pair=pair))
|
||||
|
||||
# Prune Docker data between scenarios to prevent disk exhaustion
|
||||
# in Docker-in-Docker molecule containers (each scenario pulls
|
||||
# hundreds of MB of images that accumulate across pairs).
|
||||
with contextlib.suppress(subprocess.SubprocessError, OSError):
|
||||
subprocess.run( # nosec B603, B607
|
||||
["docker", "system", "prune", "-af", "--volumes"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
click.echo(_("All molecule tests passed."))
|
||||
if junit_output:
|
||||
write_junit_report(junit_output, testcases, current_index)
|
||||
|
||||
@@ -6,8 +6,9 @@ Docker socket mounted. This module verifies Docker is accessible and
|
||||
sets ``DOCKER_HOST`` explicitly so molecule's Python docker library
|
||||
connects to the same socket as the Docker CLI.
|
||||
|
||||
If the host socket is not available, it starts a local ``dockerd``
|
||||
with the vfs storage driver (requires privileged container).
|
||||
If the host socket is not available, it tries the rootless socket, then
|
||||
starts a local ``dockerd`` with the vfs storage driver (requires
|
||||
privileged container).
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -16,6 +17,7 @@ Usage::
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
@@ -28,15 +30,18 @@ from devx.i18n import _
|
||||
|
||||
DEFAULT_TIMEOUT = 30
|
||||
DOCKER_SOCK = "/var/run/docker.sock"
|
||||
# Rootless socket fallback (e.g. /run/user/994/docker.sock)
|
||||
ROOTLESS_SOCK = f"/run/user/{os.getuid()}/docker.sock"
|
||||
|
||||
|
||||
def is_docker_ready() -> bool:
|
||||
"""Check if Docker daemon is responding on the configured socket."""
|
||||
docker_host = os.environ.get("DOCKER_HOST", f"unix://{DOCKER_SOCK}")
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["docker", "info"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env={**os.environ, "DOCKER_HOST": f"unix://{DOCKER_SOCK}"},
|
||||
env={**os.environ, "DOCKER_HOST": docker_host},
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
@@ -93,8 +98,9 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
|
||||
"""Ensure Docker is ready for molecule tests.
|
||||
|
||||
First tries the host socket. If that works, sets ``DOCKER_HOST`` and
|
||||
returns immediately. If not, starts a local ``dockerd`` with vfs
|
||||
storage driver (requires privileged container).
|
||||
returns immediately. If not, tries the rootless socket. If neither
|
||||
works, starts a local ``dockerd`` with vfs storage driver (requires
|
||||
privileged container).
|
||||
|
||||
Returns ``True`` if Docker is ready, ``False`` if it failed to
|
||||
start within the timeout.
|
||||
@@ -112,8 +118,28 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
|
||||
click.echo(_("Docker daemon already running"))
|
||||
return True
|
||||
|
||||
# Try rootless socket (e.g. /run/user/994/docker.sock)
|
||||
click.echo(f"Trying rootless socket: {ROOTLESS_SOCK}")
|
||||
os.environ["DOCKER_HOST"] = f"unix://{ROOTLESS_SOCK}"
|
||||
if os.path.exists(ROOTLESS_SOCK) and is_docker_ready():
|
||||
click.echo(_("Docker daemon already running"))
|
||||
return True
|
||||
|
||||
# Scan for any rootless sockets at other UIDs
|
||||
for sock in sorted(glob.glob("/run/user/*/docker.sock")):
|
||||
if sock == ROOTLESS_SOCK:
|
||||
continue
|
||||
click.echo(f"Trying alternative rootless socket: {sock}")
|
||||
os.environ["DOCKER_HOST"] = f"unix://{sock}"
|
||||
if is_docker_ready():
|
||||
click.echo(_("Docker daemon already running"))
|
||||
return True
|
||||
|
||||
click.echo(_("Host Docker not available, starting local dockerd..."))
|
||||
|
||||
# Reset DOCKER_HOST to host socket for local dockerd
|
||||
os.environ["DOCKER_HOST"] = f"unix://{DOCKER_SOCK}"
|
||||
|
||||
# Start local dockerd (requires privileged container)
|
||||
log_file = tempfile.NamedTemporaryFile( # noqa: SIM115
|
||||
mode="w", suffix="dockerd.log", delete=False
|
||||
@@ -162,6 +188,12 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
|
||||
def main(timeout: int) -> None:
|
||||
"""Start Docker daemon for CI molecule tests."""
|
||||
if start_docker_daemon(timeout):
|
||||
# Export DOCKER_HOST to GITHUB_ENV for subsequent CI steps
|
||||
github_env = os.environ.get("GITHUB_ENV")
|
||||
if github_env and os.environ.get("DOCKER_HOST"):
|
||||
with open(github_env, "a") as f:
|
||||
f.write(f"DOCKER_HOST={os.environ['DOCKER_HOST']}\n")
|
||||
click.echo(f"Exported DOCKER_HOST={os.environ['DOCKER_HOST']} to GITHUB_ENV")
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -155,6 +155,19 @@ def main(repo: str | None, owner: str | None, branch: str, api_url: str | None)
|
||||
if not repo:
|
||||
raise click.ClickException(_("ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME."))
|
||||
|
||||
# If DEVX_REPO_NAME contains a slash (e.g. "oblachno/infra"), split into owner/repo.
|
||||
# This prevents 404s when workflows set DEVX_REPO_NAME to the full path.
|
||||
if "/" in repo and owner is None:
|
||||
parts = repo.split("/", 1)
|
||||
owner, repo = parts[0], parts[1]
|
||||
click.echo(
|
||||
_(
|
||||
"Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME",
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
)
|
||||
)
|
||||
|
||||
if owner is None:
|
||||
owner = REPO_OWNER
|
||||
|
||||
|
||||
@@ -5,12 +5,21 @@ Runs pytest-cov, doc-coverage, lint checks, and version extraction,
|
||||
then writes SVG badge files that can be served as static files from
|
||||
the Gitea raw file API.
|
||||
|
||||
The repo root is resolved from ``GITHUB_WORKSPACE`` or ``os.getcwd()``,
|
||||
so this module works correctly both when run from a source checkout
|
||||
and when devx is installed as a pip package in CI.
|
||||
|
||||
The package name and coverage target are auto-detected from the
|
||||
``src/`` directory structure, making this module reusable across
|
||||
all oblachno-oss repos without per-repo configuration.
|
||||
|
||||
Usage:
|
||||
python3 -m devx.tools.generate_badges --output-dir .badges/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
@@ -18,8 +27,7 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
# Coverage regex matches "TOTAL ... NN%" or "TOTAL ... NN.NN%"
|
||||
_COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%")
|
||||
_PASSED_RE = re.compile(r"(\d+) passed")
|
||||
_DOC_COVERAGE_RE = re.compile(r"Doc coverage:\s+\d+/\d+\s+\((\d+)%")
|
||||
@@ -37,18 +45,59 @@ COLOR_HEX: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
def _find_package_init() -> Path | None:
|
||||
"""Find the first package __init__.py under src/ that defines __version__."""
|
||||
src_dir = REPO_ROOT / "src"
|
||||
if not src_dir.exists():
|
||||
def resolve_repo_root() -> Path:
|
||||
"""Resolve the repository root directory.
|
||||
|
||||
Uses ``GITHUB_WORKSPACE`` env var (set by Gitea Actions) or
|
||||
falls back to ``os.getcwd()``. This ensures the correct repo
|
||||
root is used even when devx is installed as a pip package.
|
||||
"""
|
||||
workspace = os.environ.get("GITHUB_WORKSPACE")
|
||||
if workspace:
|
||||
path = Path(workspace)
|
||||
if path.is_dir():
|
||||
return path
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def detect_package_name(repo_root: Path) -> str | None:
|
||||
"""Auto-detect the Python package name from ``src/`` directory.
|
||||
|
||||
Looks for the first subdirectory under ``src/`` that contains
|
||||
an ``__init__.py`` file with ``__version__``.
|
||||
|
||||
Returns the package directory name (e.g., ``devx``,
|
||||
``gitea_runner_manager``) or ``None`` if no package is found.
|
||||
"""
|
||||
src_dir = repo_root / "src"
|
||||
if not src_dir.is_dir():
|
||||
return None
|
||||
for init_file in src_dir.rglob("__init__.py"):
|
||||
try:
|
||||
content = init_file.read_text()
|
||||
except OSError:
|
||||
for entry in sorted(src_dir.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
if "__version__" in content:
|
||||
return init_file
|
||||
init_file = entry / "__init__.py"
|
||||
if init_file.exists():
|
||||
return entry.name
|
||||
return None
|
||||
|
||||
|
||||
def detect_coverage_target(repo_root: Path) -> str | None:
|
||||
"""Auto-detect the pytest-cov target from pyproject.toml.
|
||||
|
||||
Parses ``addopts`` in ``[tool.pytest.ini_options]`` for
|
||||
``--cov=src/<package>``. Falls back to ``src/<package>`` if
|
||||
the package is detected but no explicit cov target is found.
|
||||
"""
|
||||
pyproject = repo_root / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
content = pyproject.read_text()
|
||||
match = re.search(r"--cov=(\S+)", content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
# Fallback: derive from package name
|
||||
pkg = detect_package_name(repo_root)
|
||||
if pkg:
|
||||
return f"src/{pkg}"
|
||||
return None
|
||||
|
||||
|
||||
@@ -57,14 +106,15 @@ def _xml_escape(text: str) -> str:
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||
|
||||
|
||||
def run_command(cmd: list[str]) -> tuple[int, str, str]:
|
||||
def run_command(cmd: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
|
||||
"""Run a command and return (returncode, stdout, stderr)."""
|
||||
root = str(cwd or resolve_repo_root())
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=str(REPO_ROOT),
|
||||
cwd=root,
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
@@ -139,15 +189,25 @@ def extract_doc_coverage(output: str) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def read_version() -> str:
|
||||
"""Read __version__ from the package __init__.py."""
|
||||
init_file = _find_package_init()
|
||||
if init_file is None:
|
||||
def read_version(repo_root: Path) -> str:
|
||||
"""Read __version__ from the package __init__.py under src/.
|
||||
|
||||
Auto-detects the package directory and reads ``__version__``
|
||||
from its ``__init__.py``.
|
||||
"""
|
||||
pkg = detect_package_name(repo_root)
|
||||
if pkg is None:
|
||||
click.echo(" WARNING: No Python package found under src/ — version badge will show 'unknown'")
|
||||
return "unknown"
|
||||
init_file = repo_root / "src" / pkg / "__init__.py"
|
||||
if not init_file.exists():
|
||||
click.echo(f" WARNING: {init_file} not found — version badge will show 'unknown'")
|
||||
return "unknown"
|
||||
content = init_file.read_text()
|
||||
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
click.echo(f" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'")
|
||||
return "unknown"
|
||||
|
||||
|
||||
@@ -179,65 +239,170 @@ def doc_coverage_color(pct: int) -> str:
|
||||
return "orange"
|
||||
|
||||
|
||||
def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]:
|
||||
"""Generate all badge SVG files and return badge data as a dict."""
|
||||
badges: dict[str, dict[str, str | int]] = {}
|
||||
def detect_testpaths(repo_root: Path) -> list[str]:
|
||||
"""Detect test paths from pyproject.toml or filesystem.
|
||||
|
||||
# 1. Code coverage + test count (single pytest-cov run)
|
||||
rc, stdout, stderr = run_command(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
"tests/",
|
||||
"-v",
|
||||
"--cov=src/devx",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-fail-under=0",
|
||||
]
|
||||
)
|
||||
Parses ``testpaths`` in ``[tool.pytest.ini_options]`` from
|
||||
pyproject.toml. Falls back to ``["tests"]`` if the tests/
|
||||
directory exists. Returns an empty list if no test paths
|
||||
are found (pytest will use its own defaults).
|
||||
"""
|
||||
pyproject = repo_root / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
content = pyproject.read_text()
|
||||
# Match: testpaths = ["dir1", "dir2"]
|
||||
match = re.search(r"testpaths\s*=\s*\[([^\]]+)\]", content)
|
||||
if match:
|
||||
paths = re.findall(r'["\']([^"\']+)["\']', match.group(1))
|
||||
resolved = []
|
||||
for p in paths:
|
||||
p = p.strip()
|
||||
if (repo_root / p).exists():
|
||||
resolved.append(p)
|
||||
if resolved:
|
||||
return resolved
|
||||
|
||||
# Fallback: tests/ directory
|
||||
tests_dir = repo_root / "tests"
|
||||
if tests_dir.is_dir():
|
||||
return ["tests"]
|
||||
return []
|
||||
|
||||
|
||||
def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], dict[str, str | int]]:
|
||||
"""Run pytest-cov and collect coverage + test count badges.
|
||||
|
||||
Returns (coverage_badge, tests_badge). If pytest is not
|
||||
available or no tests are found, returns 'unknown' badges
|
||||
with a clear warning explaining the failure.
|
||||
"""
|
||||
cov_target = detect_coverage_target(repo_root)
|
||||
if cov_target is None:
|
||||
click.echo(" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)")
|
||||
return make_badge("coverage", "unknown", "lightgrey"), make_badge("tests", "unknown", "lightgrey")
|
||||
|
||||
testpaths = detect_testpaths(repo_root)
|
||||
click.echo(f" Test paths: {testpaths or '(pytest defaults)'}")
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
*testpaths,
|
||||
"--cov",
|
||||
cov_target,
|
||||
"--cov-report=term-missing",
|
||||
"--cov-fail-under=0",
|
||||
"-q",
|
||||
]
|
||||
rc, stdout, stderr = run_command(cmd, cwd=repo_root)
|
||||
combined = stdout + "\n" + stderr
|
||||
|
||||
coverage = extract_coverage(combined)
|
||||
if coverage is not None:
|
||||
badges["coverage"] = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
|
||||
cov_badge = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
|
||||
else:
|
||||
badges["coverage"] = make_badge("coverage", "unknown", "red")
|
||||
click.echo(f" WARNING: Could not extract coverage from pytest output (rc={rc})")
|
||||
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}")
|
||||
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}")
|
||||
cov_badge = make_badge("coverage", "unknown", "red")
|
||||
|
||||
test_count = extract_test_count(combined)
|
||||
if test_count is not None:
|
||||
badges["tests"] = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
|
||||
tests_badge = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
|
||||
else:
|
||||
badges["tests"] = make_badge("tests", "unknown", "red")
|
||||
click.echo(f" WARNING: Could not extract test count from pytest output (rc={rc})")
|
||||
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}")
|
||||
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}")
|
||||
tests_badge = make_badge("tests", "unknown", "red")
|
||||
|
||||
# 2. Documentation coverage
|
||||
rc, stdout, _ = run_command(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"devx.ci.doc_coverage",
|
||||
]
|
||||
return cov_badge, tests_badge
|
||||
|
||||
|
||||
def collect_doc_coverage(repo_root: Path) -> dict[str, str | int]:
|
||||
"""Run doc_coverage and collect the docs badge."""
|
||||
rc, stdout, stderr = run_command(
|
||||
[sys.executable, "-m", "devx.ci.doc_coverage"],
|
||||
cwd=repo_root,
|
||||
)
|
||||
doc_pct = extract_doc_coverage(stdout)
|
||||
if doc_pct is not None:
|
||||
badges["docs"] = make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
|
||||
else:
|
||||
badges["docs"] = make_badge("docs", "unknown", "red")
|
||||
return make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
|
||||
click.echo(f" WARNING: Could not extract doc coverage (rc={rc})")
|
||||
click.echo(f" stderr: {stderr.strip()[:200]}")
|
||||
return make_badge("docs", "unknown", "red")
|
||||
|
||||
# 3. Code quality (ruff + pyright + bandit all pass)
|
||||
lint_rc, _, _ = run_command([sys.executable, "-m", "ruff", "check", "src/", "tests/"])
|
||||
format_rc, _, _ = run_command([sys.executable, "-m", "ruff", "format", "--check", "src/", "tests/"])
|
||||
type_rc, _, _ = run_command([sys.executable, "-m", "pyright"])
|
||||
bandit_rc, _, _ = run_command([sys.executable, "-m", "bandit", "-r", "src/"])
|
||||
|
||||
all_pass = all(rc == 0 for rc in [lint_rc, format_rc, type_rc, bandit_rc])
|
||||
badges["quality"] = make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
|
||||
def collect_quality(repo_root: Path) -> dict[str, str | int]:
|
||||
"""Run lint checks and collect the quality badge.
|
||||
|
||||
Runs ruff check, ruff format --check, pyright, and bandit.
|
||||
If any tool is not installed, it is skipped with a warning.
|
||||
"""
|
||||
results: list[bool] = []
|
||||
tool_names: list[str] = []
|
||||
|
||||
for cmd, name in [
|
||||
([sys.executable, "-m", "ruff", "check", "src/", "tests/"], "ruff check"),
|
||||
([sys.executable, "-m", "ruff", "format", "--check", "src/", "tests/"], "ruff format"),
|
||||
([sys.executable, "-m", "pyright"], "pyright"),
|
||||
([sys.executable, "-m", "bandit", "-r", "src/"], "bandit"),
|
||||
]:
|
||||
rc, _, stderr = run_command(cmd, cwd=repo_root)
|
||||
if rc == 0:
|
||||
results.append(True)
|
||||
tool_names.append(f"{name}: pass")
|
||||
else:
|
||||
results.append(False)
|
||||
# Distinguish "tool not installed" from "tool found issues"
|
||||
if "No module named" in stderr or "not found" in stderr.lower():
|
||||
click.echo(f" WARNING: {name} not installed — skipping (counted as pass)")
|
||||
results[-1] = True
|
||||
tool_names.append(f"{name}: not installed (skipped)")
|
||||
else:
|
||||
tool_names.append(f"{name}: FAIL")
|
||||
click.echo(f" WARNING: {name} failed (rc={rc})")
|
||||
click.echo(f" stderr: {stderr.strip()[:200]}")
|
||||
|
||||
all_pass = all(results)
|
||||
click.echo(f" Quality checks: {', '.join(tool_names)}")
|
||||
return make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
|
||||
|
||||
|
||||
def generate_badges(output_dir: Path, repo_root: Path | None = None) -> dict[str, dict[str, str | int]]:
|
||||
"""Generate all badge SVG files and return badge data as a dict.
|
||||
|
||||
Args:
|
||||
output_dir: Directory to write SVG files.
|
||||
repo_root: Repository root (auto-detected if None).
|
||||
"""
|
||||
root = repo_root or resolve_repo_root()
|
||||
click.echo(f" Repo root: {root}")
|
||||
pkg = detect_package_name(root)
|
||||
click.echo(f" Package: {pkg or 'none'}")
|
||||
|
||||
badges: dict[str, dict[str, str | int]] = {}
|
||||
|
||||
# 1. Code coverage + test count (single pytest-cov run)
|
||||
click.echo(" Collecting coverage and tests...")
|
||||
cov_badge, tests_badge = collect_coverage_and_tests(root)
|
||||
badges["coverage"] = cov_badge
|
||||
badges["tests"] = tests_badge
|
||||
|
||||
# 2. Documentation coverage
|
||||
click.echo(" Collecting doc coverage...")
|
||||
badges["docs"] = collect_doc_coverage(root)
|
||||
|
||||
# 3. Code quality (ruff + pyright + bandit)
|
||||
click.echo(" Collecting code quality...")
|
||||
badges["quality"] = collect_quality(root)
|
||||
|
||||
# 4. Version
|
||||
version = read_version()
|
||||
click.echo(" Collecting version...")
|
||||
version = read_version(root)
|
||||
badges["version"] = make_badge("version", f"v{version}", "blue")
|
||||
|
||||
# 5. Python version (static but nice)
|
||||
# 5. Python version (static)
|
||||
badges["python"] = make_badge("python", "3.12", "blue")
|
||||
|
||||
# Write SVG files
|
||||
@@ -254,14 +419,20 @@ def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]:
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--output-dir",
|
||||
default=str(REPO_ROOT / ".badges"),
|
||||
default=".badges",
|
||||
help="Directory to write badge SVG files.",
|
||||
)
|
||||
def cli(output_dir: str) -> None:
|
||||
@click.option(
|
||||
"--repo-root",
|
||||
default=None,
|
||||
help="Repository root (auto-detected if not specified).",
|
||||
)
|
||||
def cli(output_dir: str, repo_root: str | None) -> None:
|
||||
"""Generate self-contained SVG badge files from project metrics."""
|
||||
out = Path(output_dir)
|
||||
root = Path(repo_root) if repo_root else None
|
||||
click.echo(f"Generating badges in {out}...")
|
||||
badges = generate_badges(out)
|
||||
badges = generate_badges(out, repo_root=root)
|
||||
click.echo(f"\nGenerated {len(badges)} badges:")
|
||||
for name, badge in badges.items():
|
||||
click.echo(f" {name}: {badge['label']}={badge['message']} ({badge['color']})")
|
||||
|
||||
@@ -776,13 +776,6 @@
|
||||
"ru": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}",
|
||||
"zh": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: <conventional commit message>\n Got: {subject}"
|
||||
},
|
||||
"Oops! No task ID found in .taskid file or branch name '{branch}'.": {
|
||||
"bg": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
|
||||
"de": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
|
||||
"en": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
|
||||
"ru": "Oops! No task ID found in .taskid file or branch name '{branch}'.",
|
||||
"zh": "Oops! No task ID found in .taskid file or branch name '{branch}'."
|
||||
},
|
||||
"Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
|
||||
"bg": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
|
||||
"de": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}",
|
||||
@@ -811,6 +804,13 @@
|
||||
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
|
||||
"zh": "哎呀!PyPI 发布失败:\n{stderr}"
|
||||
},
|
||||
"Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME": {
|
||||
"bg": "Разбор на owner={owner}, repo={repo} от DEVX_REPO_NAME",
|
||||
"de": "Owner={owner}, repo={repo} aus DEVX_REPO_NAME analysiert",
|
||||
"en": "Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME",
|
||||
"ru": "Извлечён owner={owner}, repo={repo} из DEVX_REPO_NAME",
|
||||
"zh": "从 DEVX_REPO_NAME 解析 owner={owner}, repo={repo}"
|
||||
},
|
||||
"PASSED: {pair}": {
|
||||
"bg": "PASSED: {pair}",
|
||||
"de": "PASSED: {pair}",
|
||||
@@ -1237,5 +1237,19 @@
|
||||
"en": "{file} already exists. Use --force to overwrite.",
|
||||
"ru": "{file} already exists. Use --force to overwrite.",
|
||||
"zh": "{file} already exists. Use --force to overwrite."
|
||||
},
|
||||
"Oops! No task ID found in branch name '{branch}'. Branch names must include the task ID prefix (e.g., DEVX-31-fix-bug).": {
|
||||
"en": "Oops! No task ID found in branch name '{branch}'. Branch names must include the task ID prefix (e.g., DEVX-31-fix-bug).",
|
||||
"bg": "Ой! Не е намерен ID на задача в името на клона '{branch}'. Имената на клонове трябва да включват префикса за ID на задача (напр. DEVX-31-fix-bug).",
|
||||
"de": "Hoppla! Keine Task-ID im Branch-Namen '{branch}' gefunden. Branch-Namen müssen das Task-ID-Präfix enthalten (z.B. DEVX-31-fix-bug).",
|
||||
"ru": "Ой! ID задачи не найден в имени ветки '{branch}'. Имена веток должны включать префикс ID задачи (например, DEVX-31-fix-bug).",
|
||||
"zh": "哎呀!在分支名称 '{branch}' 中未找到任务 ID。分支名称必须包含任务 ID 前缀(例如 DEVX-31-fix-bug)。"
|
||||
},
|
||||
"WARNING: .taskid file ({file_id}) is deprecated and disagrees with branch name ({branch_id}). Delete .taskid from the repo — branch name is the sole source of truth.": {
|
||||
"en": "WARNING: .taskid file ({file_id}) is deprecated and disagrees with branch name ({branch_id}). Delete .taskid from the repo — branch name is the sole source of truth.",
|
||||
"bg": "ВНИМАНИЕ: Файлът .taskid ({file_id}) е остарял и не съвпада с името на клона ({branch_id}). Изтрийте .taskid от хранилището — името на клона е единственият източник на истината.",
|
||||
"de": "WARNUNG: Die Datei .taskid ({file_id}) ist veraltet und stimmt nicht mit dem Branch-Namen ({branch_id}) überein. Löschen Sie .taskid aus dem Repo — der Branch-Name ist die einzige Wahrheitsquelle.",
|
||||
"ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.",
|
||||
"zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,23 +21,31 @@ from devx.exceptions import APIError
|
||||
|
||||
|
||||
class TestReadTaskid:
|
||||
def test_reads_from_file(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-60\n")
|
||||
assert read_taskid("some-branch") == "DEVX-60"
|
||||
|
||||
def test_falls_back_to_branch_name(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
def test_extracts_from_branch_name(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
|
||||
|
||||
def test_returns_empty_when_no_file_no_match(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
def test_returns_empty_when_no_match(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert read_taskid("feature-branch") == ""
|
||||
|
||||
def test_empty_file_falls_back_to_branch(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
def test_warns_on_stale_taskid_file(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-60\n")
|
||||
# Branch name takes priority, stale .taskid should produce deprecation warning
|
||||
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
|
||||
captured = capsys.readouterr()
|
||||
combined = captured.out + captured.err
|
||||
assert "WARNING" in combined
|
||||
assert "deprecated" in combined
|
||||
assert "DEVX-60" in combined
|
||||
assert "DEVX-19" in combined
|
||||
|
||||
def test_no_warning_when_taskid_file_absent(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("\n")
|
||||
assert read_taskid("DEVX-42-test") == "DEVX-42"
|
||||
captured = capsys.readouterr()
|
||||
assert "WARNING" not in captured.out
|
||||
|
||||
|
||||
# -- extract_task_id (legacy fallback) --
|
||||
@@ -200,7 +208,6 @@ class TestMain:
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_commits.return_value = [
|
||||
@@ -227,7 +234,7 @@ class TestMain:
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_no_task_id_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
# No .taskid file, no DEVX-N in branch name
|
||||
# No DEVX-N in branch name
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["feature-branch", "DEVX-19: test", "owner/repo", "7"])
|
||||
assert result.exit_code != 0
|
||||
@@ -237,7 +244,6 @@ class TestMain:
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_invalid_pr_title_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-19-fix", "Bad title", "owner/repo", "7"])
|
||||
@@ -251,7 +257,6 @@ class TestMain:
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_commits.return_value = [
|
||||
@@ -281,7 +286,6 @@ class TestMain:
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_commits.return_value = [
|
||||
@@ -306,7 +310,6 @@ class TestMain:
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
"""When no conventional commit message is found in PR commits, raises."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_commits.return_value = []
|
||||
@@ -324,7 +327,6 @@ class TestMain:
|
||||
def test_invalid_pr_number_raises(self, tmp_path, monkeypatch) -> None:
|
||||
"""Non-integer PR number should raise."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: Test", "owner/repo", "not-a-number"])
|
||||
assert result.exit_code != 0
|
||||
@@ -334,7 +336,6 @@ class TestMain:
|
||||
def test_invalid_repo_format_raises(self, tmp_path, monkeypatch) -> None:
|
||||
"""Repo without owner/name should raise."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: Test", "invalidrepo", "7"])
|
||||
assert result.exit_code != 0
|
||||
@@ -348,7 +349,6 @@ class TestMain:
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
"""When rebase retry also fails, raises with helpful message."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_commits.return_value = [
|
||||
|
||||
@@ -37,7 +37,7 @@ class TestConfigConstants:
|
||||
assert DEFAULT_PER_PAGE == 50
|
||||
|
||||
def test_owner(self) -> None:
|
||||
assert REPO_OWNER == "oblachno-oss"
|
||||
assert REPO_OWNER == ""
|
||||
|
||||
def test_task_prefix(self) -> None:
|
||||
assert TASK_PREFIX == "DEVX"
|
||||
|
||||
@@ -163,3 +163,37 @@ class TestMain:
|
||||
mock_client.ensure_branch_protection.assert_called_once()
|
||||
args = mock_client.ensure_branch_protection.call_args
|
||||
assert args[0][0] == "develop"
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "oblachno/infra"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_main_parses_owner_repo_from_env(self, mock_client_cls: MagicMock) -> None:
|
||||
"""DEVX_REPO_NAME with 'owner/repo' format should be split."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
# Verify GiteaClient was constructed with parsed owner and repo (positional)
|
||||
call_args = mock_client_cls.call_args
|
||||
assert call_args[0][2] == "oblachno" # owner is 3rd positional arg
|
||||
assert call_args[0][3] == "infra" # repo is 4th positional arg
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "infra", "DEVX_REPO_OWNER": "oblachno"},
|
||||
clear=True,
|
||||
)
|
||||
@patch("devx.tools.configure_repo.REPO_OWNER", "oblachno")
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_main_no_slash_when_owner_set_separately(self, mock_client_cls: MagicMock) -> None:
|
||||
"""When DEVX_REPO_OWNER is set, DEVX_REPO_NAME should not be split."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
call_args = mock_client_cls.call_args
|
||||
assert call_args[0][2] == "oblachno" # owner
|
||||
assert call_args[0][3] == "infra" # repo
|
||||
|
||||
+198
-142
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for scripts/generate_badges.py."""
|
||||
"""Unit tests for devx/tools/generate_badges.py."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -8,7 +8,13 @@ from click.testing import CliRunner
|
||||
from devx.tools.generate_badges import (
|
||||
COLOR_HEX,
|
||||
cli,
|
||||
collect_coverage_and_tests,
|
||||
collect_doc_coverage,
|
||||
collect_quality,
|
||||
coverage_color,
|
||||
detect_coverage_target,
|
||||
detect_package_name,
|
||||
detect_testpaths,
|
||||
doc_coverage_color,
|
||||
extract_coverage,
|
||||
extract_doc_coverage,
|
||||
@@ -17,10 +23,107 @@ from devx.tools.generate_badges import (
|
||||
make_badge,
|
||||
read_version,
|
||||
render_svg,
|
||||
resolve_repo_root,
|
||||
run_command,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveRepoRoot:
|
||||
def test_uses_github_workspace_when_set(self, tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.setenv("GITHUB_WORKSPACE", str(tmp_path))
|
||||
assert resolve_repo_root() == tmp_path
|
||||
|
||||
def test_falls_back_to_cwd_when_no_workspace(self, tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.delenv("GITHUB_WORKSPACE", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert resolve_repo_root() == tmp_path
|
||||
|
||||
def test_falls_back_to_cwd_when_workspace_invalid(self, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.setenv("GITHUB_WORKSPACE", "/nonexistent/path")
|
||||
result = resolve_repo_root()
|
||||
assert result == Path.cwd()
|
||||
|
||||
|
||||
class TestDetectPackageName:
|
||||
def test_detects_package_with_init(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src"
|
||||
pkg = src / "mypkg"
|
||||
pkg.mkdir(parents=True)
|
||||
(pkg / "__init__.py").write_text('__version__ = "1.0.0"\n')
|
||||
assert detect_package_name(tmp_path) == "mypkg"
|
||||
|
||||
def test_returns_none_when_no_src(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
assert detect_package_name(tmp_path) is None
|
||||
|
||||
def test_returns_none_when_no_init(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src"
|
||||
pkg = src / "mypkg"
|
||||
pkg.mkdir(parents=True)
|
||||
# No __init__.py
|
||||
assert detect_package_name(tmp_path) is None
|
||||
|
||||
def test_picks_first_package_alphabetically(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src"
|
||||
for name in ["zpkg", "apkg"]:
|
||||
d = src / name
|
||||
d.mkdir(parents=True)
|
||||
(d / "__init__.py").write_text("")
|
||||
assert detect_package_name(tmp_path) == "apkg"
|
||||
|
||||
def test_skips_non_dir_entries(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src"
|
||||
src.mkdir(parents=True)
|
||||
(src / "README.md").write_text("not a package")
|
||||
pkg = src / "mypkg"
|
||||
pkg.mkdir()
|
||||
(pkg / "__init__.py").write_text("")
|
||||
assert detect_package_name(tmp_path) == "mypkg"
|
||||
|
||||
|
||||
class TestDetectCoverageTarget:
|
||||
def test_parses_from_pyproject(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
'[tool.pytest.ini_options]\naddopts = "--cov=src/devx --cov-report=term-missing"\n'
|
||||
)
|
||||
assert detect_coverage_target(tmp_path) == "src/devx"
|
||||
|
||||
def test_falls_back_to_src_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src"
|
||||
pkg = src / "mypkg"
|
||||
pkg.mkdir(parents=True)
|
||||
(pkg / "__init__.py").write_text('__version__ = "1.0"\n')
|
||||
assert detect_coverage_target(tmp_path) == "src/mypkg"
|
||||
|
||||
def test_returns_none_when_no_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
assert detect_coverage_target(tmp_path) is None
|
||||
|
||||
|
||||
class TestDetectTestpaths:
|
||||
def test_parses_from_pyproject(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
(tmp_path / "scripts" / "tests").mkdir(parents=True)
|
||||
(tmp_path / "tests" / "unit").mkdir(parents=True)
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
'[tool.pytest.ini_options]\ntestpaths = ["scripts/tests", "tests/unit"]\n'
|
||||
)
|
||||
assert detect_testpaths(tmp_path) == ["scripts/tests", "tests/unit"]
|
||||
|
||||
def test_filters_nonexistent_paths(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
(tmp_path / "tests").mkdir()
|
||||
(tmp_path / "pyproject.toml").write_text('[tool.pytest.ini_options]\ntestpaths = ["tests", "nonexistent"]\n')
|
||||
assert detect_testpaths(tmp_path) == ["tests"]
|
||||
|
||||
def test_falls_back_to_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
(tmp_path / "tests").mkdir()
|
||||
assert detect_testpaths(tmp_path) == ["tests"]
|
||||
|
||||
def test_returns_empty_when_no_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
assert detect_testpaths(tmp_path) == []
|
||||
|
||||
def test_returns_empty_when_pyproject_has_no_testpaths(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
(tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]\naddopts = '-ra'\n")
|
||||
assert detect_testpaths(tmp_path) == []
|
||||
|
||||
|
||||
class TestRunCommand:
|
||||
@patch("devx.tools.generate_badges.subprocess.run")
|
||||
def test_returns_returncode_stdout_stderr(self, mock_run: MagicMock) -> None:
|
||||
@@ -157,100 +260,122 @@ class TestDocCoverageColor:
|
||||
|
||||
|
||||
class TestReadVersion:
|
||||
@patch("devx.tools.generate_badges._find_package_init")
|
||||
def test_reads_version_from_init(self, mock_find: MagicMock) -> None:
|
||||
mock_init = MagicMock()
|
||||
mock_init.read_text.return_value = '__version__ = "0.5.0"\n'
|
||||
mock_find.return_value = mock_init
|
||||
assert read_version() == "0.5.0"
|
||||
def test_reads_version_from_init(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src" / "mypkg"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text('__version__ = "0.5.0"\n')
|
||||
assert read_version(tmp_path) == "0.5.0"
|
||||
|
||||
@patch("devx.tools.generate_badges._find_package_init")
|
||||
def test_returns_unknown_when_no_version(self, mock_find: MagicMock) -> None:
|
||||
mock_init = MagicMock()
|
||||
mock_init.read_text.return_value = "no version here\n"
|
||||
mock_find.return_value = mock_init
|
||||
assert read_version() == "unknown"
|
||||
def test_returns_unknown_when_no_version(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src" / "mypkg"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text("no version here\n")
|
||||
assert read_version(tmp_path) == "unknown"
|
||||
|
||||
@patch("devx.tools.generate_badges._find_package_init", return_value=None)
|
||||
def test_returns_unknown_when_no_init(self, mock_find: MagicMock) -> None:
|
||||
assert read_version() == "unknown"
|
||||
def test_returns_unknown_when_no_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
assert read_version(tmp_path) == "unknown"
|
||||
|
||||
@patch("devx.tools.generate_badges.detect_package_name", return_value="mypkg")
|
||||
def test_returns_unknown_when_init_missing(self, mock_pkg: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
# Package detected but __init__.py doesn't exist (edge case)
|
||||
assert read_version(tmp_path) == "unknown"
|
||||
|
||||
|
||||
class TestFindPackageInit:
|
||||
@patch("devx.tools.generate_badges.REPO_ROOT")
|
||||
def test_no_src_dir(self, mock_root: MagicMock) -> None:
|
||||
"""Returns None when src/ directory doesn't exist."""
|
||||
from devx.tools.generate_badges import _find_package_init
|
||||
class TestCollectCoverageAndTests:
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.detect_testpaths", return_value=["tests"])
|
||||
@patch("devx.tools.generate_badges.detect_coverage_target", return_value="src/devx")
|
||||
def test_extracts_coverage_and_tests(
|
||||
self, mock_target: MagicMock, mock_testpaths: MagicMock, mock_run: MagicMock, tmp_path: Path
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (0, "1018 passed in 4.23s\nTOTAL 3546 0 100%", "")
|
||||
cov, tests = collect_coverage_and_tests(tmp_path)
|
||||
assert cov["message"] == "100%"
|
||||
assert tests["message"] == "1018 passing"
|
||||
|
||||
mock_src = MagicMock()
|
||||
mock_src.exists.return_value = False
|
||||
mock_root.__truediv__ = MagicMock(return_value=mock_src)
|
||||
assert _find_package_init() is None
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.detect_testpaths", return_value=["tests"])
|
||||
@patch("devx.tools.generate_badges.detect_coverage_target", return_value="src/devx")
|
||||
def test_returns_unknown_when_no_match(
|
||||
self, mock_target: MagicMock, mock_testpaths: MagicMock, mock_run: MagicMock, tmp_path: Path
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (1, "garbled output", "some error")
|
||||
cov, tests = collect_coverage_and_tests(tmp_path)
|
||||
assert cov["message"] == "unknown"
|
||||
assert tests["message"] == "unknown"
|
||||
|
||||
@patch("devx.tools.generate_badges.REPO_ROOT")
|
||||
def test_no_version_in_init_files(self, mock_root: MagicMock, tmp_path: Path) -> None:
|
||||
"""Returns None when no __init__.py has __version__."""
|
||||
from devx.tools.generate_badges import _find_package_init
|
||||
@patch("devx.tools.generate_badges.detect_coverage_target", return_value=None)
|
||||
def test_returns_lightgrey_when_no_target(self, mock_target: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
cov, tests = collect_coverage_and_tests(tmp_path)
|
||||
assert cov["message"] == "unknown"
|
||||
assert cov["color"] == "lightgrey"
|
||||
assert tests["message"] == "unknown"
|
||||
assert tests["color"] == "lightgrey"
|
||||
|
||||
src_dir = tmp_path / "src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "__init__.py").write_text("# no version here\n")
|
||||
mock_root.__truediv__ = MagicMock(return_value=src_dir)
|
||||
assert _find_package_init() is None
|
||||
|
||||
@patch("devx.tools.generate_badges.REPO_ROOT")
|
||||
def test_finds_init_with_version(self, mock_root: MagicMock, tmp_path: Path) -> None:
|
||||
"""Returns the __init__.py that has __version__."""
|
||||
from devx.tools.generate_badges import _find_package_init
|
||||
class TestCollectDocCoverage:
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
def test_extracts_doc_coverage(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (0, "Doc coverage: 20/20 (100%)", "")
|
||||
badge = collect_doc_coverage(tmp_path)
|
||||
assert badge["message"] == "100%"
|
||||
|
||||
src_dir = tmp_path / "src"
|
||||
pkg_dir = src_dir / "mypkg"
|
||||
pkg_dir.mkdir(parents=True)
|
||||
(src_dir / "__init__.py").write_text("# no version\n")
|
||||
(pkg_dir / "__init__.py").write_text('__version__ = "1.0.0"\n')
|
||||
mock_root.__truediv__ = MagicMock(return_value=src_dir)
|
||||
result = _find_package_init()
|
||||
assert result is not None
|
||||
assert "__version__" in result.read_text()
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
def test_returns_unknown_when_no_match(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (1, "no doc coverage", "error")
|
||||
badge = collect_doc_coverage(tmp_path)
|
||||
assert badge["message"] == "unknown"
|
||||
|
||||
@patch("devx.tools.generate_badges.REPO_ROOT")
|
||||
def test_handles_oserror(self, mock_root: MagicMock, tmp_path: Path) -> None:
|
||||
"""Handles OSError when reading init files."""
|
||||
from devx.tools.generate_badges import _find_package_init
|
||||
|
||||
src_dir = tmp_path / "src"
|
||||
src_dir.mkdir()
|
||||
init_file = src_dir / "__init__.py"
|
||||
init_file.write_text('__version__ = "1.0.0"\n')
|
||||
mock_root.__truediv__ = MagicMock(return_value=src_dir)
|
||||
# Patch Path.read_text to raise OSError
|
||||
with patch.object(Path, "read_text", side_effect=OSError("permission denied")):
|
||||
result = _find_package_init()
|
||||
assert result is None
|
||||
class TestCollectQuality:
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
def test_all_pass_returns_a(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (0, "", "")
|
||||
badge = collect_quality(tmp_path)
|
||||
assert badge["message"] == "A"
|
||||
assert badge["color"] == "brightgreen"
|
||||
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
def test_lint_failure_returns_f(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (1, "", "some error")
|
||||
badge = collect_quality(tmp_path)
|
||||
assert badge["message"] == "F"
|
||||
assert badge["color"] == "red"
|
||||
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
def test_tool_not_installed_counts_as_pass(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (1, "", "No module named ruff")
|
||||
badge = collect_quality(tmp_path)
|
||||
assert badge["message"] == "A"
|
||||
|
||||
|
||||
class TestGenerateBadges:
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.collect_quality")
|
||||
@patch("devx.tools.generate_badges.collect_doc_coverage")
|
||||
@patch("devx.tools.generate_badges.collect_coverage_and_tests")
|
||||
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
|
||||
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
|
||||
@patch("devx.tools.generate_badges.detect_package_name", return_value="devx")
|
||||
def test_generates_all_badge_files(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_pkg: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_cov_tests: MagicMock,
|
||||
mock_doc: MagicMock,
|
||||
mock_quality: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.return_value = (0, "output", "")
|
||||
badges = generate_badges(tmp_path)
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
mock_cov_tests.return_value = (
|
||||
make_badge("coverage", "100%", "brightgreen"),
|
||||
make_badge("tests", "573 passing", "brightgreen"),
|
||||
)
|
||||
mock_doc.return_value = make_badge("docs", "100%", "brightgreen")
|
||||
mock_quality.return_value = make_badge("code quality", "A", "brightgreen")
|
||||
|
||||
badges = generate_badges(tmp_path, repo_root=tmp_path)
|
||||
|
||||
expected = {"coverage", "tests", "docs", "quality", "version", "python"}
|
||||
assert set(badges.keys()) == expected
|
||||
|
||||
# Verify SVG files were written
|
||||
for name in expected:
|
||||
svg_file = tmp_path / f"{name}.svg"
|
||||
assert svg_file.exists()
|
||||
@@ -258,78 +383,10 @@ class TestGenerateBadges:
|
||||
assert content.startswith("<svg")
|
||||
assert "</svg>" in content
|
||||
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
|
||||
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
|
||||
def test_quality_badge_pass_when_all_lint_passes(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.return_value = (0, "output", "")
|
||||
badges = generate_badges(tmp_path)
|
||||
assert badges["quality"]["message"] == "A"
|
||||
assert badges["quality"]["color"] == "brightgreen"
|
||||
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
|
||||
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
|
||||
def test_quality_badge_fails_when_lint_fails(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.side_effect = [
|
||||
(0, "output", ""),
|
||||
(0, "output", ""),
|
||||
(1, "error", ""),
|
||||
(0, "output", ""),
|
||||
(0, "output", ""),
|
||||
(0, "output", ""),
|
||||
]
|
||||
badges = generate_badges(tmp_path)
|
||||
assert badges["quality"]["message"] == "F"
|
||||
assert badges["quality"]["color"] == "red"
|
||||
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("devx.tools.generate_badges.extract_coverage", return_value=None)
|
||||
@patch("devx.tools.generate_badges.extract_test_count", return_value=None)
|
||||
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=None)
|
||||
def test_badges_show_unknown_when_extraction_fails(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.return_value = (1, "garbled output", "")
|
||||
badges = generate_badges(tmp_path)
|
||||
assert badges["coverage"]["message"] == "unknown"
|
||||
assert badges["coverage"]["color"] == "red"
|
||||
assert badges["tests"]["message"] == "unknown"
|
||||
assert badges["tests"]["color"] == "red"
|
||||
assert badges["docs"]["message"] == "unknown"
|
||||
assert badges["docs"]["color"] == "red"
|
||||
|
||||
|
||||
class TestCli:
|
||||
@patch("devx.tools.generate_badges.generate_badges")
|
||||
def test_cli_generates_badges(self, mock_gen: MagicMock, tmp_path: Path) -> None:
|
||||
def test_cli_generates_badges(self, mock_gen: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
mock_gen.return_value = {
|
||||
"coverage": make_badge("coverage", "100%", "brightgreen"),
|
||||
"tests": make_badge("tests", "573 passing", "brightgreen"),
|
||||
@@ -339,7 +396,6 @@ class TestCli:
|
||||
assert result.exit_code == 0
|
||||
assert "Generating badges" in result.output
|
||||
assert "Generated 2 badges" in result.output
|
||||
mock_gen.assert_called_once_with(tmp_path)
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
|
||||
@@ -162,17 +162,26 @@ class TestCli:
|
||||
|
||||
with (
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
mock_popen.return_value = proc
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||
assert result.exit_code == 0
|
||||
assert "All molecule tests passed" in result.output
|
||||
# Verify Docker prune was called between scenarios
|
||||
mock_run.assert_called_once_with(
|
||||
["docker", "system", "prune", "-af", "--volumes"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
def test_invalid_pair_format_raises(self) -> None:
|
||||
"""Pair with fewer than 2 parts should raise."""
|
||||
@@ -324,6 +333,7 @@ class TestCli:
|
||||
),
|
||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.05)),
|
||||
):
|
||||
@@ -332,6 +342,7 @@ class TestCli:
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
mock_popen.return_value = proc
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||
@@ -536,12 +547,14 @@ class TestCliMultiRole:
|
||||
|
||||
with (
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
mock_popen.return_value = proc
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
@@ -560,12 +573,14 @@ class TestCliMultiRole:
|
||||
|
||||
with (
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
mock_popen.return_value = proc
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Unit tests for devx.ci.publish."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
@@ -64,7 +65,8 @@ class TestGenerateReleaseNotes:
|
||||
|
||||
class TestBuildPackage:
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
def test_success(self, mock_run: MagicMock) -> None:
|
||||
@patch("devx.ci.publish.Path.exists", return_value=False)
|
||||
def test_success(self, mock_exists: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
||||
build_package()
|
||||
args, _ = mock_run.call_args
|
||||
@@ -72,12 +74,23 @@ class TestBuildPackage:
|
||||
assert args[0][2] == "build"
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
||||
@patch("devx.ci.publish.Path.exists", return_value=False)
|
||||
def test_failure_raises(self, mock_exists: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="build error")
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
build_package()
|
||||
assert "build" in str(exc.value)
|
||||
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
@patch("devx.ci.publish.shutil.rmtree")
|
||||
@patch("devx.ci.publish.Path.exists", return_value=True)
|
||||
def test_cleans_dist_before_build(
|
||||
self, mock_exists: MagicMock, mock_rmtree: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
||||
build_package()
|
||||
mock_rmtree.assert_called_once_with(Path("dist"))
|
||||
|
||||
|
||||
class TestPublishToPypi:
|
||||
@patch("devx.ci.publish.subprocess.run")
|
||||
|
||||
@@ -287,3 +287,19 @@ class TestMain:
|
||||
result = runner.invoke(push_badges.main, ["--no-readme-update"])
|
||||
assert result.exit_code != 0
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
|
||||
class TestRepoRoot:
|
||||
def test_uses_github_workspace(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("GITHUB_WORKSPACE", str(tmp_path))
|
||||
assert push_badges._repo_root() == tmp_path
|
||||
|
||||
def test_falls_back_to_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("GITHUB_WORKSPACE", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert push_badges._repo_root() == tmp_path
|
||||
|
||||
def test_falls_back_when_workspace_invalid(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("GITHUB_WORKSPACE", "/nonexistent")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert push_badges._repo_root() == tmp_path
|
||||
|
||||
@@ -270,6 +270,15 @@ class TestVerifyTagConsistency:
|
||||
mock_tags.return_value = []
|
||||
assert verify_tag_consistency() == []
|
||||
|
||||
@patch("devx.ci.release.get_commit_version")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
def test_non_version_tags_ignored(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None:
|
||||
"""Non-version tags like 'master' should be skipped, not crash."""
|
||||
mock_tags.return_value = ["v0.2.0", "master", "v0.1.0"]
|
||||
mock_cv.side_effect = ["0.2.0", "0.1.0"] # only version tags get checked
|
||||
errors = verify_tag_consistency()
|
||||
assert errors == []
|
||||
|
||||
|
||||
class TestGetInitVersion:
|
||||
def test_returns_version(self, tmp_path, monkeypatch) -> None:
|
||||
@@ -776,7 +785,7 @@ class TestCreateAndPushTag:
|
||||
create_and_push_tag("0.2.0", "changelog", dry_run=False)
|
||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
||||
assert ["git", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls
|
||||
assert ["git", "push", "origin", "v0.2.0"] in calls
|
||||
assert ["git", "push", "origin", "refs/tags/v0.2.0"] in calls
|
||||
|
||||
@patch("devx.ci.release.tag_exists", return_value=False)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@@ -803,7 +812,7 @@ class TestCreateAndPushTag:
|
||||
# Should not create tag, but should ensure it's pushed
|
||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
||||
assert ["git", "tag", "-a"] not in [c[:3] for c in calls]
|
||||
assert ["git", "push", "origin", "v0.1.0"] in calls
|
||||
assert ["git", "push", "origin", "refs/tags/v0.1.0"] in calls
|
||||
|
||||
@patch("devx.ci.release.get_head_commit", return_value="def456")
|
||||
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
|
||||
|
||||
+109
-14
@@ -1,5 +1,6 @@
|
||||
"""Unit tests for devx.molecule.start_docker."""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
@@ -17,7 +18,8 @@ class TestIsDockerReady:
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
def test_ready(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
assert is_docker_ready() is True
|
||||
with patch.dict("os.environ", {"DOCKER_HOST": f"unix://{DOCKER_SOCK}"}, clear=False):
|
||||
assert is_docker_ready() is True
|
||||
call_kwargs = mock_run.call_args
|
||||
assert call_kwargs.args[0] == ["docker", "info"]
|
||||
assert call_kwargs.kwargs["env"]["DOCKER_HOST"] == f"unix://{DOCKER_SOCK}"
|
||||
@@ -27,6 +29,16 @@ class TestIsDockerReady:
|
||||
mock_run.return_value = MagicMock(returncode=1)
|
||||
assert is_docker_ready() is False
|
||||
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
def test_uses_docker_host_env(self, mock_run: MagicMock) -> None:
|
||||
"""Should check the socket specified by DOCKER_HOST env var."""
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
rootless = "unix:///run/user/999/docker.sock"
|
||||
with patch.dict("os.environ", {"DOCKER_HOST": rootless}, clear=False):
|
||||
assert is_docker_ready() is True
|
||||
call_kwargs = mock_run.call_args
|
||||
assert call_kwargs.kwargs["env"]["DOCKER_HOST"] == rootless
|
||||
|
||||
|
||||
class TestDiagnoseSocket:
|
||||
@patch("devx.molecule.start_docker.os.stat")
|
||||
@@ -68,20 +80,71 @@ class TestStartDockerDaemon:
|
||||
mock_diag.assert_called_once()
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready")
|
||||
def test_rootless_socket_available(
|
||||
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
|
||||
) -> None:
|
||||
"""Should use rootless socket if host socket fails."""
|
||||
# First check (host) fails, second check (rootless) succeeds
|
||||
mock_ready.side_effect = [False, True]
|
||||
with patch("devx.molecule.start_docker.glob.glob", return_value=[]):
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready")
|
||||
def test_alt_rootless_socket_found(
|
||||
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
|
||||
) -> None:
|
||||
"""Should find rootless socket at a different UID via glob scan."""
|
||||
# Host fails, own rootless fails, alt rootless succeeds
|
||||
mock_ready.side_effect = [False, False, True]
|
||||
alt_sock = "/run/user/999/docker.sock"
|
||||
with patch("devx.molecule.start_docker.glob.glob", return_value=[alt_sock]):
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready")
|
||||
def test_alt_rootless_socket_skips_own(
|
||||
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
|
||||
) -> None:
|
||||
"""Should skip the own rootless socket in glob scan (already tried)."""
|
||||
# Host fails, own rootless fails, alt rootless also fails, dockerd fails
|
||||
mock_ready.side_effect = [False, False, False, False, False, False]
|
||||
own_sock = f"/run/user/{os.getuid()}/docker.sock"
|
||||
alt_sock = "/run/user/999/docker.sock"
|
||||
with (
|
||||
patch("devx.molecule.start_docker.glob.glob", return_value=[own_sock, alt_sock]),
|
||||
patch("devx.molecule.start_docker.time.sleep"),
|
||||
patch("devx.molecule.start_docker.subprocess.Popen"),
|
||||
patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile") as mock_ntf,
|
||||
patch("builtins.open", mock_open(read_data="err")),
|
||||
):
|
||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||
assert start_docker_daemon(timeout=2) is False
|
||||
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
@patch("devx.molecule.start_docker.subprocess.Popen")
|
||||
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
||||
def test_starts_local_daemon(
|
||||
self,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
mock_glob: MagicMock,
|
||||
) -> None:
|
||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||
mock_ready.side_effect = [False, False, False, True]
|
||||
# Host fails, rootless doesn't exist, local daemon starts
|
||||
mock_ready.side_effect = [False, False, False, False, True]
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
mock_popen.assert_called_once()
|
||||
popen_args = mock_popen.call_args.args[0]
|
||||
@@ -89,21 +152,23 @@ class TestStartDockerDaemon:
|
||||
assert "--storage-driver" in popen_args
|
||||
assert "vfs" in popen_args
|
||||
assert "-H" in popen_args
|
||||
assert f"unix://{DOCKER_SOCK}" in popen_args
|
||||
assert mock_sleep.call_count == 2
|
||||
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
@patch("devx.molecule.start_docker.subprocess.Popen")
|
||||
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
||||
def test_fails_after_timeout(
|
||||
self,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
mock_glob: MagicMock,
|
||||
) -> None:
|
||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||
with patch("builtins.open", mock_open(read_data="dockerd error log")):
|
||||
@@ -111,42 +176,51 @@ class TestStartDockerDaemon:
|
||||
mock_popen.assert_called_once()
|
||||
assert mock_sleep.call_count == 3
|
||||
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
@patch("devx.molecule.start_docker.subprocess.Popen")
|
||||
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
||||
def test_fails_log_read_error(
|
||||
self,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
mock_glob: MagicMock,
|
||||
) -> None:
|
||||
"""Should handle log read errors gracefully."""
|
||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||
with patch("builtins.open", side_effect=OSError("permission denied")):
|
||||
assert start_docker_daemon(timeout=2) is False
|
||||
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready")
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
@patch("devx.molecule.start_docker.subprocess.Popen")
|
||||
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
||||
def test_local_daemon_ready_on_first_check(
|
||||
self,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
mock_glob: MagicMock,
|
||||
) -> None:
|
||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||
# Host fails, rootless doesn't exist, local ready on first loop check
|
||||
mock_ready.side_effect = [False, False, True]
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
mock_popen.assert_called_once()
|
||||
mock_sleep.assert_called_once_with(1)
|
||||
assert mock_popen.call_count == 1
|
||||
assert mock_sleep.call_count == 1
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker.os.environ")
|
||||
@@ -181,3 +255,24 @@ class TestMain:
|
||||
result = runner.invoke(main, ["--timeout", "60"])
|
||||
assert result.exit_code == 0
|
||||
mock_start.assert_called_once_with(60)
|
||||
|
||||
@patch("devx.molecule.start_docker.os.environ.get")
|
||||
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
|
||||
def test_exports_github_env(self, mock_start: MagicMock, mock_get: MagicMock) -> None:
|
||||
"""Should write DOCKER_HOST to GITHUB_ENV when available."""
|
||||
mock_get.side_effect = lambda key, default="": (
|
||||
"/tmp/github_env" if key == "GITHUB_ENV" else f"unix://{DOCKER_SOCK}" if key == "DOCKER_HOST" else default
|
||||
)
|
||||
with patch("builtins.open", mock_open()) as mock_file:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
mock_file.assert_called_with("/tmp/github_env", "a")
|
||||
|
||||
@patch("devx.molecule.start_docker.os.environ.get", return_value="")
|
||||
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
|
||||
def test_no_github_env(self, mock_start: MagicMock, mock_get: MagicMock) -> None:
|
||||
"""Should not crash when GITHUB_ENV is not set."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
|
||||
Reference in New Issue
Block a user