Compare commits

...
31 Commits
Author SHA1 Message Date
devx-ci-bot a14d838564 release: v0.10.2 [skip ci] 2026-06-24 20:56:35 +00:00
emil 7dcb9c03c0 DEVX-42: fix: badge generation respects pyproject.toml testpaths, shows stdout in warnings
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Successful in 6s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / release (push) Successful in 45s
Post-merge / vikunja (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 51s
Post-merge / badges (push) Successful in 1m5s
2026-06-24 20:55:35 +00:00
gitea-actions-bot 6f2b110c17 chore: update badge URLs to commit 55e25b06 [skip ci] 2026-06-24 20:34:59 +00:00
devx-ci-bot cfb856ff75 release: v0.10.1 [skip ci] 2026-06-24 20:34:06 +00:00
emil 95adf86895 DEVX-41: fix: badge generation REPO_ROOT, auto-detect package, error feedback
Post-merge / detect-type (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / release (push) Successful in 45s
Post-merge / vikunja (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 40s
Post-merge / badges (push) Successful in 48s
2026-06-24 20:33:12 +00:00
gitea-actions-bot 7cf039ebbe chore: update badge URLs to commit 16bc9c20 [skip ci] 2026-06-24 22:04:42 +02:00
devx-ci-bot a17982f2cf release: v0.10.0 [skip ci] 2026-06-24 22:03:14 +02:00
emil cf85964877 DEVX-40: feat: remove .taskid file fallback, use branch name only
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Successful in 39s
Post-merge / vikunja (push) Successful in 7s
Post-merge / sync-wiki (push) Successful in 56s
Post-merge / badges (push) Successful in 1m25s
2026-06-24 20:02:19 +00:00
gitea-actions-bot fbb1fc3134 chore: update badge URLs to commit ffc2c8a9 [skip ci] 2026-06-24 19:27:17 +00:00
emil a8f86aca68 DEVX-39: fix: use raw/branch/badges/ URLs for badges in README and docs
Post-merge / detect-type (push) Successful in 6s
Post-merge / configure-repo (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 13s
Post-merge / release (push) Successful in 34s
Post-merge / vikunja (push) Successful in 7s
Post-merge / sync-wiki (push) Successful in 39s
Post-merge / badges (push) Successful in 40s
2026-06-24 19:25:50 +00:00
gitea-actions-bot 13bed1d99c chore: update badge URLs to commit 329cfc69 [skip ci] 2026-06-24 19:03:43 +00:00
devx-ci-bot 107cff5dec release: v0.9.12 [skip ci] 2026-06-24 19:02:56 +00:00
emil cb037aa69c DEVX-38: fix: clean dist/ before build and add workflow_dispatch to publish
Post-merge / detect-type (push) Successful in 8s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 15s
Post-merge / release (push) Successful in 46s
Post-merge / vikunja (push) Successful in 14s
Post-merge / badges (push) Successful in 42s
Post-merge / sync-wiki (push) Successful in 48s
2026-06-24 19:02:02 +00:00
gitea-actions-bot 7fa1c4450c chore: update badge URLs to commit 85d87b6f [skip ci] 2026-06-24 20:51:42 +02:00
devx-ci-bot cf2921845b release: v0.9.11 [skip ci] 2026-06-24 20:50:37 +02:00
emil c272150275 DEVX-37: fix: resolve repo_root from GITHUB_WORKSPACE or cwd
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / release (push) Successful in 40s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / vikunja (push) Successful in 14s
Post-merge / sync-wiki (push) Successful in 43s
Post-merge / badges (push) Successful in 59s
2026-06-24 18:49:43 +00:00
gitea-actions-bot 8de91be405 chore: update badge URLs to commit 5b6b6674 [skip ci] 2026-06-24 20:37:25 +02:00
emil 46b8fe5078 DEVX-36: docs: comprehensive documentation rewrite
Post-merge / detect-type (push) Successful in 17s
Post-merge / configure-repo (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 13s
Post-merge / release (push) Successful in 36s
Post-merge / vikunja (push) Successful in 14s
Post-merge / sync-wiki (push) Successful in 58s
Post-merge / badges (push) Successful in 1m0s
2026-06-24 18:35:24 +00:00
gitea-actions-bot 7e2a8b4535 chore: update badge URLs to commit f54c01f9 [skip ci] 2026-06-24 17:25:38 +00:00
emil c90518acdb DEVX-35: fix: use raw/branch/badges/ URLs for badges in README and docs
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / configure-repo (push) Successful in 19s
Post-merge / release (push) Successful in 34s
Post-merge / vikunja (push) Successful in 14s
Post-merge / sync-wiki (push) Successful in 35s
Post-merge / badges (push) Successful in 43s
2026-06-24 17:24:05 +00:00
devx-ci-bot 80ca622838 release: v0.9.10 [skip ci] 2026-06-24 18:42:23 +02:00
emil 3f2d19d7ac DEVX-34: fix: retrospective fixes for CI/CD friction
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 25s
Post-merge / release (push) Successful in 50s
Post-merge / vikunja (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 41s
Post-merge / badges (push) Successful in 40s
2026-06-24 16:41:22 +00:00
devx-ci-bot c839d49fe3 release: v0.9.9 [skip ci] 2026-06-24 13:17:30 +02:00
emil 93b5d2f926 DEVX-33: fix: use explicit refspecs for git push to avoid tag/branch ambiguity
Post-merge / detect-type (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / release (push) Successful in 39s
Post-merge / vikunja (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 39s
Post-merge / badges (push) Successful in 41s
2026-06-24 11:16:41 +00:00
emil 131c04c9d0 DEVX-32: fix: filter non-version tags in release verification
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Successful in 12s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Failing after 37s
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 40s
2026-06-24 11:09:47 +00:00
emil 8e9681cf7d DEVX-31: fix: prefer branch name for task ID extraction + strip heads/ prefix in release
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 14s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Failing after 29s
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 37s
2026-06-24 10:54:58 +00:00
emil 6631525a1d DEVX-30: fix: use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets
Post-merge / detect-type (push) Successful in 6s
Post-merge / validate-commit-msg (push) Successful in 6s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / release (push) Failing after 35s
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 39s
2026-06-24 10:35:32 +00:00
devx-ci-bot 6985030a3c release: v0.9.8 [skip ci] 2026-06-24 11:15:20 +02:00
emil 4d073f3beb DEVX-29: fix: use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets
Post-merge / detect-type (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 19s
Post-merge / release (push) Successful in 42s
Post-merge / vikunja (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 42s
Post-merge / badges (push) Successful in 41s
2026-06-24 09:14:27 +00:00
devx-ci-bot 037d7b0d16 release: v0.9.7 [skip ci] 2026-06-24 02:18:19 +00:00
emil 9cb706e387 DEVX-28: fix: add rootless socket fallback and GITHUB_ENV export
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Successful in 45s
Post-merge / vikunja (push) Successful in 7s
Post-merge / sync-wiki (push) Successful in 41s
Post-merge / badges (push) Successful in 42s
2026-06-24 02:17:20 +00:00
32 changed files with 2833 additions and 436 deletions
+2 -2
View File
@@ -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
+7 -1
View File
@@ -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:
+3
View File
@@ -35,3 +35,6 @@ Thumbs.db
# Badges
.badges/
# Deprecated CI task tracking (branch name is the sole source of truth)
.taskid
-1
View File
@@ -1 +0,0 @@
DEVX-27
+30
View File
@@ -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 |
+62
View File
@@ -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
+323 -24
View File
@@ -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).
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/badges/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/badges/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/badges/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/badges/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/badges/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/python.svg)](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
View File
@@ -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).
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/python.svg)](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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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,
)
)
-2
View File
@@ -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",
]
+7
View File
@@ -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,
+12 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+15 -1
View File
@@ -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)
+37 -5
View File
@@ -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)
+13
View File
@@ -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
+231 -60
View File
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
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']})")
+21 -7
View File
@@ -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 — 分支名称是唯一的真实来源。"
}
}
+18 -18
View File
@@ -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 = [
+1 -1
View File
@@ -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"
+34
View File
@@ -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
View File
@@ -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:
+15
View File
@@ -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(
+15 -2
View File
@@ -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")
+16
View File
@@ -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
+11 -2
View File
@@ -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
View File
@@ -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