Compare commits

..
37 Commits
Author SHA1 Message Date
devx-ci-bot 700d3b55c6 release: v0.14.2
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Publish Release / publish (push) Successful in 30s
Post-merge / badges (push) Successful in 1m8s
Post-merge / detect-type (push) Successful in 8s
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
Post-merge / vikunja (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 1m13s
Post-merge / badges (push) Successful in 1m22s
Post-merge / detect-type (push) Successful in 14s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / release (push) Successful in 1m8s
Post-merge / configure-repo (push) Successful in 9s
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
Post-merge / detect-type (push) Successful in 16s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Publish Release / publish (push) Successful in 31s
Post-merge / badges (push) Successful in 1m2s
2026-06-26 01:33:23 +02:00
emil dfcd33c35b DEVX-58: fix: handle 'already a release' error idempotently in publish
Post-merge / detect-type (push) Successful in 6s
Post-merge / configure-repo (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 14s
Post-merge / release (push) Successful in 55s
Post-merge / vikunja (push) Successful in 9s
Post-merge / sync-wiki (push) Successful in 54s
Post-merge / badges (push) Successful in 58s
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
Post-merge / detect-type (push) Successful in 14s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Publish Release / publish (push) Failing after 32s
Post-merge / badges (push) Successful in 55s
2026-06-26 01:23:45 +02:00
emil 9060cd7b1e DEVX-57: feat: add FORCE_DEPLOY env var, --git flag, --from-tag flag
Post-merge / detect-type (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 13s
Post-merge / release (push) Successful in 58s
Post-merge / vikunja (push) Successful in 19s
Post-merge / badges (push) Successful in 55s
Post-merge / sync-wiki (push) Successful in 1m18s
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
Post-merge / detect-type (push) Successful in 14s
Publish Release / publish (push) Failing after 16s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 1m13s
2026-06-25 22:57:37 +00:00
emil 8450f33e88 DEVX-56: feat: add --force flag to classify_changes, fix api_clients coverage
Post-merge / detect-type (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Successful in 1m5s
Post-merge / vikunja (push) Successful in 32s
Post-merge / badges (push) Successful in 58s
Post-merge / sync-wiki (push) Successful in 1m18s
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
Post-merge / detect-type (push) Successful in 22s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / release (push) Failing after 54s
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 49s
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
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 1m2s
Publish Release / publish (push) Failing after 29s
2026-06-25 23:40:08 +02:00
emil 8affccfa35 DEVX-53: fix: make PyPI publish failures non-fatal
Post-merge / detect-type (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Successful in 48s
Post-merge / validate-commit-msg (push) Successful in 12s
Post-merge / vikunja (push) Successful in 12s
Post-merge / sync-wiki (push) Successful in 44s
Post-merge / badges (push) Successful in 1m3s
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
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 56s
Publish Release / publish (push) Failing after 23s
2026-06-25 23:27:13 +02:00
emil 44c6c42ede DEVX-52: fix: guarantee Gitea release for every tag
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Successful in 14s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / release (push) Successful in 54s
Post-merge / vikunja (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 51s
Post-merge / badges (push) Successful in 1m9s
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
Post-merge / detect-type (push) Successful in 12s
Post-merge / configure-repo (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 14s
Post-merge / release (push) Successful in 47s
Post-merge / vikunja (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 49s
Post-merge / badges (push) Successful in 57s
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
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Successful in 6s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Failing after 56s
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 51s
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
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 16s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / release (push) Successful in 1m13s
Post-merge / vikunja (push) Successful in 14s
Post-merge / badges (push) Successful in 57s
Post-merge / sync-wiki (push) Successful in 1m5s
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
31 changed files with 753 additions and 665 deletions
+4 -7
View File
@@ -82,6 +82,8 @@ jobs:
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: |
@@ -106,13 +108,8 @@ jobs:
echo "No tag found — skipping publish"
exit 0
fi
HEAD_MSG=$(git log -1 --format=%s)
if echo "$HEAD_MSG" | grep -q "^release: ${TAG}"; then
echo "Publishing release $TAG..."
python3 -m devx.ci.publish "$TAG" "${{ github.repository }}"
else
echo "HEAD is not a release commit for $TAG — skipping publish"
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:
+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
+56
View File
@@ -2,6 +2,62 @@
All notable changes to this project will be documented in this file.
## [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
+10 -14
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/9a9a53effd27fe580c2aa39edf77673e9a9a7e0f/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9a9a53effd27fe580c2aa39edf77673e9a9a7e0f/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9a9a53effd27fe580c2aa39edf77673e9a9a7e0f/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9a9a53effd27fe580c2aa39edf77673e9a9a7e0f/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9a9a53effd27fe580c2aa39edf77673e9a9a7e0f/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9a9a53effd27fe580c2aa39edf77673e9a9a7e0f/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/python.svg)](https://www.python.org/downloads/)
## Why devx?
@@ -175,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
@@ -227,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)
@@ -274,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 |
+7 -7
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/9a9a53effd27fe580c2aa39edf77673e9a9a7e0f/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9a9a53effd27fe580c2aa39edf77673e9a9a7e0f/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9a9a53effd27fe580c2aa39edf77673e9a9a7e0f/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9a9a53effd27fe580c2aa39edf77673e9a9a7e0f/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9a9a53effd27fe580c2aa39edf77673e9a9a7e0f/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9a9a53effd27fe580c2aa39edf77673e9a9a7e0f/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/python.svg)](https://www.python.org/downloads/)
## Overview
@@ -100,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
+3 -12
View File
@@ -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
@@ -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`
+3 -15
View File
@@ -447,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`
@@ -503,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
@@ -520,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
+3 -15
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.12.1"
__version__ = "0.14.2"
+34
View File
@@ -192,6 +192,17 @@ class GiteaClient:
r = self._request("GET", f"/pulls/{pr_number}")
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")
@@ -345,5 +356,28 @@ class VikunjaClient:
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(
_(
+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.
+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()
+97 -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,18 +260,27 @@ 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."))
@@ -227,6 +302,9 @@ def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> Non
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))
+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()
+71 -39
View File
@@ -383,6 +383,14 @@
"ru": "Another molecule runner failed. Stopping this runner early.",
"zh": "Another molecule runner failed. Stopping this runner early."
},
"Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": {
"bg": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"de": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"en": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"pl": "Gałąź jest w tyle za master. Wykonaj rebase ręcznie:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nNastępnie dodaj ponownie etykietę ready-to-merge.",
"ru": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"zh": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label."
},
"Bumping version: {current} -> v{new_version}": {
"bg": "Bumping version: {current} -> v{new_version}",
"de": "Bumping version: {current} -> v{new_version}",
@@ -591,6 +599,14 @@
"ru": "Generated {file} with prefix '{prefix}'.",
"zh": "Generated {file} with prefix '{prefix}'."
},
"Gitea PyPI registry: {tag} already published — continuing.": {
"bg": "Gitea PyPI registry: {tag} вече е публикуван — продължава.",
"de": "Gitea PyPI-Registry: {tag} bereits veröffentlicht — wird fortgesetzt.",
"en": "Gitea PyPI registry: {tag} already published — continuing.",
"pl": "Gitea PyPI registry: {tag} już opublikowano — kontynuacja.",
"ru": "Gitea PyPI registry: {tag} уже опубликован — продолжаем.",
"zh": "Gitea PyPI registry: {tag} 已发布 — 继续。"
},
"Gitea release {tag} already exists — skipping creation.": {
"bg": "Gitea release {tag} вече съществува — прескачане на създаването.",
"de": "Gitea-Release {tag} existiert bereits — Erstellung übersprungen.",
@@ -623,6 +639,14 @@
"ru": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
"zh": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping."
},
"HEAD is not a release commit for {tag} — skipping publish.": {
"bg": "HEAD is not a release commit for {tag} — skipping publish.",
"de": "HEAD is not a release commit for {tag} — skipping publish.",
"en": "HEAD is not a release commit for {tag} — skipping publish.",
"pl": "HEAD nie jest commitem wydania dla {tag} — pomijanie publikacji.",
"ru": "HEAD is not a release commit for {tag} — skipping publish.",
"zh": "HEAD is not a release commit for {tag} — skipping publish."
},
"HTTP error: {status} — {message}": {
"bg": "HTTP грешка: {status} — {message}",
"de": "HTTP-Fehler: {status} — {message}",
@@ -639,14 +663,6 @@
"ru": "HTTP {status} Запрещено — у вашего токена нет прав администратора.\nУбедитесь, что токен принадлежит владельцу репозитория или администратору организации.\nЛибо настройте защиту ветки вручную в разделе Настройки → Ветки.",
"zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。"
},
"Head branch is behind master. Pulling and rebasing...": {
"bg": "Head branch is behind master. Pulling and rebasing...",
"de": "Head branch is behind master. Pulling and rebasing...",
"en": "Head branch is behind master. Pulling and rebasing...",
"pl": "Gałąź head jest w tyle za master. Pobieranie i rebasing...",
"ru": "Head branch is behind master. Pulling and rebasing...",
"zh": "Head branch is behind master. Pulling and rebasing..."
},
"Host Docker not available, starting local dockerd...": {
"bg": "Хост Docker не е наличен, стартиране на локален dockerd...",
"de": "Host-Docker nicht verfügbar, lokaler dockerd wird gestartet...",
@@ -719,14 +735,6 @@
"ru": "Mapped file {file} not found. Update mapping.json or create the file.",
"zh": "Mapped file {file} not found. Update mapping.json or create the file."
},
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.": {
"bg": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"de": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"pl": "Scalanie nie powiodło się po ponownej próbie rebase: {error}\nProszę wykonać rebase PR ręcznie.",
"ru": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
"zh": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually."
},
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": {
"bg": "Сливането неуспешно с HTTP {status}: {message}\nПроверете дали PR е готов и имате права за сливане.",
"de": "Merge fehlgeschlagen mit HTTP {status}: {message}\nBitte prüfen Sie, ob der PR bereit ist und Sie Merge-Rechte haben.",
@@ -735,14 +743,6 @@
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
},
"Merged {count} reports: {tests} tests, {failures} failures → {output}": {
"bg": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
"de": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
"en": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
"pl": "Scalono {count} raportów: {tests} testów, {failures} niepowodzeń → {output}",
"ru": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
"zh": "Merged {count} reports: {tests} tests, {failures} failures → {output}"
},
"Module {mod} has no main() function": {
"bg": "Модул {mod} няма функция main()",
"de": "Modul {mod} hat keine main()-Funktion",
@@ -791,14 +791,6 @@
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
},
"No JUnit reports found matching {pattern} — skipping merge.": {
"bg": "No JUnit reports found matching {pattern} — skipping merge.",
"de": "No JUnit reports found matching {pattern} — skipping merge.",
"en": "No JUnit reports found matching {pattern} — skipping merge.",
"pl": "Nie znaleziono raportów JUnit pasujących do {pattern} — pomijanie scalania.",
"ru": "No JUnit reports found matching {pattern} — skipping merge.",
"zh": "No JUnit reports found matching {pattern} — skipping merge."
},
"No changes between {base} and {head}.": {
"bg": "No changes between {base} and {head}.",
"de": "No changes between {base} and {head}.",
@@ -815,6 +807,14 @@
"ru": "No staged changes — version and changelog already up to date.",
"zh": "No staged changes — version and changelog already up to date."
},
"No tag found — skipping publish.": {
"bg": "No tag found — skipping publish.",
"de": "No tag found — skipping publish.",
"en": "No tag found — skipping publish.",
"pl": "Nie znaleziono tagu — pomijanie publikacji.",
"ru": "No tag found — skipping publish.",
"zh": "No tag found — skipping publish."
},
"No tags found — treating all changes as user-facing.": {
"bg": "No tags found — treating all changes as user-facing.",
"de": "No tags found — treating all changes as user-facing.",
@@ -983,6 +983,14 @@
"ru": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
"zh": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit."
},
"Provide a commit message file or use --git.": {
"bg": "Provide a commit message file or use --git.",
"de": "Provide a commit message file or use --git.",
"en": "Provide a commit message file or use --git.",
"pl": "Podaj plik komunikatu commitu lub użyj --git.",
"ru": "Provide a commit message file or use --git.",
"zh": "Provide a commit message file or use --git."
},
"Published to Gitea PyPI registry.": {
"bg": "Публикувано в Gitea PyPI registry.",
"de": "In der Gitea PyPI-Registry veröffentlicht.",
@@ -999,6 +1007,14 @@
"ru": "Опубликовано в PyPI.",
"zh": "已发布到 PyPI。"
},
"Publishing release {tag}...": {
"bg": "Publishing release {tag}...",
"de": "Publishing release {tag}...",
"en": "Publishing release {tag}...",
"pl": "Publikowanie wydania {tag}...",
"ru": "Publishing release {tag}...",
"zh": "Publishing release {tag}..."
},
"Pushed release commit to master.": {
"bg": "Pushed release commit to master.",
"de": "Pushed release commit to master.",
@@ -1007,13 +1023,13 @@
"ru": "Pushed release commit to master.",
"zh": "Pushed release commit to master."
},
"Rebased and pushed. Retrying merge...": {
"bg": "Rebased and pushed. Retrying merge...",
"de": "Rebased and pushed. Retrying merge...",
"en": "Rebased and pushed. Retrying merge...",
"pl": "Rebase i wypchnięto. Ponowna próba scalenia...",
"ru": "Rebased and pushed. Retrying merge...",
"zh": "Rebased and pushed. Retrying merge..."
"PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}": {
"bg": "Публикуването в PyPI неуспешно (некритично — продължава към Gitea release):\n{error}",
"de": "PyPI-Veröffentlichung fehlgeschlagen (nicht fatal — Gitea-Release wird fortgesetzt):\n{error}",
"en": "PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}",
"pl": "Publikacja PyPI nie powiodła się (niekrytyczne — kontynuacja Gitea release):\n{error}",
"ru": "Публикация в PyPI не удалась (некритично — продолжаем создание Gitea release):\n{error}",
"zh": "PyPI 发布失败(非致命 — 继续创建 Gitea release):\n{error}"
},
"Release creation failed: {error}": {
"bg": "Release creation failed: {error}",
@@ -1111,6 +1127,22 @@
"ru": "Tag consistency check failed.",
"zh": "Tag consistency check failed."
},
"Tag is required (or use --from-tag).": {
"bg": "Tag is required (or use --from-tag).",
"de": "Tag is required (or use --from-tag).",
"en": "Tag is required (or use --from-tag).",
"pl": "Tag jest wymagany (lub użyj --from-tag).",
"ru": "Tag is required (or use --from-tag).",
"zh": "Tag is required (or use --from-tag)."
},
"REPO argument is required (or set GITHUB_REPOSITORY env var).": {
"bg": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"de": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"en": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"pl": "Argument REPO jest wymagany (lub ustaw zmienną GITHUB_REPOSITORY).",
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
},
"Tag v{version} already existed. Publish workflow should already have been triggered.": {
"bg": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"de": "Tag v{version} already existed. Publish workflow should already have been triggered.",
+69
View File
@@ -278,6 +278,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 +595,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")
+22 -19
View File
@@ -253,31 +253,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 +347,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 +361,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:
+92
View File
@@ -742,3 +742,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()
-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")
-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,
)
@@ -504,40 +502,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 +527,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 "")
+165 -6
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)
@@ -294,14 +303,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")
@@ -371,5 +387,148 @@ class TestMain:
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0
assert "Gitea release v1.0.0 created" in result.output
mock_tea.create_release.assert_called_once()
@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
+1 -1
View File
@@ -766,7 +766,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:
+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