Compare commits

..
60 Commits
Author SHA1 Message Date
devx-ci-bot 54f687f1bf release: v0.15.0
Publish Release / publish (push) Failing after 15s
2026-06-26 16:30:45 +02:00
emil 44c906a5e6 DEVX-60: feat: add create-task, create-pr, pre-push-check tools and devx.mak fragment 2026-06-26 14:29:47 +00:00
gitea-actions-bot a3d528f802 chore: update badge URLs to commit 6d2607dd [skip ci] 2026-06-26 02:20:46 +02:00
gitea-actions-bot e3a7afc0b0 chore: update badge URLs to commit a1b92efe [skip ci] 2026-06-26 02:20:43 +02:00
devx-ci-bot 700d3b55c6 release: v0.14.2
Publish Release / publish (push) Failing after 15s
2026-06-26 00:19:13 +00:00
emil 701363d935 DEVX-59: fix: make repo arg optional in publish CLI, auto-detect from GITHUB_REPOSITORY 2026-06-26 00:17:55 +00:00
gitea-actions-bot ddb2d43b4e chore: update badge URLs to commit 3fb76ae0 [skip ci] 2026-06-26 01:34:46 +02:00
gitea-actions-bot fe6373b682 chore: update badge URLs to commit 27c68d16 [skip ci] 2026-06-25 23:34:31 +00:00
devx-ci-bot 33434d5750 release: v0.14.1
Publish Release / publish (push) Failing after 14s
2026-06-26 01:33:23 +02:00
emil dfcd33c35b DEVX-58: fix: handle 'already a release' error idempotently in publish 2026-06-25 23:32:28 +00:00
gitea-actions-bot 0aefe1f028 chore: update badge URLs to commit f3c14a15 [skip ci] 2026-06-25 23:24:58 +00:00
gitea-actions-bot 891b0b5dba chore: update badge URLs to commit e89db952 [skip ci] 2026-06-25 23:24:49 +00:00
devx-ci-bot 8f15e5402b release: v0.14.0
Publish Release / publish (push) Failing after 15s
2026-06-26 01:23:45 +02:00
emil 9060cd7b1e DEVX-57: feat: add FORCE_DEPLOY env var, --git flag, --from-tag flag 2026-06-25 23:22:41 +00:00
gitea-actions-bot f687ab5aa3 chore: update badge URLs to commit c31f8a46 [skip ci] 2026-06-26 00:59:13 +02:00
gitea-actions-bot 4738b594b2 chore: update badge URLs to commit a398ba43 [skip ci] 2026-06-25 22:58:46 +00:00
devx-ci-bot f6e9f2013b release: v0.13.0
Publish Release / publish (push) Failing after 14s
2026-06-25 22:57:37 +00:00
emil 8450f33e88 DEVX-56: feat: add --force flag to classify_changes, fix api_clients coverage 2026-06-25 22:56:31 +00:00
gitea-actions-bot 904812dfae chore: update badge URLs to commit 525d3b70 [skip ci] 2026-06-26 00:11:59 +02:00
emil 95384c26e1 DEVX-55: fix: revert squash-merge format to use colon after task ID 2026-06-25 22:09:46 +00:00
gitea-actions-bot c10b759f6b chore: update badge URLs to commit 47d60f8b [skip ci] 2026-06-25 21:55:13 +00:00
gitea-actions-bot 4c818b32ce chore: update badge URLs to commit 9c9fe0f1 [skip ci] 2026-06-25 23:54:43 +02:00
devx-ci-bot bbf09c07df release: v0.1.0 2026-06-25 21:53:29 +00:00
emil faff67aa6a DEVX-54: fix: squash-merge format uses space not colon after task ID 2026-06-25 21:51:36 +00:00
gitea-actions-bot 3e4dfcadb7 chore: update badge URLs to commit 1c7678eb [skip ci] 2026-06-25 23:41:29 +02:00
gitea-actions-bot 2385747bed chore: update badge URLs to commit cb9e5b47 [skip ci] 2026-06-25 23:41:22 +02:00
devx-ci-bot 3625bf2872 release: v0.12.5
Publish Release / publish (push) Failing after 15s
2026-06-25 23:40:08 +02:00
emil 8affccfa35 DEVX-53: fix: make PyPI publish failures non-fatal 2026-06-25 21:39:12 +00:00
gitea-actions-bot 7ae85b6955 chore: update badge URLs to commit e5e2b54b [skip ci] 2026-06-25 23:28:38 +02:00
gitea-actions-bot f702286779 chore: update badge URLs to commit 0ed104f4 [skip ci] 2026-06-25 21:28:25 +00:00
devx-ci-bot d4b58fa86f release: v0.12.4
Publish Release / publish (push) Failing after 16s
2026-06-25 23:27:13 +02:00
emil 44c6c42ede DEVX-52: fix: guarantee Gitea release for every tag 2026-06-25 21:26:13 +00:00
gitea-actions-bot 14c585971d chore: update badge URLs to commit dfe5ee1e [skip ci] 2026-06-25 23:04:02 +02:00
emil 3e2342c347 DEVX-51: fix: pass REPO_TOKEN to setup-release so tea login is configured 2026-06-25 21:02:00 +00:00
gitea-actions-bot f3d5b0ff45 chore: update badge URLs to commit d5507246 [skip ci] 2026-06-25 20:56:25 +00:00
devx-ci-bot 75e36897cc release: v0.12.3 [skip ci] 2026-06-25 20:55:23 +00:00
emil 0eef69a902 DEVX-50: refactor: remove JUnit reporting from devx 2026-06-25 20:54:21 +00:00
gitea-actions-bot f9130884d1 chore: update badge URLs to commit 7341990c [skip ci] 2026-06-25 19:32:07 +00:00
devx-ci-bot ba002c2e72 release: v0.12.2 [skip ci] 2026-06-25 21:30:48 +02:00
emil af610d22ec DEVX-49: fix: remove auto-rebase from auto-merge to prevent CI feedback loop 2026-06-25 19:29:45 +00:00
gitea-actions-bot 69a585db2f chore: update badge URLs to commit 1b6a8d99 [skip ci] 2026-06-25 19:14:36 +02:00
devx-ci-bot 700df828ba release: v0.12.1 [skip ci] 2026-06-25 19:13:16 +02:00
emil 4df0602157 DEVX-48: fix: use heredoc syntax for multi-line $GITHUB_ENV values 2026-06-25 17:12:20 +00:00
gitea-actions-bot d7d90fe165 chore: update badge URLs to commit 9a9a53ef [skip ci] 2026-06-25 01:06:19 +02:00
devx-ci-bot 22c2d7c925 release: v0.12.0 [skip ci] 2026-06-24 23:05:23 +00:00
emil b4350751f1 DEVX-47: feat: add Polish as officially supported language 2026-06-24 23:04:20 +00:00
gitea-actions-bot a987b63da7 chore: update badge URLs to commit 85b1ae90 [skip ci] 2026-06-24 22:50:19 +00:00
emil 82d613e23b DEVX-46: docs: add pyproject.toml dependency and pip.conf instructions for devx 2026-06-24 22:48:06 +00:00
gitea-actions-bot 6b6c9d40f1 chore: update badge URLs to commit 90c2559f [skip ci] 2026-06-25 00:35:25 +02:00
emil d17854296a DEVX-45: docs: remove stale .taskid file fallback references 2026-06-24 22:33:35 +00:00
gitea-actions-bot 2fff7ed271 chore: update badge URLs to commit c2065ac1 [skip ci] 2026-06-25 00:28:01 +02:00
devx-ci-bot 0a4d66ab5c release: v0.11.1 [skip ci] 2026-06-25 00:25:51 +02:00
emil 14c9200179 DEVX-44: fix: add build/twine to ci deps, activate venv in notify_failure 2026-06-24 22:24:33 +00:00
gitea-actions-bot 85ae272b1f chore: update badge URLs to commit 389da217 [skip ci] 2026-06-24 22:18:25 +00:00
devx-ci-bot a0c4c1c7f0 release: v0.11.0 [skip ci] 2026-06-24 22:17:36 +00:00
emil fd0c4de31e DEVX-43: feat: add publish step to post-merge release job, make publish idempotent 2026-06-24 22:16:37 +00:00
gitea-actions-bot 8f7af97335 chore: update badge URLs to commit 0bb83e89 [skip ci] 2026-06-24 22:57:46 +02:00
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 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
54 changed files with 2651 additions and 724 deletions
+1 -2
View File
@@ -120,8 +120,7 @@ jobs:
auto-merge:
# Auto-merge runs after all CI checks pass. It reads the task ID
# from the branch name (falling back to .taskid file), validates
# the PR title, and squash-merges.
# from the branch name, validates the PR title, and squash-merges.
needs: [quality, detect-changes, pr-review]
if: github.event_name == 'pull_request'
runs-on: docker
+18 -1
View File
@@ -75,13 +75,15 @@ jobs:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
timeout-minutes: 10
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.REPO_TOKEN }}
- name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-release
- name: Configure git
run: |
@@ -94,12 +96,27 @@ jobs:
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release
- name: Publish release
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -z "$TAG" ]; then
echo "No tag found — skipping publish"
exit 0
fi
echo "Publishing release $TAG (idempotent — skips if already published)..."
python3 -m devx.ci.publish "$TAG" "${{ github.repository }}"
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.tools.install_tools --tool tea
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
+5 -6
View File
@@ -66,9 +66,8 @@ src/devx/
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
│ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures)
│ ├── notify_failure.py # Create Gitea issues on CI failures (--auto-login)
│ ├── merge_junit.py # Merge JUnit XML reports from parallel runners
│ ├── distribute_files.py # Distribute files across parallel runners
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast + JUnit output
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
│ ├── check_translations.py # Translation completeness check
│ └── doc_coverage.py # Documentation coverage check
├── tools/ # Developer tooling modules (run locally or by CI)
@@ -81,7 +80,7 @@ src/devx/
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
├── discover_runners.py # Dynamic Gitea runner discovery
├── distribute_molecule.py # Distribute molecule scenarios across runners (--roles-root for multi-role)
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast + JUnit output (--roles-root, --junit-output)
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast (--roles-root)
├── molecule_all.py # Run all molecule scenarios locally
└── platforms.py # Supported molecule platforms
```
@@ -163,7 +162,7 @@ the PR. Then add the `ready-to-merge` label. The auto-merge workflow will:
1. **Validate** PR title format (`DEVX-N: <vikunja task title>`) and match against Vikunja task title
2. **Check** that at least one substantive APPROVE review exists
3. Wait for all CI checks to pass (including the `pr-review` job)
4. Squash-merge with title: `DEVX-N <conventional commit message>` (space-separated, no colon after DEVX-N)
4. Squash-merge with title: `DEVX-N: <conventional commit message>`
5. The post-merge workflow marks the Vikunja task as done
6. The release workflow automatically versions, tags, and publishes
@@ -260,7 +259,7 @@ by `python -m devx.tools.install_tools` and configured by
### git-cliff Commit Preprocessing
Merge commits on master have the format `DEVX-N <conventional commit>`. The
Merge commits on master have the format `DEVX-N: <conventional commit>`. The
`cliff.toml` includes a `commit_preprocessors` entry that strips the `DEVX-N `
prefix before parsing. This ensures all merged work appears in the changelog.
@@ -283,7 +282,7 @@ setuptools via `dynamic = ["version"]` in `pyproject.toml`.
| Branch name | `DEVX-N-short-description` | `DEVX-12-add-release-script` |
| Branch commits | `<conventional commit>` | `feat: add release script` |
| PR title | `DEVX-N: <vikunja task title>` | `DEVX-12: Add release automation` |
| Merge commit | `DEVX-N <conventional commit>` | `DEVX-12 feat: add release script` |
| Merge commit | `DEVX-N: <conventional commit>` | `DEVX-12: feat: add release script` |
### Task ID Resolution
+92
View File
@@ -2,6 +2,98 @@
All notable changes to this project will be documented in this file.
## [0.15.0] - 2026-06-26
### Features
- Add create-task, create-pr, pre-push-check tools and devx.mak fragment
## [0.14.2] - 2026-06-26
### Bug Fixes
- Make repo arg optional in publish CLI, auto-detect from GITHUB_REPOSITORY
## [0.14.1] - 2026-06-25
### Bug Fixes
- Handle 'already a release' error idempotently in publish
## [0.14.0] - 2026-06-25
### Features
- Add FORCE_DEPLOY env var, --git flag, --from-tag flag
## [0.13.0] - 2026-06-25
### Features
- Add --force flag to classify_changes, fix api_clients coverage
### Bug Fixes
- Squash-merge format uses space not colon after task ID
- Revert squash-merge format to use colon after task ID
## [0.1.0] - 2026-06-25
## [0.12.5] - 2026-06-25
### Bug Fixes
- Make PyPI publish failures non-fatal
## [0.12.4] - 2026-06-25
### Bug Fixes
- Pass REPO_TOKEN to setup-release so tea login is configured
- Guarantee Gitea release for every tag
## [0.12.3] - 2026-06-25
### Refactor
- Remove JUnit reporting from devx
## [0.12.2] - 2026-06-25
### Bug Fixes
- Remove auto-rebase from auto-merge to prevent CI feedback loop
## [0.12.1] - 2026-06-25
### Bug Fixes
- Use heredoc syntax for multi-line $GITHUB_ENV values
## [0.12.0] - 2026-06-24
### Features
- Add Polish as officially supported language
## [0.11.1] - 2026-06-24
### Bug Fixes
- Add build/twine to ci deps, activate venv in notify_failure
## [0.11.0] - 2026-06-24
### Features
- Add publish step to post-merge release job, make publish idempotent
## [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
+50 -21
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![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/16bc9c200c1f0a7b28ff6114f2aa58af551fd155/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/16bc9c200c1f0a7b28ff6114f2aa58af551fd155/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/16bc9c200c1f0a7b28ff6114f2aa58af551fd155/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/16bc9c200c1f0a7b28ff6114f2aa58af551fd155/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/16bc9c200c1f0a7b28ff6114f2aa58af551fd155/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/16bc9c200c1f0a7b28ff6114f2aa58af551fd155/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/6d2607ddb41f5a0c0fa4d73bbd3709503195e386/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/6d2607ddb41f5a0c0fa4d73bbd3709503195e386/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/6d2607ddb41f5a0c0fa4d73bbd3709503195e386/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/6d2607ddb41f5a0c0fa4d73bbd3709503195e386/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/6d2607ddb41f5a0c0fa4d73bbd3709503195e386/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/6d2607ddb41f5a0c0fa4d73bbd3709503195e386/python.svg)](https://www.python.org/downloads/)
## Why devx?
@@ -55,28 +55,61 @@ up by bumping their devx dependency.
- **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.
Chinese, and Polish; projects can extend with their own keys.
## Installation
Install from the Gitea PyPI registry:
devx is published to the Gitea PyPI registry at
`https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple`.
The registry is publicly readable — no authentication required to install.
### Quick install (one-off)
```bash
pip install devx --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
Or add the registry to your `pip.conf` / `pyproject.toml` and install normally:
### Persistent configuration (recommended)
Add the registry to `~/.pip/pip.conf` so `pip install devx` works without
specifying `--index-url` every time:
```ini
[global]
extra-index-url = https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
### As a dependency in another project
To use devx as a dependency in your `pyproject.toml`, add the registry as an
extra index and list devx in your dependencies:
```toml
[project]
dependencies = [
"devx>=0.11.1",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Then install normally:
```bash
pip install devx
pip install -e .
```
> **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (e.g., `"devx==0.11.1"`) or use a version constraint
> (e.g., `"devx>=0.11.1,<0.12"`).
### Optional extras
devx ships optional dependency groups for different use cases:
```bash
pip install "devx[ci,lint]" # CI runners and linting (pytest, ruff, pyright, bandit)
pip install "devx[ci,lint]" # CI runners and linting (pytest, ruff, pyright, bandit, build, twine)
pip install "devx[molecule]" # Molecule testing for Ansible projects
pip install "devx[dev]" # Full local development (ci + lint + build + twine)
```
@@ -142,11 +175,8 @@ python -m devx.ci.discover_runners --owner oblachno-oss --repo devx --indices
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
# Run pytest with cross-runner fail-fast
python -m devx.ci.integration_guard -- test_a.py test_b.py
```
### Developer tools
@@ -194,7 +224,7 @@ python -m devx.molecule.distribute_molecule --list # list all scena
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 pair1 pair2
python -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2
# Run all molecule scenarios locally (sequential)
@@ -241,8 +271,7 @@ devx --version
| `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 integration-guard` | Run pytest with cross-runner fail-fast |
| `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 |
@@ -291,7 +320,7 @@ The config system loads `.env` automatically via `python-dotenv`.
| `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_LANG` | `en` | Language for i18n (en, bg, de, ru, zh, pl) |
| `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) |
@@ -398,7 +427,7 @@ src/devx/
├── 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)
├── translations.json # Translation strings (en, bg, de, ru, zh, pl)
├── 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)
+44 -13
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![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/16bc9c200c1f0a7b28ff6114f2aa58af551fd155/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/16bc9c200c1f0a7b28ff6114f2aa58af551fd155/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/16bc9c200c1f0a7b28ff6114f2aa58af551fd155/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/16bc9c200c1f0a7b28ff6114f2aa58af551fd155/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/16bc9c200c1f0a7b28ff6114f2aa58af551fd155/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/16bc9c200c1f0a7b28ff6114f2aa58af551fd155/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/6d2607ddb41f5a0c0fa4d73bbd3709503195e386/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/6d2607ddb41f5a0c0fa4d73bbd3709503195e386/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/6d2607ddb41f5a0c0fa4d73bbd3709503195e386/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/6d2607ddb41f5a0c0fa4d73bbd3709503195e386/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/6d2607ddb41f5a0c0fa4d73bbd3709503195e386/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/6d2607ddb41f5a0c0fa4d73bbd3709503195e386/python.svg)](https://www.python.org/downloads/)
## Overview
@@ -42,22 +42,53 @@ via environment variables and `pyproject.toml`, and inherit:
- **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.
Chinese, and Polish; projects can extend with their own keys.
## Installation
Install from the Gitea PyPI registry:
devx is published to the Gitea PyPI registry at
`https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple`.
The registry is publicly readable — no authentication required to install.
### Quick install (one-off)
```bash
pip install devx --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
Optional extras:
### Persistent configuration (recommended)
Add the registry to `~/.pip/pip.conf`:
```ini
[global]
extra-index-url = https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
Then `pip install devx` works without specifying `--index-url`.
### As a dependency in another project
Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml
[project]
dependencies = [
"devx>=0.11.1",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Pin a specific version if needed: `"devx==0.11.1"` or `"devx>=0.11.1,<0.12"`.
### Optional extras
```bash
pip install "devx[ci,lint]" # CI runners and linting
pip install "devx[ci,lint]" # CI runners and linting (pytest, ruff, pyright, bandit, build, twine)
pip install "devx[molecule]" # Molecule testing for Ansible projects
pip install "devx[dev]" # Full local development
pip install "devx[dev]" # Full local development (ci + lint + build + twine)
```
## Architecture
@@ -69,7 +100,7 @@ devx is a self-contained Python package under `src/devx/`:
- **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
discover_runners, distribute_files, 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
@@ -117,7 +148,7 @@ fallback. Key variables:
| `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) |
| `DEVX_LANG` | `en` | Language for i18n (en, bg, de, ru, zh, pl) |
| `REPO_TOKEN` | — | Gitea API token |
| `VIKUNJA_TOKEN` | — | Vikunja API token |
+5 -14
View File
@@ -16,7 +16,7 @@ src/devx/
├── 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)
├── translations.json # Translation strings (en, bg, de, ru, zh, pl)
├── ci/ # CI/CD automation modules (run by workflows)
│ ├── __init__.py
│ ├── _shared.py # Shared utilities (get_latest_tag)
@@ -31,9 +31,8 @@ src/devx/
│ ├── 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
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
│ ├── discover_runners.py # Dynamic Gitea runner discovery
│ ├── check_translations.py # Translation completeness check
│ └── doc_coverage.py # Documentation coverage check
@@ -188,7 +187,7 @@ 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
name, 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>`.
@@ -296,18 +295,11 @@ 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`)
@@ -382,9 +374,8 @@ platforms.
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 subprocess is killed and this runner exits early. Supports both
single-role (4-part) and multi-role (5-part) pair encoding.
### `molecule_all.py`
+5 -19
View File
@@ -93,8 +93,7 @@ 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:
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
1. **Read task ID** from branch name (e.g., `DEVX-12-fix-foo` → `DEVX-12`)
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
@@ -330,8 +329,7 @@ On failure, the `notify_failure` step creates a Gitea issue.
### `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
(e.g., `DEVX-12-fix-foo` → `DEVX-12`). 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.
@@ -449,12 +447,10 @@ 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.
failures. Aborts early if another runner fails the same job.
```bash
python -m devx.molecule.molecule_ci_guard [--roles-root <dir>] \
[--junit-output <file>] pair1 pair2 ...
python -m devx.molecule.molecule_ci_guard [--roles-root <dir>] pair1 pair2 ...
```
### `validate_commit_msg.py`
@@ -505,16 +501,6 @@ 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
@@ -522,7 +508,7 @@ 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>
python -m devx.ci.integration_guard -- <pytest args>
```
## Release process summary
+6 -18
View File
@@ -18,7 +18,7 @@ devx molecule --help # show molecule commands
### `devx ci auto-merge`
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
name, 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.
@@ -40,8 +40,8 @@ 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)
- **Missing languages** — a key exists but is missing one of the six
supported languages (en, bg, de, ru, zh, pl)
```bash
devx ci check-translations
@@ -137,13 +137,13 @@ Options:
### `devx ci integration-guard`
Run pytest with cross-runner failure detection and JUnit XML output. If any
Run 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 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
devx ci integration-guard -- test_a.py test_b.py
devx ci integration-guard -- -x -v --tb=short test_a.py
```
Environment variables:
@@ -154,16 +154,6 @@ Environment variables:
- `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. 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. Uses the tea CLI for issue
@@ -461,7 +451,6 @@ 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:
@@ -470,7 +459,6 @@ Each pair is encoded as:
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
+3 -1
View File
@@ -30,6 +30,8 @@ version = {attr = "devx.__version__"}
ci = [
"pytest>=9.1.0",
"pytest-cov>=7.1.0",
"build>=1.5.0",
"twine>=6.2.0",
]
# Lint and type-checking tools (quality job)
lint = [
@@ -57,7 +59,7 @@ dev = [
where = ["src"]
[tool.setuptools.package-data]
devx = ["translations.json"]
devx = ["translations.json", "make/*.mak"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.10.1"
__version__ = "0.15.0"
+64
View File
@@ -192,6 +192,32 @@ class GiteaClient:
r = self._request("GET", f"/pulls/{pr_number}")
return r.json()
def create_pr(self, title: str, head: str, base: str = "master", body: str = "") -> dict[str, Any]:
"""Create a pull request and return the PR dict.
Args:
title: PR title.
head: Head branch name.
base: Base branch name (default: master).
body: PR description (markdown).
"""
payload: dict[str, Any] = {"title": title, "head": head, "base": base}
if body:
payload["body"] = body
r = self._request("POST", "/pulls", json=payload)
return r.json()
def list_prs(self, state: str = "all", **params: Any) -> list[dict[str, Any]]:
"""List pull requests, optionally filtered by state.
Args:
state: ``open``, ``closed``, ``all`` (default).
**params: Additional query params (e.g. ``q="keyword"`` for title search).
"""
params.setdefault("state", state)
r = self._request("GET", "/pulls", params=params)
return r.json()
def get_pr_files(self, pr_number: str | int) -> list[dict[str, Any]]:
"""Fetch the list of files changed in a pull request."""
r = self._request("GET", f"/pulls/{pr_number}/files")
@@ -342,8 +368,46 @@ class VikunjaClient:
r = self._request("GET", f"/projects/{project_id}/tasks", params=params)
return r.json()
def create_task(self, project_id: int, title: str, description: str = "") -> dict[str, Any]:
"""Create a task in a project and return the created task dict.
Args:
project_id: Target Vikunja project ID.
title: Task title (required, non-empty).
description: Task description (HTML supported, optional).
"""
r = self._request(
"PUT",
f"/projects/{project_id}/tasks",
json={"title": title, "description": description},
)
return r.json()
def post_comment(self, task_id: int, comment: str) -> None:
self._request("PUT", f"/tasks/{task_id}/comments", json={"comment": comment})
def list_comments(self, task_id: int) -> list[dict[str, Any]]:
"""List all comments on a task."""
r = self._request("GET", f"/tasks/{task_id}/comments")
return r.json()
def update_task(self, task_id: int, **fields: Any) -> None:
"""Update task fields via POST (full replacement semantics).
Warning: Vikunja's POST /tasks/{id} replaces the entire task body.
Unspecified fields are reset to their type defaults. Use
``update_task_safe`` to preserve existing fields.
"""
self._request("POST", f"/tasks/{task_id}", json=fields)
def update_task_safe(self, task_id: int, **fields: Any) -> dict[str, Any]:
"""Safely update task fields using read-merge-write pattern.
Fetches the full task body, merges the provided fields on top,
and POSTs the complete body back. This prevents accidental
resets of done status, title, etc.
"""
task = self.get_task(task_id)
task.update(fields)
r = self._request("POST", f"/tasks/{task_id}", json=task)
return r.json()
+11 -17
View File
@@ -237,23 +237,17 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
client.merge_pr(pr_num, merge_title)
except APIError as e:
if e.status == 405 and "behind" in e.message.lower():
# Head branch is behind master — pull master and rebase, then retry
click.echo(_("Head branch is behind master. Pulling and rebasing..."))
try:
run_cmd(["git", "config", "user.name", "devx-ci-bot"])
run_cmd(["git", "config", "user.email", "devx-ci-bot@oblachno.fyi"])
run_cmd(["git", "fetch", "origin", "master"])
run_cmd(["git", "rebase", "origin/master"])
run_cmd(["git", "push", "--force-with-lease", "origin", f"HEAD:{branch}"])
click.echo(_("Rebased and pushed. Retrying merge..."))
client.merge_pr(pr_num, merge_title)
except (APIError, Exception) as retry_err:
raise click.ClickException(
_(
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
error=str(retry_err),
)
) from None
# Head branch is behind master — do NOT auto-rebase.
# Auto-rebasing creates a feedback loop: the force-push triggers
# a new pull_request synchronize event, which starts a new CI run,
# which runs auto-merge again, which rebases again, etc.
raise click.ClickException(
_(
"Branch is behind master. Rebase manually:\n"
" git fetch origin master && git rebase origin/master && git push --force-with-lease\n"
"Then re-add the ready-to-merge label.",
)
) from None
else:
raise click.ClickException(
_(
+6 -6
View File
@@ -11,8 +11,8 @@ Checks performed (all fail with exit code 1 on error):
- **Missing keys**: a ``_()`` call in code has no entry in the corresponding
translations file.
- **Dead keys**: a key in a translations file is not used in any code.
- **Missing languages**: a key exists but is missing one of the 5 supported
languages (en, bg, de, ru, zh). This is an error — all supported languages
- **Missing languages**: a key exists but is missing one of the 6 supported
languages (en, bg, de, ru, zh, pl). This is an error — all supported languages
must have translations for every key.
Usage::
@@ -33,7 +33,7 @@ import click
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh")
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh", "pl")
# Default translation set: devx package itself
DEFAULT_TRANS_FILE = REPO_ROOT / "src" / "devx" / "translations.json"
@@ -101,9 +101,9 @@ def collect_keys(src_dir: Path) -> set[str]:
if pyfile.name == "i18n.py":
continue
keys |= extract_keys(pyfile)
# Add dynamic keys for the default source directory
if src_dir == DEFAULT_SRC_DIR:
keys |= DYNAMIC_KEYS
# Dynamic keys are common status strings used via _(variable) that
# can't be detected by AST scanning. Include them for all projects.
keys |= DYNAMIC_KEYS
return keys
+19 -1
View File
@@ -615,11 +615,29 @@ def _write_github_output(key: str, value: str) -> None:
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps). "
"Outputs 'user-facing-changed' and '<tag>-changed' for each configured tag.",
)
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None:
@click.option(
"--force",
is_flag=True,
default=False,
help="Force user-facing-changed=true regardless of actual changes. "
"Used by workflow_dispatch with force-deploy input.",
)
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool, force: bool) -> None:
"""Classify git changes and output results."""
classifier = _get_classifier()
available_tags = list(classifier.config.tags.keys())
# --force can also be activated via FORCE_DEPLOY env var (for workflow_dispatch)
if os.environ.get("FORCE_DEPLOY", "").lower() == "true":
force = True
if force and github_output:
_write_github_output("user-facing-changed", "true")
for tag in available_tags:
_write_github_output(f"{tag}-changed", "true")
click.echo("Forced user-facing-changed=true via --force flag.")
return
if base is None:
base = get_latest_tag()
if not base:
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Detect whether the latest git commit is a release commit.
Release commits have the format ``release: vX.Y.Z [skip ci]``.
Release commits have the format ``release: vX.Y.Z``.
This script writes ``is-release=true`` or ``is-release=false`` to
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
+6 -1
View File
@@ -55,7 +55,12 @@ def _write_github_env(key: str, value: str) -> None:
if not gh_env:
raise click.ClickException("GITHUB_ENV environment variable is not set")
with open(gh_env, "a") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
if "\n" in value:
# Multi-line values require the heredoc syntax in $GITHUB_ENV.
delimiter = "EOF"
f.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
else:
f.write(f"{key}={value}\n")
@click.command()
+1 -13
View File
@@ -6,18 +6,13 @@ Wraps ``pytest`` with the same Gitea API polling mechanism used by
reports failure, the current pytest subprocess is killed and this runner
exits early with code 1.
JUnit XML is generated via pytest's ``--junitxml`` flag (passed through
to the pytest invocation).
Usage::
python3 -m devx.ci.integration_guard \\
--junit-output junit-results/runner-1.xml \\
-- test_file1.py test_file2.py
# With pytest options
python3 -m devx.ci.integration_guard \\
--junit-output junit-results/runner-1.xml \\
-- -x -v --tb=short test_file1.py
Environment variables:
@@ -51,12 +46,7 @@ POLL_INTERVAL = 10
@click.command(context_settings={"ignore_unknown_options": True})
@click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED, required=True)
@click.option(
"--junit-output",
default=None,
help="Path for JUnit XML output (passed to pytest as --junitxml).",
)
def cli(pytest_args: tuple[str, ...], junit_output: str | None) -> None:
def cli(pytest_args: tuple[str, ...]) -> None:
"""Run pytest with cross-runner failure detection."""
gitea_url = os.environ.get("GITEA_URL", "")
token = os.environ.get("REPO_TOKEN", "")
@@ -93,8 +83,6 @@ def cli(pytest_args: tuple[str, ...], junit_output: str | None) -> None:
poller.start()
cmd = [sys.executable, "-m", "pytest"]
if junit_output:
cmd.extend(["--junitxml", junit_output])
cmd.extend(pytest_args)
click.echo(f"Running: {' '.join(cmd)}")
-97
View File
@@ -1,97 +0,0 @@
#!/usr/bin/env python3
"""Merge multiple JUnit XML reports into a single report.
Used by CI workflows to consolidate JUnit XML files produced by
parallel matrix runners into a single merged report for archival
and dashboard consumption.
Usage::
python3 -m devx.ci.merge_junit \\
--pattern "junit-results/runner-*.xml" \\
--output junit-merged.xml
Exit code is non-zero if any merged test suite reports failures,
making this suitable as a CI gating step after matrix jobs.
"""
from __future__ import annotations
import glob
import sys
import xml.etree.ElementTree as ET # nosec B405
import click
from devx.i18n import _
def merge_files(pattern: str) -> tuple[ET.Element, int, int]:
"""Merge JUnit XML files matching *pattern* into a single ``<testsuites>`` element.
Returns ``(merged_element, total_tests, total_failures)``.
If no files match, returns an empty ``<testsuites>`` with zero counts.
"""
files = sorted(glob.glob(pattern))
merged = ET.Element("testsuites")
total_tests = 0
total_failures = 0
for f in files:
tree = ET.parse(f) # nosec B314
suite = tree.getroot()
# Handle both <testsuites> (wrapper) and <testsuite> (single) roots
if suite.tag == "testsuites":
for child in suite:
merged.append(child)
total_tests += int(child.get("tests", 0))
total_failures += int(child.get("failures", 0))
else:
merged.append(suite)
total_tests += int(suite.get("tests", 0))
total_failures += int(suite.get("failures", 0))
merged.set("tests", str(total_tests))
merged.set("failures", str(total_failures))
return merged, total_tests, total_failures
@click.command()
@click.option(
"--pattern",
default="junit-results/runner-*.xml",
show_default=True,
help="Glob pattern for input JUnit XML files.",
)
@click.option(
"--output",
default="junit-merged.xml",
show_default=True,
help="Output path for the merged JUnit XML file.",
)
def main(pattern: str, output: str) -> None:
merged, total_tests, total_failures = merge_files(pattern)
if total_tests == 0:
click.echo(_("No JUnit reports found matching {pattern} — skipping merge.", pattern=pattern))
return
ET.indent(merged)
tree = ET.ElementTree(merged)
tree.write(output, encoding="UTF-8", xml_declaration=True)
click.echo(
_(
"Merged {count} reports: {tests} tests, {failures} failures → {output}",
count=len(glob.glob(pattern)),
tests=total_tests,
failures=total_failures,
output=output,
)
)
if total_failures > 0:
sys.exit(1)
if __name__ == "__main__": # pragma: no cover
main()
+108 -19
View File
@@ -135,13 +135,21 @@ def publish_to_gitea_registry(registry_url: str, token: str) -> None:
check=False,
)
if result.returncode != 0:
raise click.ClickException(
_(
"Oops! Gitea PyPI registry publish failed:\n{stderr}",
stderr=result.stderr.strip(),
# Twine writes errors to stdout (not stderr), so check both.
combined = f"{result.stdout}\n{result.stderr}".strip()
# 409 Conflict means the package version is already published —
# this is not an error, just a sign we're re-running publish.
if "409" in combined or "Conflict" in combined:
click.echo(_("Gitea PyPI registry: {tag} already published — continuing.", tag=""))
else:
raise click.ClickException(
_(
"Oops! Gitea PyPI registry publish failed:\n{stderr}",
stderr=combined,
)
)
)
click.echo(_("Published to Gitea PyPI registry."))
else:
click.echo(_("Published to Gitea PyPI registry."))
def _default_gitea_registry_url() -> str:
@@ -161,9 +169,37 @@ def _default_gitea_registry_url() -> str:
return f"{base}/api/packages/{owner}/pypi"
def get_latest_tag() -> str | None:
"""Get the latest git tag, or None if no tags exist."""
try:
result = subprocess.run( # nosec
["git", "describe", "--tags", "--abbrev=0"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except subprocess.CalledProcessError:
return None
def is_release_commit(tag: str) -> bool:
"""Check if HEAD commit message starts with 'release: <tag>'."""
try:
result = subprocess.run( # nosec
["git", "log", "-1", "--format=%s"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip().startswith(f"release: {tag}")
except subprocess.CalledProcessError:
return False
@click.command()
@click.argument("tag")
@click.argument("repo")
@click.argument("tag", required=False)
@click.argument("repo", required=False)
@click.option(
"--registry-url",
default=None,
@@ -178,7 +214,37 @@ def _default_gitea_registry_url() -> str:
help="Skip package build and PyPI publish (for non-Python repos that only "
"need a Gitea release with git-cliff notes).",
)
def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> None:
@click.option(
"--from-tag",
is_flag=True,
default=False,
help="Auto-detect latest tag and check if HEAD is a release commit. "
"Skips publish if no tag or HEAD is not a release commit for that tag.",
)
def main(
tag: str | None,
repo: str | None,
registry_url: str | None,
skip_build: bool,
from_tag: bool,
) -> None:
if repo is None:
repo = os.environ.get("GITHUB_REPOSITORY", "")
if not repo:
raise click.ClickException(_("REPO argument is required (or set GITHUB_REPOSITORY env var)."))
if from_tag:
detected_tag = get_latest_tag()
if not detected_tag:
click.echo(_("No tag found — skipping publish."))
return
if not is_release_commit(detected_tag):
click.echo(_("HEAD is not a release commit for {tag} — skipping publish.", tag=detected_tag))
return
tag = detected_tag
click.echo(_("Publishing release {tag}...", tag=tag))
if not tag:
raise click.ClickException(_("Tag is required (or use --from-tag)."))
gitea_token = os.environ.get("REPO_TOKEN", "")
if not gitea_token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
@@ -194,28 +260,51 @@ def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> Non
if not skip_build:
build_package()
if pypi_token:
# Standard PyPI flow takes precedence when PYPI_TOKEN is set
publish_to_pypi(pypi_token)
elif registry_url:
# Gitea PyPI registry flow
publish_to_gitea_registry(registry_url, gitea_token)
else:
try:
if pypi_token:
# Standard PyPI flow takes precedence when PYPI_TOKEN is set
publish_to_pypi(pypi_token)
elif registry_url:
# Gitea PyPI registry flow
publish_to_gitea_registry(registry_url, gitea_token)
else:
click.echo(
_(
"PYPI_TOKEN not set and no registry URL configured — "
"skipping PyPI publish. No worries, we'll just create the Gitea release."
)
)
except click.ClickException as e:
click.echo(
_(
"PYPI_TOKEN not set and no registry URL configured — "
"skipping PyPI publish. No worries, we'll just create the Gitea release."
)
"PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}",
error=str(e),
),
err=True,
)
else:
click.echo(_("--skip-build: skipping package build and PyPI publish."))
tea = TeaCLI(repo=repo)
# Check if release already exists (idempotent — avoids failure when
# called multiple times, e.g. by both post-merge and publish workflows)
try:
releases = tea.list_releases(repo)
if any(r.get("tag_name") == tag for r in releases):
click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag))
return
except TeaCLIError:
pass # If listing fails, proceed to create
release_body = generate_release_notes(tag)
try:
tea.create_release(repo, tag=tag, title=tag, body=release_body)
except TeaCLIError as e:
if "already" in str(e).lower() and "release" in str(e).lower():
click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag))
return
raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None
click.echo(
+1 -1
View File
@@ -279,7 +279,7 @@ def commit_release_changes(new_version: str) -> bool:
if status.returncode == 0:
click.echo(_("No staged changes — version and changelog already up to date."))
return False
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version} [skip ci]"])
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version}"])
return True
+31 -4
View File
@@ -14,6 +14,7 @@ task ID format for each project.
import re
import subprocess # nosec B404
import sys
import click
@@ -23,6 +24,17 @@ from devx.i18n import _
MASTER_TASK_ID_RE = re.compile(rf"^{TASK_PREFIX}-\d+:")
def get_latest_commit_msg() -> str:
"""Get the latest commit message from git."""
result = subprocess.run( # nosec
["git", "log", "-1", "--format=%B"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def first_line(text: str) -> str:
return text.split("\n")[0]
@@ -41,11 +53,26 @@ def get_branch() -> str:
@click.command()
@click.argument("commit_msg_file")
@click.argument("commit_msg_file", required=False)
@click.option("--branch", default=None, help="Override branch detection (for CI use).")
def main(commit_msg_file: str, branch: str | None) -> None:
with open(commit_msg_file) as f:
msg = f.read().strip()
@click.option(
"--git",
"from_git",
is_flag=True,
default=False,
help="Read commit message from git log instead of a file.",
)
def main(commit_msg_file: str | None, branch: str | None, from_git: bool) -> None:
if from_git:
msg = get_latest_commit_msg()
elif commit_msg_file:
if commit_msg_file == "-":
msg = sys.stdin.read().strip()
else:
with open(commit_msg_file) as f:
msg = f.read().strip()
else:
raise click.ClickException(_("Provide a commit message file or use --git."))
if branch is None:
branch = get_branch()
+1 -8
View File
@@ -158,17 +158,10 @@ def ci_distribute_files(args: tuple[str, ...]) -> None:
_run_module("devx.ci.distribute_files", list(args))
@ci.command("merge-junit")
@click.argument("args", nargs=-1)
def ci_merge_junit(args: tuple[str, ...]) -> None:
"""Merge multiple JUnit XML reports into a single report."""
_run_module("devx.ci.merge_junit", list(args))
@ci.command("integration-guard")
@click.argument("args", nargs=-1)
def ci_integration_guard(args: tuple[str, ...]) -> None:
"""Run pytest with cross-runner failure detection and JUnit output."""
"""Run pytest with cross-runner failure detection."""
_run_module("devx.ci.integration_guard", list(args))
+2 -2
View File
@@ -1,7 +1,7 @@
"""Simple i18n for devx scripts and tools.
Set DEVX_LANG environment variable to override the default English.
Supported: en, bg, de, ru, zh.
Supported: en, bg, de, ru, zh, pl.
Projects can extend translations by setting DEVX_TRANSLATIONS_PATH to a
JSON file with additional keys. Keys from the project's file are merged
@@ -45,7 +45,7 @@ def _(key: str, **kwargs: object) -> str:
If unset, English is always returned regardless of system locale.
"""
lang = os.getenv("DEVX_LANG", "en")
if lang not in ("en", "bg", "de", "ru", "zh"):
if lang not in ("en", "bg", "de", "ru", "zh", "pl"):
lang = "en"
template = TRANSLATIONS.get(key, {}).get(lang, key)
return template.format(**kwargs)
+56
View File
@@ -0,0 +1,56 @@
# devx.mak — Shared Makefile fragment for devx-integrated projects.
#
# This fragment provides common targets for Vikunja task management,
# PR creation, and pushing. It is designed to be included from a
# project's Makefile after project-specific variables are set.
#
# Usage in your Makefile:
#
# # Set project-specific variables
# DEVX_VIKUNJA_PROJECT_ID := 3
# DEVX_REPO_OWNER := oblachno
# DEVX_REPO_NAME := infra
# DEVX_PYTHON := python3 # or $(BIN)/python, etc.
#
# # Include the devx fragment (silent if devx not installed yet)
# DEVX_MAK := $(shell $(DEVX_PYTHON) -c \
# "from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
# 2>/dev/null)
# -include $(DEVX_MAK)
#
# The fragment uses ?= for all variables so projects can override them
# before the include. If devx is not installed, the -include silently
# skips and the targets are simply unavailable (run 'make setup' first).
#
# Variables:
# DEVX_VIKUNJA_PROJECT_ID — Vikunja project ID (default: 1)
# DEVX_REPO_OWNER — Gitea repository owner (default: empty)
# DEVX_REPO_NAME — Gitea repository name (default: empty)
# DEVX_PYTHON — Python executable (default: python3)
# DEVX_PR_BASE — PR base branch (default: master)
DEVX_VIKUNJA_PROJECT_ID ?= 1
DEVX_REPO_OWNER ?=
DEVX_REPO_NAME ?=
DEVX_PYTHON ?= python3
DEVX_PR_BASE ?= master
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr
# Create a Vikunja task in the configured project
devx-create-task:
@$(DEVX_PYTHON) -m devx.tools.create_task --project-id $(DEVX_VIKUNJA_PROJECT_ID)
# Create a PR with title auto-derived from the Vikunja task
devx-create-pr:
@$(DEVX_PYTHON) -m devx.tools.create_pr \
--owner $(DEVX_REPO_OWNER) \
--repo $(DEVX_REPO_NAME) \
--base $(DEVX_PR_BASE)
# Push current branch to origin
devx-push:
@git push -u origin HEAD
# Push and create PR in one step
devx-push-with-pr: devx-push devx-create-pr
+1 -76
View File
@@ -13,17 +13,12 @@ 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.
JUnit XML is generated when ``--junit-output`` is provided, recording each
pair as a testcase with pass/fail status and elapsed time.
Usage::
# Single-role (grm-style)
python3 -m devx.molecule.molecule_ci_guard pair1 pair2 ...
# Multi-role (infra-style)
python3 -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2 ...
# With JUnit output
python3 -m devx.molecule.molecule_ci_guard --junit-output junit-results/runner-1.xml pair1 pair2 ...
Environment variables:
GITEA_URL Base URL of the Gitea instance.
@@ -43,7 +38,6 @@ import subprocess # nosec B404
import sys
import threading
import time
import xml.etree.ElementTree as ET # nosec B405
from pathlib import Path
import click
@@ -158,53 +152,15 @@ def resolve_role_dir(role: str, roles_root: Path | None, repo_root: Path) -> Pat
return repo_root / "ansible" / "roles" / "gitea-runner"
def write_junit_report(
output_path: str,
testcases: list[dict],
runner_index: int,
) -> None:
"""Write a JUnit XML report from collected test case results.
Each testcase dict has: role, scenario, time (float), passed (bool), error (str|None).
"""
suite = ET.Element(
"testsuite",
name=f"molecule-runner-{runner_index}",
tests=str(len(testcases)),
failures=str(sum(1 for tc in testcases if not tc["passed"])),
)
for tc in testcases:
classname = tc["role"] if tc["role"] else "molecule"
elem = ET.SubElement(
suite,
"testcase",
classname=classname,
name=tc["scenario"],
time=f"{tc['time']:.1f}",
)
if not tc["passed"]:
fail = ET.SubElement(elem, "failure")
fail.text = tc.get("error") or "molecule test failed"
tree = ET.ElementTree(suite)
ET.indent(tree)
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
tree.write(output_path, encoding="UTF-8", xml_declaration=True)
@click.command()
@click.argument("pairs", nargs=-1, required=True)
@click.option(
"--junit-output",
default=None,
help="Path to write JUnit XML report (e.g. junit-results/runner-1.xml).",
)
@click.option(
"--roles-root",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=None,
help="Root directory for multi-role pairs (e.g. ansible/roles). Required when pairs use 5-part format.",
)
def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | None) -> None:
def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None:
"""Run molecule pairs sequentially, stop if another CI runner fails."""
gitea_url = os.environ.get("GITEA_URL", "")
token = os.environ.get("REPO_TOKEN", "")
@@ -249,8 +205,6 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
)
poller.start()
testcases: list[dict] = []
try:
for pair in pairs:
if failed_event.is_set():
@@ -263,7 +217,6 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
env = build_env_for_pair(pair, base_env)
cwd = resolve_role_dir(role, roles_root, repo_root)
start = time.time()
process = subprocess.Popen( # nosec B603
cmd,
cwd=str(cwd),
@@ -282,18 +235,6 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
process.wait()
elapsed = time.time() - start
testcases.append(
{
"role": role,
"scenario": scenario,
"time": elapsed,
"passed": False,
"error": "Cancelled — another runner failed",
}
)
if junit_output:
write_junit_report(junit_output, testcases, current_index)
sys.exit(1)
time.sleep(1)
except KeyboardInterrupt:
@@ -303,23 +244,9 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
sys.exit(1)
rc = process.returncode
elapsed = time.time() - start
passed = rc == 0
testcases.append(
{
"role": role,
"scenario": scenario,
"time": elapsed,
"passed": passed,
"error": f"Exit code: {rc}" if not passed else None,
}
)
if rc != 0:
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
if junit_output:
write_junit_report(junit_output, testcases, current_index)
sys.exit(rc)
click.echo(_("PASSED: {pair}", pair=pair))
@@ -336,8 +263,6 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
)
click.echo(_("All molecule tests passed."))
if junit_output:
write_junit_report(junit_output, testcases, current_index)
finally:
stop_event.set()
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Create a pull request with the correct title from the Vikunja task.
This tool is run **after** pushing a feature branch. It:
1. Extracts the task ID from the branch name (e.g. ``DEVX-31-fix-foo`` → ``DEVX-31``).
2. Fetches the Vikunja task title for that task ID.
3. Creates a PR with title ``{TASK_PREFIX}-N: <vikunja task title>``.
This eliminates manual PR title entry and ensures the title always
matches the Vikunja task — which is what the auto-merge workflow
validates.
If a PR already exists for the branch, the tool prints its URL and
exits successfully (idempotent).
Usage::
python -m devx.tools.create_pr --branch DEVX-31-fix-foo
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
"""
from __future__ import annotations
import os
import subprocess # nosec B404
import click
from dotenv import load_dotenv
from devx.api_clients import GiteaClient, VikunjaClient
from devx.config import (
DEFAULT_PER_PAGE,
GITEA_API_URL,
REPO_OWNER,
TASK_ID_RE,
TASK_PREFIX,
VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID,
)
from devx.i18n import _
load_dotenv()
def get_repo_name() -> str:
"""Auto-detect repository name from env vars or git remote."""
name = os.environ.get("DEVX_REPO_NAME", "")
if name:
return name
github_repo = os.environ.get("GITHUB_REPOSITORY", "")
if github_repo and "/" in github_repo:
return github_repo.split("/", 1)[1]
raise click.ClickException(
_("Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var."),
)
def extract_task_id(branch: str) -> str:
"""Extract the task ID (e.g. ``DEVX-31``) from a branch name."""
match = TASK_ID_RE.search(branch)
return match.group(0) if match else ""
def get_vikunja_task_title(task_id: str) -> str:
"""Fetch the Vikunja task title for the given task identifier.
Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found.
"""
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Required to derive PR title."))
client = VikunjaClient(VIKUNJA_API_URL, token)
page = 1
while True:
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
if not tasks:
break
matches = [t for t in tasks if t.get("identifier") == task_id]
if matches:
return str(matches[0].get("title", ""))
if len(tasks) < DEFAULT_PER_PAGE:
break
page += 1
raise click.ClickException(
_(
"Could not find Vikunja task {task_id} in project {project_id}.",
task_id=task_id,
project_id=VIKUNJA_PROJECT_ID,
),
)
def find_existing_pr(client: GiteaClient, branch: str) -> dict | None:
"""Return an existing open PR for the branch, or None."""
prs = client.list_prs(state="open")
for pr in prs:
if pr.get("head", {}).get("ref") == branch:
return pr
return None
def create_pr(
branch: str,
base: str,
body: str,
repo_owner: str,
repo_name: str,
) -> dict:
"""Create a PR with the title derived from the Vikunja task.
Returns the PR dict from the Gitea API.
"""
task_id = extract_task_id(branch)
if not task_id:
raise click.ClickException(
_(
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description",
branch=branch,
prefix=TASK_PREFIX,
),
)
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("REPO_TOKEN is not set. Required to create a PR."))
vikunja_title = get_vikunja_task_title(task_id)
pr_title = f"{task_id}: {vikunja_title}"
client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name)
existing = find_existing_pr(client, branch)
if existing:
click.echo(
_(
"PR already exists: #{index} — {url}",
index=existing.get("number", "?"),
url=existing.get("html_url", ""),
),
)
return existing
pr = client.create_pr(title=pr_title, head=branch, base=base, body=body)
click.echo(
_(
"Created PR #{index}: {title}\n {url}",
index=pr.get("number", "?"),
title=pr_title,
url=pr.get("html_url", ""),
),
)
return pr
@click.command()
@click.option("--branch", default=None, help="Head branch (default: auto-detect from git).")
@click.option("--base", default="master", show_default=True, help="Base branch.")
@click.option("--body", default="", help="PR body (markdown). Read from stdin if '-' is passed.")
@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).")
@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).")
def cli(branch: str | None, base: str, body: str, owner: str | None, repo: str | None) -> None:
"""Create a PR with the correct title from the Vikunja task."""
if branch is None:
result = subprocess.run( # nosec
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(
_("Could not detect current branch: {error}", error=result.stderr.strip()),
)
branch = result.stdout.strip()
if body == "-":
body = click.get_text_stream("stdin").read().strip()
repo_owner = owner or REPO_OWNER
if not repo_owner:
raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var."))
repo_name = repo or get_repo_name()
create_pr(branch, base, body, repo_owner, repo_name)
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Create a Vikunja task with a detailed HTML description.
This tool is used during the planning phase of the development workflow
to create a well-described task before any code is written. The task
identifier (e.g. ``DEVX-N``, ``GRM-N``, ``OBL-INFRA-N``) is then used
to name the feature branch and the pull request.
Usage::
python -m devx.tools.create_task --title "Add release automation" \\
--description "<h2>Overview</h2><p>Implement automated...</p>"
The project ID and task prefix are read from ``DEVX_VIKUNJA_PROJECT_ID``
and ``DEVX_TASK_PREFIX`` environment variables (or ``.env``).
"""
from __future__ import annotations
import os
import click
from dotenv import load_dotenv
from devx.api_clients import VikunjaClient
from devx.config import TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.i18n import _
load_dotenv()
@click.command()
@click.option("--title", required=True, help="Task title (becomes the Vikunja task title).")
@click.option(
"--description",
default="",
help="Task description (HTML supported). Read from stdin if '-' is passed.",
)
@click.option("--project-id", type=int, default=None, help="Vikunja project ID (default: DEVX_VIKUNJA_PROJECT_ID).")
def cli(title: str, description: str, project_id: int | None) -> None:
"""Create a Vikunja task and print its identifier."""
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Set it in .env or environment."))
pid = project_id if project_id is not None else VIKUNJA_PROJECT_ID
if description == "-":
description = click.get_text_stream("stdin").read().strip()
client = VikunjaClient(VIKUNJA_API_URL, token)
task = client.create_task(pid, title, description)
identifier = task.get("identifier", "")
task_id = task.get("id", "")
click.echo(
_(
"Created Vikunja task: {identifier} (id={task_id})",
identifier=identifier,
task_id=task_id,
)
)
if identifier:
click.echo(
_(
"Next steps:\n"
" 1. git checkout master && git pull\n"
" 2. git checkout -b {prefix}-{num}-short-description\n"
" 3. Implement changes, commit with conventional commit format\n"
" 4. git push -u origin HEAD\n"
" 5. make create-pr (creates PR with title: {identifier}: {title})",
prefix=TASK_PREFIX,
num=identifier.split("-")[-1] if "-" in identifier else "N",
identifier=identifier,
title=title,
)
)
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+36 -4
View File
@@ -239,6 +239,36 @@ def doc_coverage_color(pct: int) -> str:
return "orange"
def detect_testpaths(repo_root: Path) -> list[str]:
"""Detect test paths from pyproject.toml or filesystem.
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.
@@ -251,8 +281,8 @@ def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], d
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")
tests_dir = repo_root / "tests"
testpaths: list[str] = [str(tests_dir)] if tests_dir.is_dir() else []
testpaths = detect_testpaths(repo_root)
click.echo(f" Test paths: {testpaths or '(pytest defaults)'}")
cmd = [
sys.executable,
@@ -273,7 +303,8 @@ def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], d
cov_badge = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
else:
click.echo(f" WARNING: Could not extract coverage from pytest output (rc={rc})")
click.echo(f" pytest stderr: {stderr.strip()[:200]}")
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)
@@ -281,7 +312,8 @@ def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], d
tests_badge = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
else:
click.echo(f" WARNING: Could not extract test count from pytest output (rc={rc})")
click.echo(f" pytest stderr: {stderr.strip()[:200]}")
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")
return cov_badge, tests_badge
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Pre-push validation: ensure a Vikunja task exists for the branch.
This tool is designed to run as a git pre-push hook. It extracts the
task ID from the branch name (e.g. ``DEVX-31-fix-foo`` → ``DEVX-31``)
and verifies that a corresponding Vikunja task exists.
If the task does not exist, the hook **fails with guidance** — it does
not auto-create the task. This prevents accidental pushes of branches
without a planning task.
Usage::
python -m devx.tools.pre_push_check --branch DEVX-31-fix-foo
Exit codes:
0 — all checks passed, safe to push
1 — validation failed (missing task, missing token, etc.)
"""
from __future__ import annotations
import os
import subprocess # nosec B404
import click
from dotenv import load_dotenv
from devx.api_clients import VikunjaClient
from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.i18n import _
load_dotenv()
def get_current_branch() -> str:
"""Return the current git branch name, or empty string on error."""
result = subprocess.run( # nosec
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
check=False,
)
return result.stdout.strip()
def extract_task_id(branch: str) -> str:
"""Extract the task ID (e.g. ``DEVX-31``) from a branch name."""
match = TASK_ID_RE.search(branch)
return match.group(0) if match else ""
def task_exists(task_id: str) -> bool:
"""Check if a Vikunja task with the given identifier exists.
Returns ``False`` if VIKUNJA_TOKEN is not set (soft-fail in local mode).
"""
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
return False
client = VikunjaClient(VIKUNJA_API_URL, token)
page = 1
while True:
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
if not tasks:
break
if any(t.get("identifier") == task_id for t in tasks):
return True
if len(tasks) < DEFAULT_PER_PAGE:
break
page += 1
return False
def validate(branch: str) -> None:
"""Run all pre-push validations for the given branch.
Raises ``click.ClickException`` on failure.
"""
if not branch or branch in ("master", "main"):
return
task_id = extract_task_id(branch)
if not task_id:
raise click.ClickException(
_(
"Branch '{branch}' does not contain a task ID.\n"
" Expected format: {prefix}-N-short-description\n"
" Example: {prefix}-42-add-feature\n"
" Fix: rename the branch or create a Vikunja task first:\n"
' python -m devx.tools.create_task --title "Task title"',
branch=branch,
prefix=TASK_PREFIX,
)
)
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
click.echo(
_(
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. "
"Set it in .env to enable full validation.",
),
err=True,
)
return
if not task_exists(task_id):
raise click.ClickException(
_(
"Vikunja task {task_id} not found in project {project_id}.\n"
" Create it first:\n"
' python -m devx.tools.create_task --title "Task title"\n'
" Or check that the task ID in the branch name is correct.",
task_id=task_id,
project_id=VIKUNJA_PROJECT_ID,
)
)
click.echo(_("Pre-push check passed: task {task_id} exists.", task_id=task_id))
@click.command()
@click.option("--branch", default=None, help="Branch name (default: auto-detect from git).")
def cli(branch: str | None) -> None:
"""Validate pre-push preconditions for the current branch."""
if branch is None:
branch = get_current_branch()
validate(branch)
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+12 -2
View File
@@ -150,19 +150,29 @@ def _verify(bin_dir: str) -> None:
default=False,
help="Skip Ansible Galaxy collection installation.",
)
@click.option(
"--skip-install",
is_flag=True,
default=False,
help="Skip pip install (use when deps already installed, e.g. devx came via ci extra).",
)
def main(
bin_dir: str,
extras: str,
no_pre_commit: bool,
no_tea_login: bool,
no_ansible_collections: bool,
skip_install: bool,
) -> None:
"""Install Python deps, pre-commit hooks, and configure tea CLI."""
if not Path(bin_dir).exists():
raise click.ClickException(f"Bin directory not found: {bin_dir}. Run 'python3 -m venv .venv' first.")
click.echo(f"Installing Python dependencies (extras: {extras})...")
_install_python_deps(bin_dir, extras)
if not skip_install:
click.echo(f"Installing Python dependencies (extras: {extras})...")
_install_python_deps(bin_dir, extras)
else:
click.echo("Skipping pip install (--skip-install).")
if not no_ansible_collections:
click.echo("Installing Ansible Galaxy collections...")
File diff suppressed because it is too large Load Diff
+149
View File
@@ -137,6 +137,19 @@ class TestGiteaClient:
assert result is None
client.create_label.assert_not_called()
def test_ensure_label_creates_when_others_exist(self) -> None:
"""When labels exist but none match the target name, create a new one."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_labels = MagicMock(
return_value=[{"name": "bug", "color": "ff0000"}, {"name": "docs", "color": "007ec6"}]
)
client.create_label = MagicMock(return_value={"name": "ready-to-merge", "color": "2ecc71"})
result = client.ensure_label("ready-to-merge", "2ecc71", "desc")
assert result is not None
assert result["name"] == "ready-to-merge"
client.create_label.assert_called_once_with("ready-to-merge", "2ecc71", "desc")
def test_list_branch_protections(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
@@ -196,6 +209,18 @@ class TestGiteaClient:
expected_update = {k: v for k, v in TEST_BP_CONFIG.items() if k != "branch_name"}
client.update_branch_protection.assert_called_once_with("master", expected_update)
def test_ensure_branch_protection_creates_when_none_match(self) -> None:
"""When existing protections exist but none match the target branch, create a new one."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_branch_protections = MagicMock(
return_value=[{"branch_name": "develop"}, {"branch_name": "staging"}]
)
client.create_branch_protection = MagicMock(return_value={"id": 5, "branch_name": "master"})
result = client.ensure_branch_protection("master", TEST_BP_CONFIG)
assert result["id"] == 5
client.create_branch_protection.assert_called_once_with(TEST_BP_CONFIG)
def test_merge_pr(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response())
@@ -248,6 +273,37 @@ class TestGiteaClient:
timeout=DEFAULT_TIMEOUT,
)
def test_create_pr(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response({"number": 15, "html_url": "https://git.example.com/pr/15"})
)
result = client.create_pr(title="DEVX-42: Add feature", head="DEVX-42-fix", body="desc")
assert result["number"] == 15
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls",
timeout=DEFAULT_TIMEOUT,
json={"title": "DEVX-42: Add feature", "head": "DEVX-42-fix", "base": "master", "body": "desc"},
)
def test_create_pr_no_body(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response({"number": 16, "html_url": "https://git.example.com/pr/16"})
)
result = client.create_pr(title="DEVX-43: Fix bug", head="DEVX-43-fix")
assert result["number"] == 16
call_kwargs = client._session.request.call_args.kwargs
assert "body" not in call_kwargs["json"]
def test_create_pr_custom_base(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"number": 17}))
client.create_pr(title="Test", head="branch", base="develop")
call_kwargs = client._session.request.call_args.kwargs
assert call_kwargs["json"]["base"] == "develop"
def test_get_pr_files(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
@@ -278,6 +334,35 @@ class TestGiteaClient:
timeout=DEFAULT_TIMEOUT,
)
def test_list_prs(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response([{"number": 1, "title": "feat: add"}, {"number": 2, "title": "fix: bug"}])
)
result = client.list_prs()
assert len(result) == 2
assert result[0]["number"] == 1
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls",
params={"state": "all"},
timeout=DEFAULT_TIMEOUT,
)
def test_list_prs_with_params(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"number": 3, "title": "docs: update"}]))
result = client.list_prs(state="closed", q="docs")
assert len(result) == 1
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls",
params={"state": "closed", "q": "docs"},
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr_reviews(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "state": "APPROVED"}]))
@@ -566,6 +651,46 @@ class TestVikunjaClient:
json={"done": True},
)
def test_list_comments(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
return_value=_mock_response([{"id": 1, "comment": "first"}, {"id": 2, "comment": "second"}])
)
result = client.list_comments(42)
assert len(result) == 2
assert result[0]["comment"] == "first"
client._session.request.assert_called_once_with(
"GET",
"https://work.example.com/tasks/42/comments",
timeout=DEFAULT_TIMEOUT,
)
def test_update_task_safe(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
side_effect=[
_mock_response({"id": 42, "title": "My task", "done": False}),
_mock_response({"id": 42, "title": "My task", "done": True}),
]
)
result = client.update_task_safe(42, done=True)
assert result["done"] is True
assert result["title"] == "My task"
assert client._session.request.call_count == 2
client._session.request.assert_any_call(
"GET",
"https://work.example.com/tasks/42",
timeout=DEFAULT_TIMEOUT,
)
client._session.request.assert_any_call(
"POST",
"https://work.example.com/tasks/42",
timeout=DEFAULT_TIMEOUT,
json={"id": 42, "title": "My task", "done": True},
)
@patch("devx.api_clients.time.sleep")
def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None:
client = VikunjaClient("https://work.example.com", "tok")
@@ -632,6 +757,30 @@ class TestVikunjaClient:
assert exc_info.value.status == 0
assert client._session.request.call_count == 3 # MAX_RETRIES
def test_vikunja_create_task(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
return_value=_mock_response({"id": 1, "identifier": "DEVX-1", "title": "Test"})
)
result = client.create_task(6, "Test", "<p>desc</p>")
assert result["identifier"] == "DEVX-1"
client._session.request.assert_called_once_with(
"PUT",
"https://work.example.com/projects/6/tasks",
timeout=DEFAULT_TIMEOUT,
json={"title": "Test", "description": "<p>desc</p>"},
)
def test_vikunja_create_task_no_description(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
return_value=_mock_response({"id": 2, "identifier": "DEVX-2", "title": "No desc"})
)
result = client.create_task(6, "No desc")
assert result["id"] == 2
call_kwargs = client._session.request.call_args.kwargs
assert call_kwargs["json"]["description"] == ""
class TestIsRetryable:
def test_connection_error_is_retryable(self) -> None:
+38 -19
View File
@@ -47,6 +47,22 @@ class TestReadTaskid:
captured = capsys.readouterr()
assert "WARNING" not in captured.out
def test_no_warning_when_taskid_file_matches_branch(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
"""No warning when .taskid file content matches the branch task ID."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
captured = capsys.readouterr()
assert "WARNING" not in captured.out
def test_no_warning_when_taskid_file_empty(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
"""No warning when .taskid file exists but is empty."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("\n")
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
captured = capsys.readouterr()
assert "WARNING" not in captured.out
# -- extract_task_id (legacy fallback) --
@@ -253,31 +269,34 @@ class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_merge_behind_master_rebases(
def test_merge_behind_master_raises_no_rebase(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""When branch is behind master, auto-merge should NOT rebase.
Auto-rebasing creates a feedback loop: the force-push triggers a new
pull_request synchronize event, which starts a new CI run, which runs
auto-merge again, which rebases again, etc.
"""
monkeypatch.chdir(tmp_path)
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve timeout"}},
]
mock_client.merge_pr.side_effect = [
APIError(405, "HEAD branch is behind master"),
None, # Second call succeeds
]
mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master")
mock_client_cls.return_value = mock_client
with patch("devx.ci.auto_merge.run_cmd") as mock_run:
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code == 0, result.output
assert mock_client.merge_pr.call_count == 2
# Should have fetched, rebased, and pushed
assert mock_run.call_count == 5 # config name, config email, fetch, rebase, push
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert "behind master" in result.output.lower()
assert "rebase manually" in result.output.lower()
# Must NOT have called merge_pr twice (no retry after rebase)
assert mock_client.merge_pr.call_count == 1
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@@ -344,10 +363,10 @@ class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_rebase_retry_failure_raises(
def test_merge_behind_master_does_not_force_push(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""When rebase retry also fails, raises with helpful message."""
"""Verify no git commands are run when branch is behind master."""
monkeypatch.chdir(tmp_path)
mock_client = MagicMock()
@@ -358,14 +377,14 @@ class TestMain:
mock_client_cls.return_value = mock_client
with patch("devx.ci.auto_merge.run_cmd") as mock_run:
mock_run.side_effect = click.ClickException("git rebase failed")
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert "rebase" in result.output.lower()
# No git commands should be run (no rebase, no push)
mock_run.assert_not_called()
def test_main_module_block() -> None:
+29 -11
View File
@@ -41,9 +41,12 @@ class TestCheckTranslationSet:
src_dir.mkdir()
(src_dir / "mod.py").write_text('_("Hello")\n')
trans_file = tmp_path / "translations.json"
trans_file.write_text(
json.dumps({"Hello": {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好"}})
)
all_langs = {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好", "pl": "Cześć"}
# Include dynamic keys since collect_keys now adds them for all dirs
data = {"Hello": all_langs}
for dk in check_translations.DYNAMIC_KEYS:
data[dk] = all_langs
trans_file.write_text(json.dumps(data))
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert not result.errors
@@ -170,9 +173,11 @@ class TestMain:
def test_translations_flag(self, tmp_path: Path) -> None:
"""--translations flag should check a specific file."""
trans_file = tmp_path / "translations.json"
trans_file.write_text(
json.dumps({"Hello": {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好"}})
)
all_langs = {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好", "pl": "Cześć"}
data = {"Hello": all_langs}
for dk in check_translations.DYNAMIC_KEYS:
data[dk] = all_langs
trans_file.write_text(json.dumps(data))
(tmp_path / "mod.py").write_text('_("Hello")\n')
runner = CliRunner()
@@ -251,6 +256,19 @@ class TestDevxI18n:
monkeypatch.delenv("DEVX_LANG", raising=False)
importlib.reload(devx.i18n)
def test_polish_translation(self, monkeypatch: pytest.MonkeyPatch) -> None:
import importlib
monkeypatch.setenv("DEVX_LANG", "pl")
import devx.i18n
importlib.reload(devx.i18n)
result = devx.i18n._("Running tests...")
assert "Uruchamianie testów" in result
monkeypatch.delenv("DEVX_LANG", raising=False)
importlib.reload(devx.i18n)
def test_unsupported_lang_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
import importlib
@@ -290,14 +308,14 @@ class TestCollectKeys:
assert "should_appear" in keys
assert "should_not_appear" not in keys
def test_non_default_dir_no_dynamic_keys(self, tmp_path: Path) -> None:
"""Non-default source dirs should not include DYNAMIC_KEYS."""
def test_non_default_dir_includes_dynamic_keys(self, tmp_path: Path) -> None:
"""Non-default source dirs should also include DYNAMIC_KEYS."""
(tmp_path / "mod.py").write_text('_("mykey")\n')
keys = check_translations.collect_keys(tmp_path)
assert "mykey" in keys
# Dynamic keys should NOT be present for non-default dirs
assert "completed" not in keys
assert "pending" not in keys
# Dynamic keys should be present for all dirs
assert "completed" in keys
assert "pending" in keys
def test_default_dir_includes_dynamic_keys(self) -> None:
"""The default source dir should include DYNAMIC_KEYS."""
+122
View File
@@ -162,6 +162,15 @@ class TestClassifierConfig:
assert config.user_facing_overrides == []
assert config.tags == {}
def test_from_pyproject_dedupes_existing_default(self, tmp_path: Path) -> None:
"""Project infrastructure patterns already in defaults are not duplicated."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx.classify]\ninfrastructure = [".gitea/**", "scripts/**"]\n')
config = ClassifierConfig.from_pyproject(str(pyproject))
# .gitea/** should appear only once (deduplicated with defaults)
assert config.infrastructure.count(".gitea/**") == 1
assert "scripts/**" in config.infrastructure
def test_defaults_are_empty_for_bare_constructor(self) -> None:
"""ClassifierConfig() without from_pyproject has empty lists."""
config = ClassifierConfig()
@@ -515,6 +524,27 @@ class TestMain:
assert "Ansible files" in result.output
assert "ansible/tasks/main.yml" in result.output
@patch("devx.ci.classify_changes._get_classifier")
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_default_mode_skips_empty_tag(
self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock
) -> None:
"""Tags with no matching files are skipped in default mode output."""
mock_changes.return_value = ["ansible/tasks/main.yml"]
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={"ansible": ["ansible/**"], "docs": ["docs/**"]},
)
)
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "Ansible files" in result.output
# docs tag has no matching files — should not appear
assert "Docs files" not in result.output
@patch("devx.ci.classify_changes.get_latest_tag", return_value="")
def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None:
runner = CliRunner()
@@ -742,3 +772,95 @@ class TestGithubOutput:
assert "user-facing-changed=true" in content
# No tag outputs since no tags are configured
assert "ansible-changed" not in content
@patch("devx.ci.classify_changes._get_classifier")
def test_force_outputs_true(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""--force with --github-output writes user-facing-changed=true and all tags true."""
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
runner = CliRunner()
result = runner.invoke(main, ["--github-output", "--force"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "user-facing-changed=true" in content
assert "ansible-changed=true" in content
assert "Forced user-facing-changed=true" in result.output
@patch("devx.ci.classify_changes._get_classifier")
def test_force_without_github_output_does_nothing(
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""--force without --github-output falls through to normal classification."""
mock_clf.return_value = self._make_classifier_with_ansible()
monkeypatch.setenv("GITHUB_OUTPUT", str(tmp_path / "output.txt"))
with patch.object(classify_changes_mod, "get_latest_tag", return_value="v1.0"):
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
runner = CliRunner()
result = runner.invoke(main, ["--force", "--quiet"])
assert result.exit_code == 0
assert result.output.strip() == "false"
@patch("devx.ci.classify_changes._get_classifier")
def test_force_no_tags(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""--force with --github-output and no tags writes only user-facing-changed=true."""
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={},
)
)
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
runner = CliRunner()
result = runner.invoke(main, ["--github-output", "--force"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "user-facing-changed=true" in content
assert "ansible-changed" not in content
@patch("devx.ci.classify_changes._get_classifier")
def test_force_deploy_env_var(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""FORCE_DEPLOY=true env var activates force mode without --force flag."""
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
monkeypatch.setenv("FORCE_DEPLOY", "true")
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "user-facing-changed=true" in content
assert "ansible-changed=true" in content
@patch("devx.ci.classify_changes._get_classifier")
def test_force_deploy_env_var_false(
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""FORCE_DEPLOY=false does not activate force mode."""
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
monkeypatch.setenv("FORCE_DEPLOY", "false")
with patch.object(classify_changes_mod, "get_latest_tag", return_value="v1.0"):
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "user-facing-changed=false" in content
@patch("devx.ci.classify_changes._get_classifier")
def test_force_flag_overrides_env_var(
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""--force flag works even when FORCE_DEPLOY=false."""
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
monkeypatch.setenv("FORCE_DEPLOY", "false")
runner = CliRunner()
result = runner.invoke(main, ["--github-output", "--force"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "user-facing-changed=true" in content
-7
View File
@@ -233,13 +233,6 @@ class TestNewCiCommands:
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.distribute_files", ["--pattern", "*.py"])
@patch("devx.cli._run_module")
def test_ci_merge_junit(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "merge-junit", "--", "--output", "merged.xml"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.merge_junit", ["--output", "merged.xml"])
@patch("devx.cli._run_module")
def test_ci_integration_guard(self, mock_run: MagicMock) -> None:
runner = CliRunner()
+193
View File
@@ -0,0 +1,193 @@
"""Unit tests for devx.tools.create_pr."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.tools.create_pr import (
cli,
create_pr,
extract_task_id,
find_existing_pr,
get_repo_name,
get_vikunja_task_title,
)
class TestExtractTaskId:
def test_valid(self) -> None:
assert extract_task_id("DEVX-42-fix") == "DEVX-42"
def test_invalid(self) -> None:
assert extract_task_id("feature") == ""
class TestGetRepoName:
@patch.dict("os.environ", {"DEVX_REPO_NAME": "infra"})
def test_from_env(self) -> None:
assert get_repo_name() == "infra"
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
def test_from_github(self) -> None:
assert get_repo_name() == "infra"
@patch.dict("os.environ", {}, clear=True)
def test_missing_raises(self) -> None:
with pytest.raises(click.ClickException, match="Repository name"):
get_repo_name()
class TestGetVikunjaTaskTitle:
@patch("devx.tools.create_pr.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-42", "title": "Add feature"}]
mock_client_cls.return_value = mock_client
assert get_vikunja_task_title("DEVX-42") == "Add feature"
@patch.dict("os.environ", {}, clear=True)
def test_no_token(self) -> None:
with pytest.raises(click.ClickException, match="VIKUNJA_TOKEN"):
get_vikunja_task_title("DEVX-42")
@patch("devx.tools.create_pr.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_not_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="Could not find"):
get_vikunja_task_title("DEVX-42")
@patch("devx.tools.create_pr.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_pagination_not_found(self, mock_client_cls: MagicMock) -> None:
from devx.config import DEFAULT_PER_PAGE
mock_client = MagicMock()
page1 = [{"identifier": f"OTHER-{i}"} for i in range(DEFAULT_PER_PAGE)]
page2 = [{"identifier": "OTHER-99"}]
mock_client.list_project_tasks.side_effect = [page1, page2]
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="Could not find"):
get_vikunja_task_title("DEVX-42")
class TestFindExistingPr:
def test_found(self) -> None:
client = MagicMock()
client.list_prs.return_value = [{"head": {"ref": "DEVX-42-fix"}, "number": 10}]
result = find_existing_pr(client, "DEVX-42-fix")
assert result is not None
assert result["number"] == 10
def test_not_found(self) -> None:
client = MagicMock()
client.list_prs.return_value = [{"head": {"ref": "other"}, "number": 10}]
result = find_existing_pr(client, "DEVX-42-fix")
assert result is None
class TestCreatePr:
@patch("devx.tools.create_pr.GiteaClient")
@patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature")
@patch("devx.tools.create_pr.find_existing_pr", return_value=None)
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
def test_creates_new_pr(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_pr.return_value = {"number": 15, "html_url": "https://git.example.com/pr/15"}
mock_gitea.return_value = mock_client
result = create_pr("DEVX-42-fix", "master", "body", "owner", "repo")
assert result["number"] == 15
mock_client.create_pr.assert_called_once_with(
title="DEVX-42: Add feature",
head="DEVX-42-fix",
base="master",
body="body",
)
@patch("devx.tools.create_pr.GiteaClient")
@patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature")
@patch("devx.tools.create_pr.find_existing_pr")
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
def test_existing_pr_idempotent(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None:
mock_find.return_value = {"number": 10, "html_url": "https://git.example.com/pr/10"}
mock_client = MagicMock()
mock_gitea.return_value = mock_client
result = create_pr("DEVX-42-fix", "master", "", "owner", "repo")
assert result["number"] == 10
mock_client.create_pr.assert_not_called()
@patch.dict("os.environ", {}, clear=True)
def test_no_repo_token(self) -> None:
with pytest.raises(click.ClickException, match="REPO_TOKEN"):
create_pr("DEVX-42-fix", "master", "", "owner", "repo")
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
def test_no_task_id_in_branch(self) -> None:
with pytest.raises(click.ClickException, match="does not contain a task ID"):
create_pr("feature-branch", "master", "", "owner", "repo")
class TestCli:
@patch("devx.tools.create_pr.create_pr")
@patch("devx.tools.create_pr.subprocess.run")
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_auto_detect_branch(self, mock_repo: MagicMock, mock_run: MagicMock, mock_create: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="DEVX-42-fix\n", returncode=0)
mock_create.return_value = {"number": 1}
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
mock_create.assert_called_once_with("DEVX-42-fix", "master", "", "owner", "repo")
@patch("devx.tools.create_pr.create_pr")
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_explicit_branch(self, mock_repo: MagicMock, mock_create: MagicMock) -> None:
mock_create.return_value = {"number": 1}
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "DEVX-42-fix"])
assert result.exit_code == 0
@patch("devx.tools.create_pr.create_pr")
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_body_from_stdin(self, mock_repo: MagicMock, mock_create: MagicMock) -> None:
mock_create.return_value = {"number": 1}
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "DEVX-42-fix", "--body", "-"], input="PR body text")
assert result.exit_code == 0
mock_create.assert_called_once()
assert mock_create.call_args.args[2] == "PR body text"
@patch("devx.tools.create_pr.REPO_OWNER", "")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_missing_owner(self, mock_repo: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "DEVX-42-fix"])
assert result.exit_code != 0
assert "owner" in result.output.lower()
@patch("devx.tools.create_pr.create_pr")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_explicit_owner(self, mock_repo: MagicMock, mock_create: MagicMock) -> None:
mock_create.return_value = {"number": 1}
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "DEVX-42-fix", "--owner", "custom"])
assert result.exit_code == 0
mock_create.assert_called_once_with("DEVX-42-fix", "master", "", "custom", "repo")
@patch("devx.tools.create_pr.subprocess.run")
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_git_detect_failure(self, mock_repo: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="", stderr="fatal: not a git repository", returncode=128)
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code != 0
assert "Could not detect" in result.output
+83
View File
@@ -0,0 +1,83 @@
"""Unit tests for devx.tools.create_task."""
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.tools.create_task import cli
class TestCreateTaskCli:
@patch("devx.tools.create_task.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_success(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_task.return_value = {"identifier": "DEVX-60", "id": 60}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(cli, ["--title", "Add feature X"])
assert result.exit_code == 0
assert "DEVX-60" in result.output
mock_client.create_task.assert_called_once()
@patch.dict("os.environ", {}, clear=True)
def test_missing_token(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--title", "Add feature X"])
assert result.exit_code != 0
assert "VIKUNJA_TOKEN" in result.output
@patch("devx.tools.create_task.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_with_description(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_task.return_value = {"identifier": "DEVX-61", "id": 61}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
cli,
["--title", "Add feature Y", "--description", "<p>desc</p>"],
)
assert result.exit_code == 0
call_args = mock_client.create_task.call_args
assert call_args.args[1] == "Add feature Y"
assert call_args.args[2] == "<p>desc</p>"
@patch("devx.tools.create_task.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_description_from_stdin(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_task.return_value = {"identifier": "DEVX-62", "id": 62}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
cli,
["--title", "Add feature Z", "--description", "-"],
input="<p>stdin desc</p>",
)
assert result.exit_code == 0
mock_client.create_task.assert_called_once()
call_args = mock_client.create_task.call_args
assert call_args.args[2] == "<p>stdin desc</p>"
@patch("devx.tools.create_task.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_custom_project_id(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_task.return_value = {"identifier": "GRM-10", "id": 10}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(cli, ["--title", "Task", "--project-id", "3"])
assert result.exit_code == 0
mock_client.create_task.assert_called_once_with(3, "Task", "")
@patch("devx.tools.create_task.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_no_identifier_in_response(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_task.return_value = {"id": 99}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(cli, ["--title", "Task"])
assert result.exit_code == 0
assert "id=99" in result.output
+12
View File
@@ -237,3 +237,15 @@ class TestMain:
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code != 0
@patch("devx.ci.discover_runners.get_runner_count", return_value=2)
def test_explicit_owner_and_repo(self, mock_count: MagicMock) -> None:
"""When --owner and --repo are provided, env vars are not used."""
runner = CliRunner()
result = runner.invoke(main, ["--owner", "myorg", "--repo", "myrepo"])
assert result.exit_code == 0
mock_count.assert_called_once()
# Verify owner/repo passed through
args, kwargs = mock_count.call_args
assert "myorg" in args
assert "myrepo" in args
+17
View File
@@ -101,6 +101,23 @@ class TestCli:
assert "ASSIGNED_FILES=" in content
assert "SKIP=false" in content
def test_github_env_multiline_uses_heredoc(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
for i in range(6):
(tmp_path / f"test_{i}.py").write_text("")
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "1", "--max-runners", "2", "--github-env"],
)
assert result.exit_code == 0
content = gh_file.read_text()
# Multi-line values must use heredoc syntax to avoid corrupting $GITHUB_ENV
assert "ASSIGNED_FILES<<EOF" in content
assert content.count("EOF") >= 2
assert "SKIP=false" in content
def test_skip_if_excess(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
+15
View File
@@ -46,6 +46,21 @@ class TestExtractCliCommands:
commands = extract_cli_commands()
assert "my_command" in commands
def test_command_decorator_no_def_fallback(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When a command decorator has no name and no following def, it is skipped."""
from devx.ci import doc_coverage
fake_cli = tmp_path / "cli.py"
# The last @cli.command() has no explicit name and no def statement after it
fake_cli.write_text(
"@click.group()\ndef cli():\n pass\n@cli.command()\ndef real_cmd():\n pass\n@cli.command()\npass\n"
)
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli)
commands = extract_cli_commands()
# real_cmd should be found via def fallback; the bare @cli.command() is skipped
assert "real_cmd" in commands
assert "pass" not in commands
class TestCheckCommandDocumented:
def test_finds_command_in_heading(self) -> None:
+52 -2
View File
@@ -14,6 +14,7 @@ from devx.tools.generate_badges import (
coverage_color,
detect_coverage_target,
detect_package_name,
detect_testpaths,
doc_coverage_color,
extract_coverage,
extract_doc_coverage,
@@ -96,6 +97,49 @@ class TestDetectCoverageTarget:
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
def test_pyproject_without_cov_falls_back_to_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
"""When pyproject exists but has no --cov=, falls back to package name."""
src = tmp_path / "src"
pkg = src / "mypkg"
pkg.mkdir(parents=True)
(pkg / "__init__.py").write_text('__version__ = "1.0"\n')
(tmp_path / "pyproject.toml").write_text('[tool.pytest.ini_options]\naddopts = "-ra"\n')
assert detect_coverage_target(tmp_path) == "src/mypkg"
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_all_paths_nonexistent_falls_back_to_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
"""When all testpaths are non-existent, falls back to tests/ directory."""
(tmp_path / "tests").mkdir()
(tmp_path / "pyproject.toml").write_text(
'[tool.pytest.ini_options]\ntestpaths = ["nonexistent1", "nonexistent2"]\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")
@@ -256,16 +300,22 @@ class TestReadVersion:
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_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
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"
@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_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
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"
-20
View File
@@ -43,26 +43,6 @@ class TestCli:
assert result.exit_code == 1
assert "failed" in result.output
def test_junit_output_passed_to_pytest(self) -> None:
with (
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
["--junit-output", "junit-results/runner-1.xml", "--", "test_foo.py"],
)
assert result.exit_code == 0
call_args = mock_popen.call_args[0][0]
assert "--junitxml" in call_args
assert "junit-results/runner-1.xml" in call_args
def test_pytest_args_passed_through(self) -> None:
with (
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
-91
View File
@@ -1,91 +0,0 @@
"""Unit tests for devx.ci.merge_junit."""
from pathlib import Path
from xml.etree import ElementTree as ET
import pytest
from click.testing import CliRunner
from devx.ci.merge_junit import main, merge_files
def _write_suite(path: Path, name: str, tests: int, failures: int) -> None:
suite = ET.Element("testsuite", name=name, tests=str(tests), failures=str(failures))
for i in range(tests):
tc = ET.SubElement(suite, "testcase", classname="cls", name=f"test{i}", time="0.1")
if i < failures:
ET.SubElement(tc, "failure", message="fail")
tree = ET.ElementTree(suite)
tree.write(path, encoding="UTF-8", xml_declaration=True)
class TestMergeFiles:
def test_merges_multiple_suites(self, tmp_path: Path) -> None:
_write_suite(tmp_path / "runner-1.xml", "r1", tests=3, failures=1)
_write_suite(tmp_path / "runner-2.xml", "r2", tests=2, failures=0)
merged, total_tests, total_failures = merge_files(str(tmp_path / "runner-*.xml"))
assert total_tests == 5
assert total_failures == 1
assert merged.tag == "testsuites"
assert len(merged) == 2
def test_no_files_returns_empty(self, tmp_path: Path) -> None:
merged, total_tests, total_failures = merge_files(str(tmp_path / "nonexistent-*.xml"))
assert total_tests == 0
assert total_failures == 0
assert merged.tag == "testsuites"
assert len(merged) == 0
def test_handles_testsuites_wrapper_root(self, tmp_path: Path) -> None:
wrapper = ET.Element("testsuites")
suite = ET.SubElement(wrapper, "testsuite", name="r1", tests="4", failures="2")
ET.SubElement(suite, "testcase", classname="c", name="t", time="0.1")
tree = ET.ElementTree(wrapper)
tree.write(tmp_path / "runner-1.xml", encoding="UTF-8", xml_declaration=True)
merged, total_tests, total_failures = merge_files(str(tmp_path / "runner-*.xml"))
assert total_tests == 4
assert total_failures == 2
class TestCli:
def test_writes_merged_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_write_suite(tmp_path / "runner-1.xml", "r1", tests=2, failures=0)
_write_suite(tmp_path / "runner-2.xml", "r2", tests=3, failures=0)
out = tmp_path / "merged.xml"
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "runner-*.xml"), "--output", str(out)],
)
assert result.exit_code == 0
assert out.exists()
tree = ET.parse(out)
root = tree.getroot()
assert root.get("tests") == "5"
assert root.get("failures") == "0"
def test_exits_nonzero_on_failures(self, tmp_path: Path) -> None:
_write_suite(tmp_path / "runner-1.xml", "r1", tests=2, failures=1)
out = tmp_path / "merged.xml"
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "runner-*.xml"), "--output", str(out)],
)
assert result.exit_code != 0
assert "failures" in result.output
def test_no_files_exits_zero(self, tmp_path: Path) -> None:
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "nonexistent-*.xml"), "--output", str(tmp_path / "out.xml")],
)
assert result.exit_code == 0
assert "No JUnit" in result.output or "skipping" in result.output
def test_main_module_block() -> None:
import devx.ci.merge_junit as mod
assert hasattr(mod, "main")
+5 -166
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import os
import subprocess # nosec B404
import time
import xml.etree.ElementTree as ET
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -22,7 +21,6 @@ from devx.molecule.molecule_ci_guard import (
parse_pair,
poll_for_other_failures,
resolve_role_dir,
write_junit_report,
)
@@ -99,6 +97,11 @@ class TestBuildEnvForPair:
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_PLATFORM_COMMAND": "old"})
assert "MOLECULE_PLATFORM_COMMAND" not in env
def test_preserves_existing_molecule_home(self) -> None:
"""When MOLECULE_HOME is already set, it is not overridden."""
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_HOME": "/custom/home"})
assert env["MOLECULE_HOME"] == "/custom/home"
class TestPollForOtherFailures:
def test_sets_failed_event_when_other_runner_fails(self) -> None:
@@ -504,40 +507,6 @@ class TestResolveRoleDir:
assert result == tmp_path / "ansible" / "roles" / "gitea-runner"
class TestWriteJunitReport:
def test_writes_report_with_passing_tests(self, tmp_path: Path) -> None:
output = str(tmp_path / "junit-results" / "runner-1.xml")
testcases = [
{"role": "gitea-runner", "scenario": "default", "time": 5.2, "passed": True, "error": None},
{"role": "docker-base", "scenario": "lifecycle", "time": 3.1, "passed": True, "error": None},
]
write_junit_report(output, testcases, 1)
tree = ET.parse(output)
root = tree.getroot()
assert root.get("tests") == "2"
assert root.get("failures") == "0"
assert len(root) == 2
def test_writes_report_with_failures(self, tmp_path: Path) -> None:
output = str(tmp_path / "runner-2.xml")
testcases = [
{"role": "", "scenario": "default", "time": 1.0, "passed": False, "error": "Exit code: 1"},
]
write_junit_report(output, testcases, 2)
tree = ET.parse(output)
root = tree.getroot()
assert root.get("tests") == "1"
assert root.get("failures") == "1"
failure = root[0][0]
assert failure.tag == "failure"
assert failure.text == "Exit code: 1"
def test_creates_parent_directory(self, tmp_path: Path) -> None:
output = str(tmp_path / "deep" / "nested" / "dir" / "runner.xml")
write_junit_report(output, [], 0)
assert Path(output).exists()
class TestCliMultiRole:
def test_multi_role_pair_passes(self, tmp_path: Path) -> None:
from click.testing import CliRunner
@@ -563,133 +532,3 @@ class TestCliMultiRole:
)
assert result.exit_code == 0
assert "All molecule tests passed" in result.output
def test_junit_output_written(self, tmp_path: Path) -> None:
from click.testing import CliRunner
roles_root = tmp_path / "ansible" / "roles"
(roles_root / "gitea-runner").mkdir(parents=True)
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
with (
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("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,
[
"--roles-root",
str(roles_root),
"--junit-output",
junit_path,
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
],
)
assert result.exit_code == 0
assert Path(junit_path).exists()
def test_junit_output_on_failure(self, tmp_path: Path) -> None:
from click.testing import CliRunner
roles_root = tmp_path / "ansible" / "roles"
(roles_root / "gitea-runner").mkdir(parents=True)
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
with (
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 1
proc.returncode = 1
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
[
"--roles-root",
str(roles_root),
"--junit-output",
junit_path,
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
],
)
assert result.exit_code == 1
assert Path(junit_path).exists()
tree = ET.parse(junit_path)
assert tree.getroot().get("failures") == "1"
def test_junit_output_on_cancellation(self, tmp_path: Path) -> None:
"""JUnit report is written when a runner is cancelled by another runner's failure."""
from click.testing import CliRunner
real_sleep = time.sleep
roles_root = tmp_path / "ansible" / "roles"
(roles_root / "gitea-runner").mkdir(parents=True)
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
return [
{"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/infra",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg"),
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
[
"--roles-root",
str(roles_root),
"--junit-output",
junit_path,
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
],
)
assert result.exit_code == 1
assert Path(junit_path).exists()
tree = ET.parse(junit_path)
root = tree.getroot()
assert root.get("failures") == "1"
# The failure message should mention cancellation
failure = root[0][0]
assert "Cancelled" in (failure.text or "")
@@ -208,3 +208,14 @@ class TestMain:
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code != 0
@patch("devx.molecule.discover_runners.get_runner_count", return_value=2)
def test_explicit_owner_and_repo(self, mock_count: MagicMock) -> None:
"""When --owner and --repo are provided, env vars are not used."""
runner = CliRunner()
result = runner.invoke(main, ["--owner", "myorg", "--repo", "myrepo"])
assert result.exit_code == 0
mock_count.assert_called_once()
args, kwargs = mock_count.call_args
assert "myorg" in args
assert "myrepo" in args
+61
View File
@@ -129,6 +129,18 @@ class TestCheckArchitectureCompliance:
assert result.has_issues
assert "os.system" in result.issues[0]["body"]
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": "@@ -1,2 @@\n+ subprocess.run(['ls'])\n",
}
]
check_architecture_compliance(files, result)
assert result.has_issues
class TestCheckBestPractices:
def test_print_triggers_warning(self) -> None:
@@ -190,6 +202,19 @@ class TestCheckBestPractices:
check_best_practices(files, result)
assert not result.has_issues
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": "@@ -1,2 @@\n+ print('hello')\n",
}
]
check_best_practices(files, result)
assert result.has_issues
assert "print()" in result.issues[0]["body"]
class TestCheckSecurity:
def test_hardcoded_secret_triggers_error(self) -> None:
@@ -239,6 +264,19 @@ class TestCheckSecurity:
check_security(files, result)
assert not result.has_issues
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [
{
"filename": "src/devx/config.py",
"patch": "@@ -1,2 @@\n+ token = 'abc123secrettoken456'\n",
}
]
check_security(files, result)
assert result.has_issues
assert "secret" in result.issues[0]["body"].lower()
class TestCheckI18n:
def test_raw_string_in_echo_triggers_warning(self) -> None:
@@ -295,6 +333,14 @@ class TestCheckI18n:
check_i18n(files, result)
assert any("i18n: OK" in s for s in result.summary)
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,2 @@\n+click.echo("Hello world")\n'}]
check_i18n(files, result)
assert result.has_issues
assert any("i18n" in i["body"] for i in result.issues)
class TestCheckResourceManagement:
def test_open_without_with_triggers_warning(self) -> None:
@@ -366,6 +412,14 @@ class TestCheckResourceManagement:
check_resource_management(files, result)
assert any("Resource management: OK" in s for s in result.summary)
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,2 @@\n+f = open("file.txt")\n'}]
check_resource_management(files, result)
assert result.has_issues
assert any("resource" in i["body"].lower() for i in result.issues)
class TestCheckFunctionLength:
def test_long_function_triggers_warning(self) -> None:
@@ -429,6 +483,13 @@ class TestCheckFunctionLength:
assert result.has_issues
assert "foo" in result.issues[0]["body"]
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": "@@ -1,2 @@\n+def foo():\n+ pass\n"}]
check_function_length(files, result)
assert not result.has_issues
class TestCheckDocumentation:
def test_src_changes_without_docs_warns(self) -> None:
+137
View File
@@ -0,0 +1,137 @@
"""Unit tests for devx.tools.pre_push_check."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.tools.pre_push_check import (
cli,
extract_task_id,
get_current_branch,
task_exists,
validate,
)
class TestExtractTaskId:
def test_valid_branch(self) -> None:
assert extract_task_id("DEVX-42-fix-bug") == "DEVX-42"
def test_no_task_id(self) -> None:
assert extract_task_id("feature-branch") == ""
def test_empty_branch(self) -> None:
assert extract_task_id("") == ""
class TestGetCurrentBranch:
@patch("devx.tools.pre_push_check.subprocess.run")
def test_success(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="DEVX-42-fix\n", returncode=0)
assert get_current_branch() == "DEVX-42-fix"
@patch("devx.tools.pre_push_check.subprocess.run")
def test_failure(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="", returncode=1)
assert get_current_branch() == ""
class TestTaskExists:
@patch("devx.tools.pre_push_check.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-42"}]
mock_client_cls.return_value = mock_client
assert task_exists("DEVX-42") is True
@patch("devx.tools.pre_push_check.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_not_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-99"}]
mock_client_cls.return_value = mock_client
assert task_exists("DEVX-42") is False
@patch.dict("os.environ", {}, clear=True)
def test_no_token(self) -> None:
assert task_exists("DEVX-42") is False
@patch("devx.tools.pre_push_check.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_pagination(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
# First page: full page (50 items, none matching), second page: match
page1 = [{"identifier": f"OTHER-{i}"} for i in range(50)]
page2 = [{"identifier": "DEVX-42"}]
mock_client.list_project_tasks.side_effect = [page1, page2]
mock_client_cls.return_value = mock_client
assert task_exists("DEVX-42") is True
@patch("devx.tools.pre_push_check.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_empty_project(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
mock_client_cls.return_value = mock_client
assert task_exists("DEVX-42") is False
@patch("devx.tools.pre_push_check.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_pagination_not_found(self, mock_client_cls: MagicMock) -> None:
from devx.config import DEFAULT_PER_PAGE
mock_client = MagicMock()
page1 = [{"identifier": f"OTHER-{i}"} for i in range(DEFAULT_PER_PAGE)]
page2 = [{"identifier": "OTHER-99"}]
mock_client.list_project_tasks.side_effect = [page1, page2]
mock_client_cls.return_value = mock_client
assert task_exists("DEVX-42") is False
class TestValidate:
def test_master_branch_skips(self) -> None:
validate("master")
def test_main_branch_skips(self) -> None:
validate("main")
def test_empty_branch_skips(self) -> None:
validate("")
def test_no_task_id_raises(self) -> None:
with pytest.raises(click.ClickException, match="does not contain a task ID"):
validate("feature-branch")
@patch.dict("os.environ", {}, clear=True)
def test_no_token_warns(self) -> None:
validate("DEVX-42-fix-bug")
@patch("devx.tools.pre_push_check.task_exists", return_value=True)
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_task_exists_passes(self, mock_exists: MagicMock) -> None:
validate("DEVX-42-fix-bug")
@patch("devx.tools.pre_push_check.task_exists", return_value=False)
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_task_not_found_raises(self, mock_exists: MagicMock) -> None:
with pytest.raises(click.ClickException, match="not found"):
validate("DEVX-42-fix-bug")
class TestCli:
@patch("devx.tools.pre_push_check.get_current_branch", return_value="master")
def test_auto_detect_master(self, mock_branch: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
@patch("devx.tools.pre_push_check.task_exists", return_value=True)
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_explicit_branch(self, mock_exists: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "DEVX-42-fix"])
assert result.exit_code == 0
assert "passed" in result.output
+213 -4
View File
@@ -11,6 +11,8 @@ from devx.ci.publish import (
_default_gitea_registry_url,
build_package,
generate_release_notes,
get_latest_tag,
is_release_commit,
main,
publish_to_gitea_registry,
publish_to_pypi,
@@ -122,11 +124,18 @@ class TestPublishToGiteaRegistry:
@patch("devx.ci.publish.subprocess.run")
def test_failure_raises(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stderr="registry upload failed")
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="registry upload failed")
with pytest.raises(click.ClickException) as exc:
publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok")
assert "Gitea PyPI registry" in str(exc.value)
@patch("devx.ci.publish.subprocess.run")
def test_409_conflict_is_non_fatal(self, mock_run: MagicMock) -> None:
"""409 Conflict (already published) should not raise — just continue."""
mock_run.return_value = MagicMock(returncode=1, stdout="ERROR 409 Conflict from url", stderr="")
# Should not raise
publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok")
class TestDefaultGiteaRegistryUrl:
@patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True)
@@ -147,6 +156,13 @@ class TestDefaultGiteaRegistryUrl:
url = _default_gitea_registry_url()
assert "oblachno-oss" in url
@patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True)
@patch("devx.ci.publish.GITEA_API_URL", "https://git.example.com/")
def test_no_api_suffix(self) -> None:
"""URL without /api/v1 or /api suffix is used as-is."""
url = _default_gitea_registry_url()
assert url == "https://git.example.com/api/packages/myorg/pypi"
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@@ -162,6 +178,7 @@ class TestMain:
mock_notes: MagicMock,
) -> None:
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
@@ -187,6 +204,7 @@ class TestMain:
) -> None:
"""When no PYPI_TOKEN, publishes to Gitea PyPI registry."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
@@ -209,6 +227,7 @@ class TestMain:
) -> None:
"""--registry-url flag publishes to the specified Gitea registry."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
@@ -236,6 +255,7 @@ class TestMain:
) -> None:
"""DEVX_PYPI_REGISTRY_URL env var sets the registry URL."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
@@ -256,6 +276,7 @@ class TestMain:
) -> None:
"""When no PYPI_TOKEN and no registry URL, skips publish and creates release only."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--registry-url", ""])
@@ -289,14 +310,21 @@ class TestMain:
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
def test_publish_failure_raises_click(
def test_publish_failure_continues_to_gitea_release(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
"""PyPI publish failure is non-fatal — Gitea release is still created."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
mock_publish.side_effect = click.ClickException("publish failed")
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1
assert "publish" in result.output
assert result.exit_code == 0
assert "non-fatal" in result.output
mock_tea.create_release.assert_called_once_with(
"owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes"
)
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@@ -307,6 +335,7 @@ class TestMain:
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea.create_release.side_effect = TeaCLIError("server error")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
@@ -323,6 +352,7 @@ class TestMain:
) -> None:
"""--skip-build skips build_package and PyPI publish, only creates Gitea release."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
@@ -330,3 +360,182 @@ class TestMain:
assert "skip" in result.output.lower()
mock_build.assert_not_called()
mock_tea.create_release.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
def test_skips_release_creation_when_already_exists(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
"""If the Gitea release already exists, skip creation (idempotent)."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = [{"tag_name": "v1.0.0"}]
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0
assert "already exists" in result.output
mock_tea.create_release.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
def test_proceeds_to_create_when_list_releases_fails(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
"""If list_releases raises TeaCLIError, proceed to create the release."""
mock_tea = MagicMock()
mock_tea.list_releases.side_effect = TeaCLIError("api error")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
def test_create_release_already_exists_is_idempotent(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
"""If create_release fails with 'already exists', treat as success."""
mock_tea = MagicMock()
mock_tea.list_releases.side_effect = TeaCLIError("api error")
mock_tea.create_release.side_effect = TeaCLIError("there is already a release for this tag")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0
assert "already exists" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
def test_create_release_other_error_raises(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
"""If create_release fails with a non-'already exists' error, raise."""
mock_tea = MagicMock()
mock_tea.list_releases.side_effect = TeaCLIError("api error")
mock_tea.create_release.side_effect = TeaCLIError("network error")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code != 0
assert "Release creation failed" in result.output
class TestFromTag:
def test_get_latest_tag_success(self) -> None:
import subprocess
with patch("devx.ci.publish.subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="v1.2.3\n")
result = get_latest_tag()
assert result == "v1.2.3"
def test_get_latest_tag_no_tags(self) -> None:
import subprocess
with patch("devx.ci.publish.subprocess.run") as mock_run:
mock_run.side_effect = subprocess.CalledProcessError(1, [])
result = get_latest_tag()
assert result is None
def test_is_release_commit_match(self) -> None:
import subprocess
with patch("devx.ci.publish.subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(
args=[], returncode=0, stdout="release: v1.2.3 [skip ci]\n"
)
result = is_release_commit("v1.2.3")
assert result is True
def test_is_release_commit_no_match(self) -> None:
import subprocess
with patch("devx.ci.publish.subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: add feature\n")
result = is_release_commit("v1.2.3")
assert result is False
def test_is_release_commit_git_error(self) -> None:
import subprocess
with patch("devx.ci.publish.subprocess.run") as mock_run:
mock_run.side_effect = subprocess.CalledProcessError(1, [])
result = is_release_commit("v1.2.3")
assert result is False
@patch("devx.ci.publish.get_latest_tag", return_value=None)
def test_from_tag_no_tag_skips(self, _mock: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"])
assert result.exit_code == 0
assert "No tag found" in result.output
@patch("devx.ci.publish.get_latest_tag", return_value=None)
def test_from_tag_no_repo_uses_env(self, _mock: MagicMock) -> None:
runner = CliRunner()
with patch.dict("os.environ", {"GITHUB_REPOSITORY": "owner/repo"}):
result = runner.invoke(main, ["--from-tag", "--skip-build"])
assert result.exit_code == 0
assert "No tag found" in result.output
@patch("devx.ci.publish.get_latest_tag", return_value=None)
def test_from_tag_no_repo_no_env_raises(self, _mock: MagicMock) -> None:
runner = CliRunner()
with patch.dict("os.environ", {}, clear=True):
result = runner.invoke(main, ["--from-tag", "--skip-build"])
assert result.exit_code != 0
assert "REPO argument is required" in result.output
@patch("devx.ci.publish.is_release_commit", return_value=False)
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0")
def test_from_tag_not_release_commit_skips(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"])
assert result.exit_code == 0
assert "not a release commit" in result.output
@patch("devx.ci.publish.is_release_commit", return_value=True)
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0")
def test_from_tag_publishes(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
with patch("devx.ci.publish.TeaCLI") as mock_tea_cls:
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
with patch("devx.ci.publish.generate_release_notes", return_value="notes"):
runner = CliRunner()
result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"])
assert result.exit_code == 0
assert "Publishing release v1.0.0" in result.output
@patch("devx.ci.publish.is_release_commit", return_value=True)
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0")
def test_from_tag_publishes_no_repo_arg(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
with patch.dict("os.environ", {"REPO_TOKEN": "fake", "GITHUB_REPOSITORY": "owner/repo"}):
with patch("devx.ci.publish.TeaCLI") as mock_tea_cls:
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
with patch("devx.ci.publish.generate_release_notes", return_value="notes"):
runner = CliRunner()
result = runner.invoke(main, ["--from-tag", "--skip-build"])
assert result.exit_code == 0
assert "Publishing release v1.0.0" in result.output
def test_no_tag_no_from_tag_raises(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["", "owner/repo", "--skip-build"])
assert result.exit_code != 0
assert "Tag is required" in result.output
+35 -1
View File
@@ -508,6 +508,30 @@ class TestVerifyAlignment:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 1
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_no_latest_tag_skips_changelog_tag_check(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""When there is no latest tag, the CHANGELOG/tag match check is skipped."""
mock_lt.return_value = None # no tags
mock_tags.return_value = []
mock_vtc.return_value = []
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.4"] # changelog has versions but no tag to compare
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 0
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@@ -756,6 +780,16 @@ class TestUpdateChangelog:
assert "# Changelog" not in content
assert "## [0.2.0]" in content
def test_no_version_section_in_changelog(self, tmp_path, monkeypatch) -> None:
"""Changelog input without any ## [ version section is inserted as-is."""
changelog_file = tmp_path / "CHANGELOG.md"
changelog_file.write_text("# Changelog\n\n## [0.1.0] - 2026-06-20\n\n### Features\n- old thing\n")
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", str(changelog_file))
# No ## [ section in the cliff output — should not be stripped
update_changelog("Some raw text without version header")
content = changelog_file.read_text()
assert "Some raw text without version header" in content
class TestCommitReleaseChanges:
@patch("devx.ci.release.run_cmd")
@@ -766,7 +800,7 @@ class TestCommitReleaseChanges:
assert result is True
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "add", "src/devx/__init__.py", "CHANGELOG.md"] in calls
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0 [skip ci]"] in calls
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0"] in calls
@patch("devx.ci.release.run_cmd")
def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None:
+28
View File
@@ -186,6 +186,12 @@ class TestVerify:
mock_run.side_effect = subprocess.TimeoutExpired(cmd="devx", timeout=10)
_verify(".venv/bin") # Should not raise
@patch("devx.tools.setup.subprocess.run")
def test_verify_handles_nonzero_returncode(self, mock_run: MagicMock) -> None:
"""When a tool returns non-zero, it is skipped without raising."""
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
_verify(".venv/bin") # Should not raise
class TestMain:
@patch("devx.tools.setup._configure_tea_login")
@@ -304,6 +310,28 @@ class TestMain:
assert result.exit_code != 0
assert "Bin directory not found" in result.output
@patch("devx.tools.setup._verify")
@patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps")
def test_main_skip_install(
self,
mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock,
mock_verify: MagicMock,
mock_tea: MagicMock,
tmp_path: Path,
) -> None:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
runner = CliRunner()
result = runner.invoke(main, ["--bin", str(bin_dir), "--skip-install"])
assert result.exit_code == 0
mock_install_deps.assert_not_called()
assert "Skipping pip install" in result.output
def test_main_module_block(tmp_path: Path) -> None:
"""Test the __main__ block execution."""
+16
View File
@@ -69,6 +69,22 @@ class TestDiagnoseSocket:
_diagnose_socket()
mock_exists.assert_called_with(DOCKER_SOCK)
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
@patch("devx.molecule.start_docker.os.stat")
@patch("devx.molecule.start_docker.subprocess.run")
def test_docker_info_no_matching_lines(
self, mock_run: MagicMock, mock_stat: MagicMock, mock_exists: MagicMock
) -> None:
"""docker info succeeds but stdout has no Server Version/Storage Driver/Root Dir lines."""
mock_stat.return_value = MagicMock(st_mode=0o660, st_uid=0, st_gid=0)
mock_run.side_effect = [
MagicMock(stdout="/dev/sda1 /var/lib/docker ext4\n", returncode=0, text=""),
MagicMock(stdout="default\n", returncode=0, text=""),
MagicMock(stdout="Containers: 0\nImages: 0\nKernel: 6.1\n", returncode=0, text=""),
]
_diagnose_socket()
mock_exists.assert_called_with(DOCKER_SOCK)
class TestStartDockerDaemon:
@patch("devx.molecule.start_docker._diagnose_socket")
+44 -2
View File
@@ -7,7 +7,7 @@ from unittest.mock import patch
from click.testing import CliRunner
from devx.ci.validate_commit_msg import first_line, get_branch, main
from devx.ci.validate_commit_msg import first_line, get_branch, get_latest_commit_msg, main
from devx.config import CONVENTIONAL_RE, TASK_ID_RE
@@ -126,7 +126,7 @@ class TestMain:
def test_usage_message_without_args(self) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 2
assert result.exit_code != 0
def test_branch_override_accepts_master_commit(self) -> None:
"""--branch master overrides branch detection (for CI use)."""
@@ -257,3 +257,45 @@ def test_main_module_block() -> None:
namespace["main"]([msg_path], standalone_mode=False)
os.unlink(msg_path)
class TestGitMode:
def test_git_flag_reads_from_git(self, tmp_path) -> None:
with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value="feat: add feature"):
with patch("devx.ci.validate_commit_msg.get_branch", return_value="feature-branch"):
runner = CliRunner()
result = runner.invoke(main, ["--git"])
assert result.exit_code == 0
def test_git_flag_master_valid(self) -> None:
msg = "DEVX-24: fix: resolve timeout"
with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value=msg):
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
runner = CliRunner()
result = runner.invoke(main, ["--git", "--branch", "master"])
assert result.exit_code == 0
def test_git_flag_master_invalid(self) -> None:
msg = "fix: resolve timeout"
with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value=msg):
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
runner = CliRunner()
result = runner.invoke(main, ["--git", "--branch", "master"])
assert result.exit_code != 0
def test_no_file_no_git_raises(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--branch", "master"])
assert result.exit_code != 0
def test_get_latest_commit_msg_success(self) -> None:
with patch("subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: test\n\nBody")
result = get_latest_commit_msg()
assert result == "feat: test\n\nBody"
def test_stdin_input(self) -> None:
with patch("devx.ci.validate_commit_msg.get_branch", return_value="feature-branch"):
runner = CliRunner()
result = runner.invoke(main, input="feat: add feature\n", args=["-", "--branch", "feature-branch"])
assert result.exit_code == 0