Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3625bf2872 | ||
|
|
8affccfa35 | ||
|
|
7ae85b6955 | ||
|
|
f702286779 | ||
|
|
d4b58fa86f | ||
|
|
44c6c42ede | ||
|
|
14c585971d | ||
|
|
3e2342c347 | ||
|
|
f3d5b0ff45 | ||
|
|
75e36897cc | ||
|
|
0eef69a902 | ||
|
|
f9130884d1 | ||
|
|
ba002c2e72 | ||
|
|
af610d22ec | ||
|
|
69a585db2f | ||
|
|
700df828ba | ||
|
|
4df0602157 | ||
|
|
d7d90fe165 | ||
|
|
22c2d7c925 | ||
|
|
b4350751f1 | ||
|
|
a987b63da7 | ||
|
|
82d613e23b | ||
|
|
6b6c9d40f1 | ||
|
|
d17854296a | ||
|
|
2fff7ed271 | ||
|
|
0a4d66ab5c | ||
|
|
14c9200179 | ||
|
|
85ae272b1f | ||
|
|
a0c4c1c7f0 | ||
|
|
fd0c4de31e | ||
|
|
8f7af97335 | ||
|
|
a14d838564 | ||
|
|
7dcb9c03c0 | ||
|
|
6f2b110c17 | ||
|
|
cfb856ff75 | ||
|
|
95adf86895 | ||
|
|
7cf039ebbe | ||
|
|
a17982f2cf | ||
|
|
cf85964877 | ||
|
|
fbb1fc3134 | ||
|
|
a8f86aca68 | ||
|
|
13bed1d99c |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,3 +35,6 @@ Thumbs.db
|
||||
|
||||
# Badges
|
||||
.badges/
|
||||
|
||||
# Deprecated CI task tracking (branch name is the sole source of truth)
|
||||
.taskid
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -287,15 +286,10 @@ setuptools via `dynamic = ["version"]` in `pyproject.toml`.
|
||||
|
||||
### Task ID Resolution
|
||||
|
||||
`auto_merge` resolves the task ID from the branch name first (e.g.
|
||||
`DEVX-12-fix-foo` → `DEVX-12`), falling back to the `.taskid` file
|
||||
for branches without a task ID prefix. If both exist and disagree,
|
||||
a warning is printed and the branch task ID is preferred.
|
||||
|
||||
**When creating a new branch from an existing branch**, the `.taskid`
|
||||
file may be stale (it contains the old branch's task ID). Either:
|
||||
1. Update `.taskid` to match the new branch's task ID, or
|
||||
2. Delete `.taskid` — the branch name is the primary source of truth
|
||||
`auto_merge` resolves the task ID solely from the branch name (e.g.
|
||||
`DEVX-12-fix-foo` → `DEVX-12`). Branch names must include the task ID
|
||||
prefix — there is no `.taskid` file fallback. If a stale `.taskid` file
|
||||
exists in the repo, a deprecation warning is printed advising its removal.
|
||||
|
||||
### Workflow `auto-merge` Job and `always()`
|
||||
|
||||
|
||||
@@ -2,6 +2,77 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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
|
||||
|
||||
- Badge generation REPO_ROOT, auto-detect package, error feedback
|
||||
|
||||
## [0.10.0] - 2026-06-24
|
||||
|
||||
### Features
|
||||
|
||||
- Remove .taskid file fallback, use branch name only
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use raw/branch/badges/ URLs for badges in README and docs
|
||||
|
||||
## [0.9.12] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -16,12 +16,12 @@ quality badges.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Why devx?
|
||||
|
||||
@@ -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
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.9.12"
|
||||
__version__ = "0.12.5"
|
||||
|
||||
+26
-44
@@ -2,9 +2,9 @@
|
||||
"""Auto-merge PR when all CI checks pass.
|
||||
|
||||
Runs as the final job in ci.yml. Reads the task ID from the branch name
|
||||
(falling back to ``.taskid`` file for branches without a task ID prefix),
|
||||
validates the PR title, and squash-merges with a conventional commit
|
||||
message prefixed by the task ID.
|
||||
(e.g., ``DEVX-31-fix-foo`` → ``DEVX-31``), validates the PR title against
|
||||
the Vikunja task, and squash-merges with a conventional commit message
|
||||
prefixed by the task ID.
|
||||
|
||||
PR title format: ``{PREFIX}-N: <vikunja task title>``
|
||||
Merge commit format: ``{PREFIX}-N <conventional commit message>``
|
||||
@@ -42,7 +42,7 @@ from devx.config import (
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
TASKID_FILE = ".taskid"
|
||||
TASKID_FILE = ".taskid" # Deprecated, kept for backward-compat warnings
|
||||
PR_TITLE_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+")
|
||||
|
||||
load_dotenv()
|
||||
@@ -63,44 +63,31 @@ def run_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess[
|
||||
|
||||
|
||||
def read_taskid(branch: str) -> str:
|
||||
"""Read task ID from branch name, falling back to .taskid file.
|
||||
"""Read task ID from branch name.
|
||||
|
||||
The branch name is the primary source of truth for the task ID
|
||||
(e.g., ``DEVX-31-fix-foo`` → ``DEVX-31``). The ``.taskid`` file
|
||||
is a legacy fallback for branches without a task ID prefix.
|
||||
The branch name is the sole source of truth for the task ID
|
||||
(e.g., ``DEVX-31-fix-foo`` → ``DEVX-31``). Branches must include
|
||||
the task ID prefix — there is no ``.taskid`` file fallback.
|
||||
|
||||
If both sources exist and disagree, a warning is printed and the
|
||||
branch task ID is preferred (it is the current source of truth).
|
||||
If a stale ``.taskid`` file exists and disagrees with the branch
|
||||
name, a deprecation warning is printed advising its removal.
|
||||
"""
|
||||
branch_task_id = extract_task_id(branch)
|
||||
if branch_task_id:
|
||||
# Check for stale .taskid file that disagrees with branch name
|
||||
# Warn about stale .taskid file if it exists and disagrees
|
||||
path = Path(TASKID_FILE)
|
||||
if path.exists():
|
||||
file_task_id = path.read_text(encoding="utf-8").strip()
|
||||
if file_task_id and file_task_id != branch_task_id:
|
||||
click.echo(
|
||||
_(
|
||||
"WARNING: .taskid file ({file_id}) disagrees with branch name ({branch_id}). "
|
||||
"Using branch task ID. Update or delete .taskid to silence this warning.",
|
||||
"WARNING: .taskid file ({file_id}) is deprecated and disagrees with branch name ({branch_id}). "
|
||||
"Delete .taskid from the repo — branch name is the sole source of truth.",
|
||||
file_id=file_task_id,
|
||||
branch_id=branch_task_id,
|
||||
)
|
||||
)
|
||||
return branch_task_id
|
||||
# Fallback: read from .taskid file
|
||||
path = Path(TASKID_FILE)
|
||||
if path.exists():
|
||||
task_id = path.read_text(encoding="utf-8").strip()
|
||||
if task_id:
|
||||
click.echo(
|
||||
_(
|
||||
"Task ID from .taskid file: {task_id} (not found in branch name '{branch}')",
|
||||
task_id=task_id,
|
||||
branch=branch,
|
||||
)
|
||||
)
|
||||
return task_id
|
||||
return ""
|
||||
|
||||
|
||||
@@ -229,7 +216,8 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
||||
if not task_id:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! No task ID found in .taskid file or branch name '{branch}'.",
|
||||
"Oops! No task ID found in branch name '{branch}'. "
|
||||
"Branch names must include the task ID prefix (e.g., DEVX-31-fix-bug).",
|
||||
branch=branch,
|
||||
)
|
||||
)
|
||||
@@ -249,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(
|
||||
_(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -298,8 +298,6 @@ DEFAULT_INFRASTRUCTURE: list[str] = [
|
||||
"activate.sh",
|
||||
"activate.fish",
|
||||
"activate.zsh",
|
||||
# CI task tracking file (written by CI, not by developers)
|
||||
".taskid",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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()
|
||||
+44
-16
@@ -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:
|
||||
@@ -194,23 +202,43 @@ 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:
|
||||
|
||||
@@ -18,6 +18,7 @@ Usage::
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
@@ -27,7 +28,16 @@ from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
|
||||
def _repo_root() -> Path:
|
||||
"""Resolve repo root from GITHUB_WORKSPACE or cwd."""
|
||||
workspace = os.environ.get("GITHUB_WORKSPACE")
|
||||
if workspace:
|
||||
path = Path(workspace)
|
||||
if path.is_dir():
|
||||
return path
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
# Badge filenames that get pushed to the badges branch
|
||||
BADGE_FILES = ["coverage.svg", "tests.svg", "docs.svg", "quality.svg", "version.svg", "python.svg"]
|
||||
@@ -116,7 +126,7 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
|
||||
Switches back to master, replaces ``raw/branch/badges/`` URLs with
|
||||
``raw/commit/<sha>/`` URLs, commits and pushes.
|
||||
"""
|
||||
root = repo_root or REPO_ROOT
|
||||
root = repo_root or _repo_root()
|
||||
|
||||
# Switch back to master
|
||||
_run(["git", "checkout", "master"]) # nosec B607
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+1
-8
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -5,12 +5,21 @@ Runs pytest-cov, doc-coverage, lint checks, and version extraction,
|
||||
then writes SVG badge files that can be served as static files from
|
||||
the Gitea raw file API.
|
||||
|
||||
The repo root is resolved from ``GITHUB_WORKSPACE`` or ``os.getcwd()``,
|
||||
so this module works correctly both when run from a source checkout
|
||||
and when devx is installed as a pip package in CI.
|
||||
|
||||
The package name and coverage target are auto-detected from the
|
||||
``src/`` directory structure, making this module reusable across
|
||||
all oblachno-oss repos without per-repo configuration.
|
||||
|
||||
Usage:
|
||||
python3 -m devx.tools.generate_badges --output-dir .badges/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
@@ -18,8 +27,7 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
# Coverage regex matches "TOTAL ... NN%" or "TOTAL ... NN.NN%"
|
||||
_COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%")
|
||||
_PASSED_RE = re.compile(r"(\d+) passed")
|
||||
_DOC_COVERAGE_RE = re.compile(r"Doc coverage:\s+\d+/\d+\s+\((\d+)%")
|
||||
@@ -37,18 +45,59 @@ COLOR_HEX: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
def _find_package_init() -> Path | None:
|
||||
"""Find the first package __init__.py under src/ that defines __version__."""
|
||||
src_dir = REPO_ROOT / "src"
|
||||
if not src_dir.exists():
|
||||
def resolve_repo_root() -> Path:
|
||||
"""Resolve the repository root directory.
|
||||
|
||||
Uses ``GITHUB_WORKSPACE`` env var (set by Gitea Actions) or
|
||||
falls back to ``os.getcwd()``. This ensures the correct repo
|
||||
root is used even when devx is installed as a pip package.
|
||||
"""
|
||||
workspace = os.environ.get("GITHUB_WORKSPACE")
|
||||
if workspace:
|
||||
path = Path(workspace)
|
||||
if path.is_dir():
|
||||
return path
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def detect_package_name(repo_root: Path) -> str | None:
|
||||
"""Auto-detect the Python package name from ``src/`` directory.
|
||||
|
||||
Looks for the first subdirectory under ``src/`` that contains
|
||||
an ``__init__.py`` file with ``__version__``.
|
||||
|
||||
Returns the package directory name (e.g., ``devx``,
|
||||
``gitea_runner_manager``) or ``None`` if no package is found.
|
||||
"""
|
||||
src_dir = repo_root / "src"
|
||||
if not src_dir.is_dir():
|
||||
return None
|
||||
for init_file in src_dir.rglob("__init__.py"):
|
||||
try:
|
||||
content = init_file.read_text()
|
||||
except OSError:
|
||||
for entry in sorted(src_dir.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
if "__version__" in content:
|
||||
return init_file
|
||||
init_file = entry / "__init__.py"
|
||||
if init_file.exists():
|
||||
return entry.name
|
||||
return None
|
||||
|
||||
|
||||
def detect_coverage_target(repo_root: Path) -> str | None:
|
||||
"""Auto-detect the pytest-cov target from pyproject.toml.
|
||||
|
||||
Parses ``addopts`` in ``[tool.pytest.ini_options]`` for
|
||||
``--cov=src/<package>``. Falls back to ``src/<package>`` if
|
||||
the package is detected but no explicit cov target is found.
|
||||
"""
|
||||
pyproject = repo_root / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
content = pyproject.read_text()
|
||||
match = re.search(r"--cov=(\S+)", content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
# Fallback: derive from package name
|
||||
pkg = detect_package_name(repo_root)
|
||||
if pkg:
|
||||
return f"src/{pkg}"
|
||||
return None
|
||||
|
||||
|
||||
@@ -57,14 +106,15 @@ def _xml_escape(text: str) -> str:
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||
|
||||
|
||||
def run_command(cmd: list[str]) -> tuple[int, str, str]:
|
||||
def run_command(cmd: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
|
||||
"""Run a command and return (returncode, stdout, stderr)."""
|
||||
root = str(cwd or resolve_repo_root())
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=str(REPO_ROOT),
|
||||
cwd=root,
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
@@ -139,15 +189,25 @@ def extract_doc_coverage(output: str) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def read_version() -> str:
|
||||
"""Read __version__ from the package __init__.py."""
|
||||
init_file = _find_package_init()
|
||||
if init_file is None:
|
||||
def read_version(repo_root: Path) -> str:
|
||||
"""Read __version__ from the package __init__.py under src/.
|
||||
|
||||
Auto-detects the package directory and reads ``__version__``
|
||||
from its ``__init__.py``.
|
||||
"""
|
||||
pkg = detect_package_name(repo_root)
|
||||
if pkg is None:
|
||||
click.echo(" WARNING: No Python package found under src/ — version badge will show 'unknown'")
|
||||
return "unknown"
|
||||
init_file = repo_root / "src" / pkg / "__init__.py"
|
||||
if not init_file.exists():
|
||||
click.echo(f" WARNING: {init_file} not found — version badge will show 'unknown'")
|
||||
return "unknown"
|
||||
content = init_file.read_text()
|
||||
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
click.echo(f" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'")
|
||||
return "unknown"
|
||||
|
||||
|
||||
@@ -179,65 +239,170 @@ def doc_coverage_color(pct: int) -> str:
|
||||
return "orange"
|
||||
|
||||
|
||||
def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]:
|
||||
"""Generate all badge SVG files and return badge data as a dict."""
|
||||
badges: dict[str, dict[str, str | int]] = {}
|
||||
def detect_testpaths(repo_root: Path) -> list[str]:
|
||||
"""Detect test paths from pyproject.toml or filesystem.
|
||||
|
||||
# 1. Code coverage + test count (single pytest-cov run)
|
||||
rc, stdout, stderr = run_command(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
"tests/",
|
||||
"-v",
|
||||
"--cov=src/devx",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-fail-under=0",
|
||||
]
|
||||
)
|
||||
Parses ``testpaths`` in ``[tool.pytest.ini_options]`` from
|
||||
pyproject.toml. Falls back to ``["tests"]`` if the tests/
|
||||
directory exists. Returns an empty list if no test paths
|
||||
are found (pytest will use its own defaults).
|
||||
"""
|
||||
pyproject = repo_root / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
content = pyproject.read_text()
|
||||
# Match: testpaths = ["dir1", "dir2"]
|
||||
match = re.search(r"testpaths\s*=\s*\[([^\]]+)\]", content)
|
||||
if match:
|
||||
paths = re.findall(r'["\']([^"\']+)["\']', match.group(1))
|
||||
resolved = []
|
||||
for p in paths:
|
||||
p = p.strip()
|
||||
if (repo_root / p).exists():
|
||||
resolved.append(p)
|
||||
if resolved:
|
||||
return resolved
|
||||
|
||||
# Fallback: tests/ directory
|
||||
tests_dir = repo_root / "tests"
|
||||
if tests_dir.is_dir():
|
||||
return ["tests"]
|
||||
return []
|
||||
|
||||
|
||||
def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], dict[str, str | int]]:
|
||||
"""Run pytest-cov and collect coverage + test count badges.
|
||||
|
||||
Returns (coverage_badge, tests_badge). If pytest is not
|
||||
available or no tests are found, returns 'unknown' badges
|
||||
with a clear warning explaining the failure.
|
||||
"""
|
||||
cov_target = detect_coverage_target(repo_root)
|
||||
if cov_target is None:
|
||||
click.echo(" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)")
|
||||
return make_badge("coverage", "unknown", "lightgrey"), make_badge("tests", "unknown", "lightgrey")
|
||||
|
||||
testpaths = detect_testpaths(repo_root)
|
||||
click.echo(f" Test paths: {testpaths or '(pytest defaults)'}")
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
*testpaths,
|
||||
"--cov",
|
||||
cov_target,
|
||||
"--cov-report=term-missing",
|
||||
"--cov-fail-under=0",
|
||||
"-q",
|
||||
]
|
||||
rc, stdout, stderr = run_command(cmd, cwd=repo_root)
|
||||
combined = stdout + "\n" + stderr
|
||||
|
||||
coverage = extract_coverage(combined)
|
||||
if coverage is not None:
|
||||
badges["coverage"] = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
|
||||
cov_badge = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
|
||||
else:
|
||||
badges["coverage"] = make_badge("coverage", "unknown", "red")
|
||||
click.echo(f" WARNING: Could not extract coverage from pytest output (rc={rc})")
|
||||
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}")
|
||||
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}")
|
||||
cov_badge = make_badge("coverage", "unknown", "red")
|
||||
|
||||
test_count = extract_test_count(combined)
|
||||
if test_count is not None:
|
||||
badges["tests"] = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
|
||||
tests_badge = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
|
||||
else:
|
||||
badges["tests"] = make_badge("tests", "unknown", "red")
|
||||
click.echo(f" WARNING: Could not extract test count from pytest output (rc={rc})")
|
||||
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}")
|
||||
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}")
|
||||
tests_badge = make_badge("tests", "unknown", "red")
|
||||
|
||||
# 2. Documentation coverage
|
||||
rc, stdout, _ = run_command(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"devx.ci.doc_coverage",
|
||||
]
|
||||
return cov_badge, tests_badge
|
||||
|
||||
|
||||
def collect_doc_coverage(repo_root: Path) -> dict[str, str | int]:
|
||||
"""Run doc_coverage and collect the docs badge."""
|
||||
rc, stdout, stderr = run_command(
|
||||
[sys.executable, "-m", "devx.ci.doc_coverage"],
|
||||
cwd=repo_root,
|
||||
)
|
||||
doc_pct = extract_doc_coverage(stdout)
|
||||
if doc_pct is not None:
|
||||
badges["docs"] = make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
|
||||
else:
|
||||
badges["docs"] = make_badge("docs", "unknown", "red")
|
||||
return make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
|
||||
click.echo(f" WARNING: Could not extract doc coverage (rc={rc})")
|
||||
click.echo(f" stderr: {stderr.strip()[:200]}")
|
||||
return make_badge("docs", "unknown", "red")
|
||||
|
||||
# 3. Code quality (ruff + pyright + bandit all pass)
|
||||
lint_rc, _, _ = run_command([sys.executable, "-m", "ruff", "check", "src/", "tests/"])
|
||||
format_rc, _, _ = run_command([sys.executable, "-m", "ruff", "format", "--check", "src/", "tests/"])
|
||||
type_rc, _, _ = run_command([sys.executable, "-m", "pyright"])
|
||||
bandit_rc, _, _ = run_command([sys.executable, "-m", "bandit", "-r", "src/"])
|
||||
|
||||
all_pass = all(rc == 0 for rc in [lint_rc, format_rc, type_rc, bandit_rc])
|
||||
badges["quality"] = make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
|
||||
def collect_quality(repo_root: Path) -> dict[str, str | int]:
|
||||
"""Run lint checks and collect the quality badge.
|
||||
|
||||
Runs ruff check, ruff format --check, pyright, and bandit.
|
||||
If any tool is not installed, it is skipped with a warning.
|
||||
"""
|
||||
results: list[bool] = []
|
||||
tool_names: list[str] = []
|
||||
|
||||
for cmd, name in [
|
||||
([sys.executable, "-m", "ruff", "check", "src/", "tests/"], "ruff check"),
|
||||
([sys.executable, "-m", "ruff", "format", "--check", "src/", "tests/"], "ruff format"),
|
||||
([sys.executable, "-m", "pyright"], "pyright"),
|
||||
([sys.executable, "-m", "bandit", "-r", "src/"], "bandit"),
|
||||
]:
|
||||
rc, _, stderr = run_command(cmd, cwd=repo_root)
|
||||
if rc == 0:
|
||||
results.append(True)
|
||||
tool_names.append(f"{name}: pass")
|
||||
else:
|
||||
results.append(False)
|
||||
# Distinguish "tool not installed" from "tool found issues"
|
||||
if "No module named" in stderr or "not found" in stderr.lower():
|
||||
click.echo(f" WARNING: {name} not installed — skipping (counted as pass)")
|
||||
results[-1] = True
|
||||
tool_names.append(f"{name}: not installed (skipped)")
|
||||
else:
|
||||
tool_names.append(f"{name}: FAIL")
|
||||
click.echo(f" WARNING: {name} failed (rc={rc})")
|
||||
click.echo(f" stderr: {stderr.strip()[:200]}")
|
||||
|
||||
all_pass = all(results)
|
||||
click.echo(f" Quality checks: {', '.join(tool_names)}")
|
||||
return make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
|
||||
|
||||
|
||||
def generate_badges(output_dir: Path, repo_root: Path | None = None) -> dict[str, dict[str, str | int]]:
|
||||
"""Generate all badge SVG files and return badge data as a dict.
|
||||
|
||||
Args:
|
||||
output_dir: Directory to write SVG files.
|
||||
repo_root: Repository root (auto-detected if None).
|
||||
"""
|
||||
root = repo_root or resolve_repo_root()
|
||||
click.echo(f" Repo root: {root}")
|
||||
pkg = detect_package_name(root)
|
||||
click.echo(f" Package: {pkg or 'none'}")
|
||||
|
||||
badges: dict[str, dict[str, str | int]] = {}
|
||||
|
||||
# 1. Code coverage + test count (single pytest-cov run)
|
||||
click.echo(" Collecting coverage and tests...")
|
||||
cov_badge, tests_badge = collect_coverage_and_tests(root)
|
||||
badges["coverage"] = cov_badge
|
||||
badges["tests"] = tests_badge
|
||||
|
||||
# 2. Documentation coverage
|
||||
click.echo(" Collecting doc coverage...")
|
||||
badges["docs"] = collect_doc_coverage(root)
|
||||
|
||||
# 3. Code quality (ruff + pyright + bandit)
|
||||
click.echo(" Collecting code quality...")
|
||||
badges["quality"] = collect_quality(root)
|
||||
|
||||
# 4. Version
|
||||
version = read_version()
|
||||
click.echo(" Collecting version...")
|
||||
version = read_version(root)
|
||||
badges["version"] = make_badge("version", f"v{version}", "blue")
|
||||
|
||||
# 5. Python version (static but nice)
|
||||
# 5. Python version (static)
|
||||
badges["python"] = make_badge("python", "3.12", "blue")
|
||||
|
||||
# Write SVG files
|
||||
@@ -254,14 +419,20 @@ def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]:
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--output-dir",
|
||||
default=str(REPO_ROOT / ".badges"),
|
||||
default=".badges",
|
||||
help="Directory to write badge SVG files.",
|
||||
)
|
||||
def cli(output_dir: str) -> None:
|
||||
@click.option(
|
||||
"--repo-root",
|
||||
default=None,
|
||||
help="Repository root (auto-detected if not specified).",
|
||||
)
|
||||
def cli(output_dir: str, repo_root: str | None) -> None:
|
||||
"""Generate self-contained SVG badge files from project metrics."""
|
||||
out = Path(output_dir)
|
||||
root = Path(repo_root) if repo_root else None
|
||||
click.echo(f"Generating badges in {out}...")
|
||||
badges = generate_badges(out)
|
||||
badges = generate_badges(out, repo_root=root)
|
||||
click.echo(f"\nGenerated {len(badges)} badges:")
|
||||
for name, badge in badges.items():
|
||||
click.echo(f" {name}: {badge['label']}={badge['message']} ({badge['color']})")
|
||||
|
||||
+224
-60
File diff suppressed because it is too large
Load Diff
@@ -21,40 +21,31 @@ from devx.exceptions import APIError
|
||||
|
||||
|
||||
class TestReadTaskid:
|
||||
def test_prefers_branch_name_over_file(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-60\n")
|
||||
# Branch name takes priority over .taskid file
|
||||
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
|
||||
|
||||
def test_falls_back_to_file_when_no_branch_match(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-60\n")
|
||||
# No task ID in branch name → fall back to .taskid
|
||||
assert read_taskid("some-branch") == "DEVX-60"
|
||||
|
||||
def test_falls_back_to_branch_name(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
def test_extracts_from_branch_name(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
|
||||
|
||||
def test_returns_empty_when_no_file_no_match(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
def test_returns_empty_when_no_match(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert read_taskid("feature-branch") == ""
|
||||
|
||||
def test_empty_file_falls_back_to_branch(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("\n")
|
||||
assert read_taskid("DEVX-42-test") == "DEVX-42"
|
||||
|
||||
def test_warns_on_stale_taskid_file(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-60\n")
|
||||
# Branch name takes priority, but stale .taskid should produce a warning
|
||||
# Branch name takes priority, stale .taskid should produce deprecation warning
|
||||
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
|
||||
captured = capsys.readouterr()
|
||||
assert "WARNING" in captured.out
|
||||
assert "DEVX-60" in captured.out
|
||||
assert "DEVX-19" in captured.out
|
||||
combined = captured.out + captured.err
|
||||
assert "WARNING" in combined
|
||||
assert "deprecated" in combined
|
||||
assert "DEVX-60" in combined
|
||||
assert "DEVX-19" in combined
|
||||
|
||||
def test_no_warning_when_taskid_file_absent(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert read_taskid("DEVX-42-test") == "DEVX-42"
|
||||
captured = capsys.readouterr()
|
||||
assert "WARNING" not in captured.out
|
||||
|
||||
|
||||
# -- extract_task_id (legacy fallback) --
|
||||
@@ -217,7 +208,6 @@ class TestMain:
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_commits.return_value = [
|
||||
@@ -244,7 +234,7 @@ class TestMain:
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_no_task_id_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
# No .taskid file, no DEVX-N in branch name
|
||||
# No DEVX-N in branch name
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["feature-branch", "DEVX-19: test", "owner/repo", "7"])
|
||||
assert result.exit_code != 0
|
||||
@@ -254,7 +244,6 @@ class TestMain:
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_invalid_pr_title_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-19-fix", "Bad title", "owner/repo", "7"])
|
||||
@@ -264,32 +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)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
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")
|
||||
@@ -298,7 +289,6 @@ class TestMain:
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_commits.return_value = [
|
||||
@@ -323,7 +313,6 @@ class TestMain:
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
"""When no conventional commit message is found in PR commits, raises."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_commits.return_value = []
|
||||
@@ -341,7 +330,6 @@ class TestMain:
|
||||
def test_invalid_pr_number_raises(self, tmp_path, monkeypatch) -> None:
|
||||
"""Non-integer PR number should raise."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: Test", "owner/repo", "not-a-number"])
|
||||
assert result.exit_code != 0
|
||||
@@ -351,7 +339,6 @@ class TestMain:
|
||||
def test_invalid_repo_format_raises(self, tmp_path, monkeypatch) -> None:
|
||||
"""Repo without owner/name should raise."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: Test", "invalidrepo", "7"])
|
||||
assert result.exit_code != 0
|
||||
@@ -360,12 +347,11 @@ 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)
|
||||
(tmp_path / ".taskid").write_text("DEVX-19\n")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_commits.return_value = [
|
||||
@@ -375,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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
+198
-142
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for scripts/generate_badges.py."""
|
||||
"""Unit tests for devx/tools/generate_badges.py."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -8,7 +8,13 @@ from click.testing import CliRunner
|
||||
from devx.tools.generate_badges import (
|
||||
COLOR_HEX,
|
||||
cli,
|
||||
collect_coverage_and_tests,
|
||||
collect_doc_coverage,
|
||||
collect_quality,
|
||||
coverage_color,
|
||||
detect_coverage_target,
|
||||
detect_package_name,
|
||||
detect_testpaths,
|
||||
doc_coverage_color,
|
||||
extract_coverage,
|
||||
extract_doc_coverage,
|
||||
@@ -17,10 +23,107 @@ from devx.tools.generate_badges import (
|
||||
make_badge,
|
||||
read_version,
|
||||
render_svg,
|
||||
resolve_repo_root,
|
||||
run_command,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveRepoRoot:
|
||||
def test_uses_github_workspace_when_set(self, tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.setenv("GITHUB_WORKSPACE", str(tmp_path))
|
||||
assert resolve_repo_root() == tmp_path
|
||||
|
||||
def test_falls_back_to_cwd_when_no_workspace(self, tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.delenv("GITHUB_WORKSPACE", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert resolve_repo_root() == tmp_path
|
||||
|
||||
def test_falls_back_to_cwd_when_workspace_invalid(self, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.setenv("GITHUB_WORKSPACE", "/nonexistent/path")
|
||||
result = resolve_repo_root()
|
||||
assert result == Path.cwd()
|
||||
|
||||
|
||||
class TestDetectPackageName:
|
||||
def test_detects_package_with_init(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src"
|
||||
pkg = src / "mypkg"
|
||||
pkg.mkdir(parents=True)
|
||||
(pkg / "__init__.py").write_text('__version__ = "1.0.0"\n')
|
||||
assert detect_package_name(tmp_path) == "mypkg"
|
||||
|
||||
def test_returns_none_when_no_src(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
assert detect_package_name(tmp_path) is None
|
||||
|
||||
def test_returns_none_when_no_init(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src"
|
||||
pkg = src / "mypkg"
|
||||
pkg.mkdir(parents=True)
|
||||
# No __init__.py
|
||||
assert detect_package_name(tmp_path) is None
|
||||
|
||||
def test_picks_first_package_alphabetically(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src"
|
||||
for name in ["zpkg", "apkg"]:
|
||||
d = src / name
|
||||
d.mkdir(parents=True)
|
||||
(d / "__init__.py").write_text("")
|
||||
assert detect_package_name(tmp_path) == "apkg"
|
||||
|
||||
def test_skips_non_dir_entries(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src"
|
||||
src.mkdir(parents=True)
|
||||
(src / "README.md").write_text("not a package")
|
||||
pkg = src / "mypkg"
|
||||
pkg.mkdir()
|
||||
(pkg / "__init__.py").write_text("")
|
||||
assert detect_package_name(tmp_path) == "mypkg"
|
||||
|
||||
|
||||
class TestDetectCoverageTarget:
|
||||
def test_parses_from_pyproject(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
'[tool.pytest.ini_options]\naddopts = "--cov=src/devx --cov-report=term-missing"\n'
|
||||
)
|
||||
assert detect_coverage_target(tmp_path) == "src/devx"
|
||||
|
||||
def test_falls_back_to_src_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src"
|
||||
pkg = src / "mypkg"
|
||||
pkg.mkdir(parents=True)
|
||||
(pkg / "__init__.py").write_text('__version__ = "1.0"\n')
|
||||
assert detect_coverage_target(tmp_path) == "src/mypkg"
|
||||
|
||||
def test_returns_none_when_no_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
assert detect_coverage_target(tmp_path) is None
|
||||
|
||||
|
||||
class TestDetectTestpaths:
|
||||
def test_parses_from_pyproject(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
(tmp_path / "scripts" / "tests").mkdir(parents=True)
|
||||
(tmp_path / "tests" / "unit").mkdir(parents=True)
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
'[tool.pytest.ini_options]\ntestpaths = ["scripts/tests", "tests/unit"]\n'
|
||||
)
|
||||
assert detect_testpaths(tmp_path) == ["scripts/tests", "tests/unit"]
|
||||
|
||||
def test_filters_nonexistent_paths(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
(tmp_path / "tests").mkdir()
|
||||
(tmp_path / "pyproject.toml").write_text('[tool.pytest.ini_options]\ntestpaths = ["tests", "nonexistent"]\n')
|
||||
assert detect_testpaths(tmp_path) == ["tests"]
|
||||
|
||||
def test_falls_back_to_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
(tmp_path / "tests").mkdir()
|
||||
assert detect_testpaths(tmp_path) == ["tests"]
|
||||
|
||||
def test_returns_empty_when_no_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
assert detect_testpaths(tmp_path) == []
|
||||
|
||||
def test_returns_empty_when_pyproject_has_no_testpaths(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
(tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]\naddopts = '-ra'\n")
|
||||
assert detect_testpaths(tmp_path) == []
|
||||
|
||||
|
||||
class TestRunCommand:
|
||||
@patch("devx.tools.generate_badges.subprocess.run")
|
||||
def test_returns_returncode_stdout_stderr(self, mock_run: MagicMock) -> None:
|
||||
@@ -157,100 +260,122 @@ class TestDocCoverageColor:
|
||||
|
||||
|
||||
class TestReadVersion:
|
||||
@patch("devx.tools.generate_badges._find_package_init")
|
||||
def test_reads_version_from_init(self, mock_find: MagicMock) -> None:
|
||||
mock_init = MagicMock()
|
||||
mock_init.read_text.return_value = '__version__ = "0.5.0"\n'
|
||||
mock_find.return_value = mock_init
|
||||
assert read_version() == "0.5.0"
|
||||
def test_reads_version_from_init(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src" / "mypkg"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text('__version__ = "0.5.0"\n')
|
||||
assert read_version(tmp_path) == "0.5.0"
|
||||
|
||||
@patch("devx.tools.generate_badges._find_package_init")
|
||||
def test_returns_unknown_when_no_version(self, mock_find: MagicMock) -> None:
|
||||
mock_init = MagicMock()
|
||||
mock_init.read_text.return_value = "no version here\n"
|
||||
mock_find.return_value = mock_init
|
||||
assert read_version() == "unknown"
|
||||
def test_returns_unknown_when_no_version(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
src = tmp_path / "src" / "mypkg"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text("no version here\n")
|
||||
assert read_version(tmp_path) == "unknown"
|
||||
|
||||
@patch("devx.tools.generate_badges._find_package_init", return_value=None)
|
||||
def test_returns_unknown_when_no_init(self, mock_find: MagicMock) -> None:
|
||||
assert read_version() == "unknown"
|
||||
def test_returns_unknown_when_no_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
assert read_version(tmp_path) == "unknown"
|
||||
|
||||
@patch("devx.tools.generate_badges.detect_package_name", return_value="mypkg")
|
||||
def test_returns_unknown_when_init_missing(self, mock_pkg: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
# Package detected but __init__.py doesn't exist (edge case)
|
||||
assert read_version(tmp_path) == "unknown"
|
||||
|
||||
|
||||
class TestFindPackageInit:
|
||||
@patch("devx.tools.generate_badges.REPO_ROOT")
|
||||
def test_no_src_dir(self, mock_root: MagicMock) -> None:
|
||||
"""Returns None when src/ directory doesn't exist."""
|
||||
from devx.tools.generate_badges import _find_package_init
|
||||
class TestCollectCoverageAndTests:
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.detect_testpaths", return_value=["tests"])
|
||||
@patch("devx.tools.generate_badges.detect_coverage_target", return_value="src/devx")
|
||||
def test_extracts_coverage_and_tests(
|
||||
self, mock_target: MagicMock, mock_testpaths: MagicMock, mock_run: MagicMock, tmp_path: Path
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (0, "1018 passed in 4.23s\nTOTAL 3546 0 100%", "")
|
||||
cov, tests = collect_coverage_and_tests(tmp_path)
|
||||
assert cov["message"] == "100%"
|
||||
assert tests["message"] == "1018 passing"
|
||||
|
||||
mock_src = MagicMock()
|
||||
mock_src.exists.return_value = False
|
||||
mock_root.__truediv__ = MagicMock(return_value=mock_src)
|
||||
assert _find_package_init() is None
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.detect_testpaths", return_value=["tests"])
|
||||
@patch("devx.tools.generate_badges.detect_coverage_target", return_value="src/devx")
|
||||
def test_returns_unknown_when_no_match(
|
||||
self, mock_target: MagicMock, mock_testpaths: MagicMock, mock_run: MagicMock, tmp_path: Path
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (1, "garbled output", "some error")
|
||||
cov, tests = collect_coverage_and_tests(tmp_path)
|
||||
assert cov["message"] == "unknown"
|
||||
assert tests["message"] == "unknown"
|
||||
|
||||
@patch("devx.tools.generate_badges.REPO_ROOT")
|
||||
def test_no_version_in_init_files(self, mock_root: MagicMock, tmp_path: Path) -> None:
|
||||
"""Returns None when no __init__.py has __version__."""
|
||||
from devx.tools.generate_badges import _find_package_init
|
||||
@patch("devx.tools.generate_badges.detect_coverage_target", return_value=None)
|
||||
def test_returns_lightgrey_when_no_target(self, mock_target: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
cov, tests = collect_coverage_and_tests(tmp_path)
|
||||
assert cov["message"] == "unknown"
|
||||
assert cov["color"] == "lightgrey"
|
||||
assert tests["message"] == "unknown"
|
||||
assert tests["color"] == "lightgrey"
|
||||
|
||||
src_dir = tmp_path / "src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "__init__.py").write_text("# no version here\n")
|
||||
mock_root.__truediv__ = MagicMock(return_value=src_dir)
|
||||
assert _find_package_init() is None
|
||||
|
||||
@patch("devx.tools.generate_badges.REPO_ROOT")
|
||||
def test_finds_init_with_version(self, mock_root: MagicMock, tmp_path: Path) -> None:
|
||||
"""Returns the __init__.py that has __version__."""
|
||||
from devx.tools.generate_badges import _find_package_init
|
||||
class TestCollectDocCoverage:
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
def test_extracts_doc_coverage(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (0, "Doc coverage: 20/20 (100%)", "")
|
||||
badge = collect_doc_coverage(tmp_path)
|
||||
assert badge["message"] == "100%"
|
||||
|
||||
src_dir = tmp_path / "src"
|
||||
pkg_dir = src_dir / "mypkg"
|
||||
pkg_dir.mkdir(parents=True)
|
||||
(src_dir / "__init__.py").write_text("# no version\n")
|
||||
(pkg_dir / "__init__.py").write_text('__version__ = "1.0.0"\n')
|
||||
mock_root.__truediv__ = MagicMock(return_value=src_dir)
|
||||
result = _find_package_init()
|
||||
assert result is not None
|
||||
assert "__version__" in result.read_text()
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
def test_returns_unknown_when_no_match(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (1, "no doc coverage", "error")
|
||||
badge = collect_doc_coverage(tmp_path)
|
||||
assert badge["message"] == "unknown"
|
||||
|
||||
@patch("devx.tools.generate_badges.REPO_ROOT")
|
||||
def test_handles_oserror(self, mock_root: MagicMock, tmp_path: Path) -> None:
|
||||
"""Handles OSError when reading init files."""
|
||||
from devx.tools.generate_badges import _find_package_init
|
||||
|
||||
src_dir = tmp_path / "src"
|
||||
src_dir.mkdir()
|
||||
init_file = src_dir / "__init__.py"
|
||||
init_file.write_text('__version__ = "1.0.0"\n')
|
||||
mock_root.__truediv__ = MagicMock(return_value=src_dir)
|
||||
# Patch Path.read_text to raise OSError
|
||||
with patch.object(Path, "read_text", side_effect=OSError("permission denied")):
|
||||
result = _find_package_init()
|
||||
assert result is None
|
||||
class TestCollectQuality:
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
def test_all_pass_returns_a(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (0, "", "")
|
||||
badge = collect_quality(tmp_path)
|
||||
assert badge["message"] == "A"
|
||||
assert badge["color"] == "brightgreen"
|
||||
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
def test_lint_failure_returns_f(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (1, "", "some error")
|
||||
badge = collect_quality(tmp_path)
|
||||
assert badge["message"] == "F"
|
||||
assert badge["color"] == "red"
|
||||
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
def test_tool_not_installed_counts_as_pass(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
mock_run.return_value = (1, "", "No module named ruff")
|
||||
badge = collect_quality(tmp_path)
|
||||
assert badge["message"] == "A"
|
||||
|
||||
|
||||
class TestGenerateBadges:
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.collect_quality")
|
||||
@patch("devx.tools.generate_badges.collect_doc_coverage")
|
||||
@patch("devx.tools.generate_badges.collect_coverage_and_tests")
|
||||
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
|
||||
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
|
||||
@patch("devx.tools.generate_badges.detect_package_name", return_value="devx")
|
||||
def test_generates_all_badge_files(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_pkg: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_cov_tests: MagicMock,
|
||||
mock_doc: MagicMock,
|
||||
mock_quality: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.return_value = (0, "output", "")
|
||||
badges = generate_badges(tmp_path)
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
mock_cov_tests.return_value = (
|
||||
make_badge("coverage", "100%", "brightgreen"),
|
||||
make_badge("tests", "573 passing", "brightgreen"),
|
||||
)
|
||||
mock_doc.return_value = make_badge("docs", "100%", "brightgreen")
|
||||
mock_quality.return_value = make_badge("code quality", "A", "brightgreen")
|
||||
|
||||
badges = generate_badges(tmp_path, repo_root=tmp_path)
|
||||
|
||||
expected = {"coverage", "tests", "docs", "quality", "version", "python"}
|
||||
assert set(badges.keys()) == expected
|
||||
|
||||
# Verify SVG files were written
|
||||
for name in expected:
|
||||
svg_file = tmp_path / f"{name}.svg"
|
||||
assert svg_file.exists()
|
||||
@@ -258,78 +383,10 @@ class TestGenerateBadges:
|
||||
assert content.startswith("<svg")
|
||||
assert "</svg>" in content
|
||||
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
|
||||
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
|
||||
def test_quality_badge_pass_when_all_lint_passes(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.return_value = (0, "output", "")
|
||||
badges = generate_badges(tmp_path)
|
||||
assert badges["quality"]["message"] == "A"
|
||||
assert badges["quality"]["color"] == "brightgreen"
|
||||
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
|
||||
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
|
||||
def test_quality_badge_fails_when_lint_fails(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.side_effect = [
|
||||
(0, "output", ""),
|
||||
(0, "output", ""),
|
||||
(1, "error", ""),
|
||||
(0, "output", ""),
|
||||
(0, "output", ""),
|
||||
(0, "output", ""),
|
||||
]
|
||||
badges = generate_badges(tmp_path)
|
||||
assert badges["quality"]["message"] == "F"
|
||||
assert badges["quality"]["color"] == "red"
|
||||
|
||||
@patch("devx.tools.generate_badges.run_command")
|
||||
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("devx.tools.generate_badges.extract_coverage", return_value=None)
|
||||
@patch("devx.tools.generate_badges.extract_test_count", return_value=None)
|
||||
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=None)
|
||||
def test_badges_show_unknown_when_extraction_fails(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.return_value = (1, "garbled output", "")
|
||||
badges = generate_badges(tmp_path)
|
||||
assert badges["coverage"]["message"] == "unknown"
|
||||
assert badges["coverage"]["color"] == "red"
|
||||
assert badges["tests"]["message"] == "unknown"
|
||||
assert badges["tests"]["color"] == "red"
|
||||
assert badges["docs"]["message"] == "unknown"
|
||||
assert badges["docs"]["color"] == "red"
|
||||
|
||||
|
||||
class TestCli:
|
||||
@patch("devx.tools.generate_badges.generate_badges")
|
||||
def test_cli_generates_badges(self, mock_gen: MagicMock, tmp_path: Path) -> None:
|
||||
def test_cli_generates_badges(self, mock_gen: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
|
||||
mock_gen.return_value = {
|
||||
"coverage": make_badge("coverage", "100%", "brightgreen"),
|
||||
"tests": make_badge("tests", "573 passing", "brightgreen"),
|
||||
@@ -339,7 +396,6 @@ class TestCli:
|
||||
assert result.exit_code == 0
|
||||
assert "Generating badges" in result.output
|
||||
assert "Generated 2 badges" in result.output
|
||||
mock_gen.assert_called_once_with(tmp_path)
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 "")
|
||||
|
||||
@@ -122,11 +122,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)
|
||||
@@ -162,6 +169,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 +195,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 +218,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 +246,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 +267,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 +301,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 +326,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 +343,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 +351,39 @@ 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
|
||||
assert "Gitea release v1.0.0 created" in result.output
|
||||
mock_tea.create_release.assert_called_once()
|
||||
|
||||
@@ -287,3 +287,19 @@ class TestMain:
|
||||
result = runner.invoke(push_badges.main, ["--no-readme-update"])
|
||||
assert result.exit_code != 0
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
|
||||
class TestRepoRoot:
|
||||
def test_uses_github_workspace(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("GITHUB_WORKSPACE", str(tmp_path))
|
||||
assert push_badges._repo_root() == tmp_path
|
||||
|
||||
def test_falls_back_to_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("GITHUB_WORKSPACE", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert push_badges._repo_root() == tmp_path
|
||||
|
||||
def test_falls_back_when_workspace_invalid(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("GITHUB_WORKSPACE", "/nonexistent")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert push_badges._repo_root() == tmp_path
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user