Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
700d3b55c6 | ||
|
|
701363d935 | ||
|
|
ddb2d43b4e | ||
|
|
fe6373b682 | ||
|
|
33434d5750 | ||
|
|
dfcd33c35b | ||
|
|
0aefe1f028 | ||
|
|
891b0b5dba | ||
|
|
8f15e5402b | ||
|
|
9060cd7b1e | ||
|
|
f687ab5aa3 | ||
|
|
4738b594b2 | ||
|
|
f6e9f2013b | ||
|
|
8450f33e88 | ||
|
|
904812dfae | ||
|
|
95384c26e1 | ||
|
|
c10b759f6b | ||
|
|
4c818b32ce | ||
|
|
bbf09c07df | ||
|
|
faff67aa6a | ||
|
|
3e4dfcadb7 | ||
|
|
2385747bed | ||
|
|
3625bf2872 | ||
|
|
8affccfa35 | ||
|
|
7ae85b6955 | ||
|
|
f702286779 | ||
|
|
d4b58fa86f | ||
|
|
44c6c42ede | ||
|
|
14c585971d | ||
|
|
3e2342c347 | ||
|
|
f3d5b0ff45 | ||
|
|
75e36897cc | ||
|
|
0eef69a902 | ||
|
|
f9130884d1 |
@@ -82,6 +82,8 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.REPO_TOKEN }}
|
token: ${{ secrets.REPO_TOKEN }}
|
||||||
- name: Set up environment
|
- name: Set up environment
|
||||||
|
env:
|
||||||
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
run: make setup-release
|
run: make setup-release
|
||||||
- name: Configure git
|
- name: Configure git
|
||||||
run: |
|
run: |
|
||||||
@@ -106,13 +108,8 @@ jobs:
|
|||||||
echo "No tag found — skipping publish"
|
echo "No tag found — skipping publish"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
HEAD_MSG=$(git log -1 --format=%s)
|
echo "Publishing release $TAG (idempotent — skips if already published)..."
|
||||||
if echo "$HEAD_MSG" | grep -q "^release: ${TAG}"; then
|
python3 -m devx.ci.publish "$TAG" "${{ github.repository }}"
|
||||||
echo "Publishing release $TAG..."
|
|
||||||
python3 -m devx.ci.publish "$TAG" "${{ github.repository }}"
|
|
||||||
else
|
|
||||||
echo "HEAD is not a release commit for $TAG — skipping publish"
|
|
||||||
fi
|
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -66,9 +66,8 @@ src/devx/
|
|||||||
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
|
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
|
||||||
│ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures)
|
│ ├── 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)
|
│ ├── 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
|
│ ├── 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
|
│ ├── check_translations.py # Translation completeness check
|
||||||
│ └── doc_coverage.py # Documentation coverage check
|
│ └── doc_coverage.py # Documentation coverage check
|
||||||
├── tools/ # Developer tooling modules (run locally or by CI)
|
├── tools/ # Developer tooling modules (run locally or by CI)
|
||||||
@@ -81,7 +80,7 @@ src/devx/
|
|||||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||||
├── distribute_molecule.py # Distribute molecule scenarios across runners (--roles-root for multi-role)
|
├── 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
|
├── molecule_all.py # Run all molecule scenarios locally
|
||||||
└── platforms.py # Supported molecule platforms
|
└── platforms.py # Supported molecule platforms
|
||||||
```
|
```
|
||||||
@@ -163,7 +162,7 @@ the PR. Then add the `ready-to-merge` label. The auto-merge workflow will:
|
|||||||
1. **Validate** PR title format (`DEVX-N: <vikunja task title>`) and match against Vikunja task title
|
1. **Validate** PR title format (`DEVX-N: <vikunja task title>`) and match against Vikunja task title
|
||||||
2. **Check** that at least one substantive APPROVE review exists
|
2. **Check** that at least one substantive APPROVE review exists
|
||||||
3. Wait for all CI checks to pass (including the `pr-review` job)
|
3. Wait for all CI checks to pass (including the `pr-review` job)
|
||||||
4. Squash-merge with title: `DEVX-N <conventional commit message>` (space-separated, no colon after DEVX-N)
|
4. Squash-merge with title: `DEVX-N: <conventional commit message>`
|
||||||
5. The post-merge workflow marks the Vikunja task as done
|
5. The post-merge workflow marks the Vikunja task as done
|
||||||
6. The release workflow automatically versions, tags, and publishes
|
6. The release workflow automatically versions, tags, and publishes
|
||||||
|
|
||||||
@@ -260,7 +259,7 @@ by `python -m devx.tools.install_tools` and configured by
|
|||||||
|
|
||||||
### git-cliff Commit Preprocessing
|
### git-cliff Commit Preprocessing
|
||||||
|
|
||||||
Merge commits on master have the format `DEVX-N <conventional commit>`. The
|
Merge commits on master have the format `DEVX-N: <conventional commit>`. The
|
||||||
`cliff.toml` includes a `commit_preprocessors` entry that strips the `DEVX-N `
|
`cliff.toml` includes a `commit_preprocessors` entry that strips the `DEVX-N `
|
||||||
prefix before parsing. This ensures all merged work appears in the changelog.
|
prefix before parsing. This ensures all merged work appears in the changelog.
|
||||||
|
|
||||||
@@ -283,7 +282,7 @@ setuptools via `dynamic = ["version"]` in `pyproject.toml`.
|
|||||||
| Branch name | `DEVX-N-short-description` | `DEVX-12-add-release-script` |
|
| Branch name | `DEVX-N-short-description` | `DEVX-12-add-release-script` |
|
||||||
| Branch commits | `<conventional commit>` | `feat: add release script` |
|
| Branch commits | `<conventional commit>` | `feat: add release script` |
|
||||||
| PR title | `DEVX-N: <vikunja task title>` | `DEVX-12: Add release automation` |
|
| PR title | `DEVX-N: <vikunja task title>` | `DEVX-12: Add release automation` |
|
||||||
| Merge commit | `DEVX-N <conventional commit>` | `DEVX-12 feat: add release script` |
|
| Merge commit | `DEVX-N: <conventional commit>` | `DEVX-12: feat: add release script` |
|
||||||
|
|
||||||
### Task ID Resolution
|
### Task ID Resolution
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,56 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.14.2] - 2026-06-26
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Make repo arg optional in publish CLI, auto-detect from GITHUB_REPOSITORY
|
||||||
|
|
||||||
|
## [0.14.1] - 2026-06-25
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Handle 'already a release' error idempotently in publish
|
||||||
|
|
||||||
|
## [0.14.0] - 2026-06-25
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add FORCE_DEPLOY env var, --git flag, --from-tag flag
|
||||||
|
|
||||||
|
## [0.13.0] - 2026-06-25
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add --force flag to classify_changes, fix api_clients coverage
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Squash-merge format uses space not colon after task ID
|
||||||
|
- Revert squash-merge format to use colon after task ID
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-06-25
|
||||||
|
|
||||||
|
## [0.12.5] - 2026-06-25
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Make PyPI publish failures non-fatal
|
||||||
|
|
||||||
|
## [0.12.4] - 2026-06-25
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Pass REPO_TOKEN to setup-release so tea login is configured
|
||||||
|
- Guarantee Gitea release for every tag
|
||||||
|
|
||||||
|
## [0.12.3] - 2026-06-25
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Remove JUnit reporting from devx
|
||||||
|
|
||||||
## [0.12.2] - 2026-06-25
|
## [0.12.2] - 2026-06-25
|
||||||
|
|
||||||
### Bug Fixes
|
### 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/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](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/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Why devx?
|
## Why devx?
|
||||||
|
|
||||||
@@ -175,11 +175,8 @@ python -m devx.ci.discover_runners --owner oblachno-oss --repo devx --indices
|
|||||||
python -m devx.ci.distribute_files --pattern "tests/integration/test_*.py" \
|
python -m devx.ci.distribute_files --pattern "tests/integration/test_*.py" \
|
||||||
--runner-index 1 --max-runners 3 --github-env
|
--runner-index 1 --max-runners 3 --github-env
|
||||||
|
|
||||||
# Merge JUnit XML reports from parallel runners
|
# Run pytest with cross-runner fail-fast
|
||||||
python -m devx.ci.merge_junit --pattern "junit-results/runner-*.xml" --output junit-merged.xml
|
python -m devx.ci.integration_guard -- test_a.py test_b.py
|
||||||
|
|
||||||
# Run pytest with cross-runner fail-fast and JUnit output
|
|
||||||
python -m devx.ci.integration_guard --junit-output junit-results/runner-1.xml -- test_a.py test_b.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Developer tools
|
### Developer tools
|
||||||
@@ -227,7 +224,7 @@ python -m devx.molecule.distribute_molecule --list # list all scena
|
|||||||
python -m devx.molecule.distribute_molecule --list-platforms # list platforms
|
python -m devx.molecule.distribute_molecule --list-platforms # list platforms
|
||||||
|
|
||||||
# Run molecule tests with cross-runner fail-fast
|
# 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
|
python -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2
|
||||||
|
|
||||||
# Run all molecule scenarios locally (sequential)
|
# Run all molecule scenarios locally (sequential)
|
||||||
@@ -274,8 +271,7 @@ devx --version
|
|||||||
| `devx ci discover-runners` | Discover available Gitea Actions runners |
|
| `devx ci discover-runners` | Discover available Gitea Actions runners |
|
||||||
| `devx ci distribute-files` | Distribute files across parallel runners (round-robin) |
|
| `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 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 integration-guard` | Run pytest with cross-runner fail-fast |
|
||||||
| `devx ci merge-junit` | Merge JUnit XML reports from parallel runners |
|
|
||||||
| `devx ci notify-failure` | Create a Gitea issue when a CI workflow fails |
|
| `devx ci 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 post-merge` | Update Vikunja task after a merge to master |
|
||||||
| `devx ci pr-review` | Run automated PR review |
|
| `devx ci pr-review` | Run automated PR review |
|
||||||
|
|||||||
+7
-7
@@ -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/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](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/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ devx is a self-contained Python package under `src/devx/`:
|
|||||||
- **CI automation** (`devx.ci`) — release, publish, auto_merge, pr_review,
|
- **CI automation** (`devx.ci`) — release, publish, auto_merge, pr_review,
|
||||||
classify_changes, sync_wiki, push_badges, check_translations, doc_coverage,
|
classify_changes, sync_wiki, push_badges, check_translations, doc_coverage,
|
||||||
validate_commit_msg, detect_release_commit, notify_failure, post_merge,
|
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,
|
- **Dev tools** (`devx.tools`) — setup, install_tools, check_test_speed,
|
||||||
configure_repo, generate_badges, generate_cliff_config, install_checkmake
|
configure_repo, generate_badges, generate_cliff_config, install_checkmake
|
||||||
- **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible
|
- **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible
|
||||||
|
|||||||
@@ -31,9 +31,8 @@ src/devx/
|
|||||||
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
|
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
|
||||||
│ ├── push_badges.py # Generate and push quality badges
|
│ ├── push_badges.py # Generate and push quality badges
|
||||||
│ ├── notify_failure.py # Create Gitea issues on CI failures
|
│ ├── 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
|
│ ├── 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
|
│ ├── discover_runners.py # Dynamic Gitea runner discovery
|
||||||
│ ├── check_translations.py # Translation completeness check
|
│ ├── check_translations.py # Translation completeness check
|
||||||
│ └── doc_coverage.py # Documentation coverage check
|
│ └── doc_coverage.py # Documentation coverage check
|
||||||
@@ -296,18 +295,11 @@ Distributes files matching a glob pattern across N parallel runners
|
|||||||
(round-robin). Writes the assigned file list for the current runner to
|
(round-robin). Writes the assigned file list for the current runner to
|
||||||
`$GITHUB_ENV`. Used for splitting test suites across CI runners.
|
`$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`
|
### `integration_guard.py`
|
||||||
|
|
||||||
Runs pytest with the same cross-runner failure detection mechanism used by
|
Runs pytest with the same cross-runner failure detection mechanism used by
|
||||||
`molecule_ci_guard`. If any other integration-tests matrix runner reports
|
`molecule_ci_guard`. If any other integration-tests matrix runner reports
|
||||||
failure, the current pytest subprocess is killed and this runner exits early.
|
failure, the current pytest subprocess is killed and this runner exits early.
|
||||||
Generates JUnit XML via pytest's `--junitxml` flag.
|
|
||||||
|
|
||||||
## Developer tools (`devx.tools`)
|
## Developer tools (`devx.tools`)
|
||||||
|
|
||||||
@@ -382,9 +374,8 @@ platforms.
|
|||||||
|
|
||||||
Runs molecule tests sequentially while polling the Gitea API for other runner
|
Runs molecule tests sequentially while polling the Gitea API for other runner
|
||||||
failures. If any other molecule matrix runner reports failure, the current
|
failures. If any other molecule matrix runner reports failure, the current
|
||||||
molecule subprocess is killed and this runner exits early. Generates JUnit
|
molecule subprocess is killed and this runner exits early. Supports both
|
||||||
XML when `--junit-output` is provided. Supports both single-role (4-part) and
|
single-role (4-part) and multi-role (5-part) pair encoding.
|
||||||
multi-role (5-part) pair encoding.
|
|
||||||
|
|
||||||
### `molecule_all.py`
|
### `molecule_all.py`
|
||||||
|
|
||||||
|
|||||||
@@ -447,12 +447,10 @@ python -m devx.molecule.distribute_molecule --list-platforms
|
|||||||
### `molecule_ci_guard.py`
|
### `molecule_ci_guard.py`
|
||||||
|
|
||||||
Runs molecule tests sequentially while polling the Gitea API for other runner
|
Runs molecule tests sequentially while polling the Gitea API for other runner
|
||||||
failures. Aborts early if another runner fails the same job. Generates JUnit
|
failures. Aborts early if another runner fails the same job.
|
||||||
XML when `--junit-output` is provided.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m devx.molecule.molecule_ci_guard [--roles-root <dir>] \
|
python -m devx.molecule.molecule_ci_guard [--roles-root <dir>] pair1 pair2 ...
|
||||||
[--junit-output <file>] pair1 pair2 ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### `validate_commit_msg.py`
|
### `validate_commit_msg.py`
|
||||||
@@ -503,16 +501,6 @@ python -m devx.ci.distribute_files --pattern <glob> --runner-index <i> \
|
|||||||
--max-runners <n> [--github-env] [--skip-if-excess]
|
--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`
|
### `integration_guard.py`
|
||||||
|
|
||||||
Runs pytest with cross-runner failure detection. If any other
|
Runs pytest with cross-runner failure detection. If any other
|
||||||
@@ -520,7 +508,7 @@ integration-tests matrix runner reports failure, the current pytest
|
|||||||
subprocess is killed and this runner exits early.
|
subprocess is killed and this runner exits early.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m devx.ci.integration_guard --junit-output <file> -- <pytest args>
|
python -m devx.ci.integration_guard -- <pytest args>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Release process summary
|
## Release process summary
|
||||||
|
|||||||
@@ -137,13 +137,13 @@ Options:
|
|||||||
|
|
||||||
### `devx ci integration-guard`
|
### `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
|
other integration-tests matrix runner reports failure, the current pytest
|
||||||
subprocess is killed and this runner exits early with code 1.
|
subprocess is killed and this runner exits early with code 1.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
devx ci integration-guard --junit-output junit-results/runner-1.xml -- test_a.py test_b.py
|
devx ci integration-guard -- 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 -- -x -v --tb=short test_a.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Environment variables:
|
Environment variables:
|
||||||
@@ -154,16 +154,6 @@ Environment variables:
|
|||||||
- `MATRIX_INDEX` — current matrix index (runner-index)
|
- `MATRIX_INDEX` — current matrix index (runner-index)
|
||||||
- `GITEA_REPOSITORY` — repository in `owner/repo` format
|
- `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`
|
### `devx ci notify-failure`
|
||||||
|
|
||||||
Create a Gitea issue when a CI workflow fails. Uses the tea CLI for issue
|
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
|
```bash
|
||||||
devx molecule guard pair1 pair2 pair3
|
devx molecule guard pair1 pair2 pair3
|
||||||
devx molecule guard --roles-root ansible/roles pair1 pair2
|
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:
|
Each pair is encoded as:
|
||||||
@@ -470,7 +459,6 @@ Each pair is encoded as:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
- `--roles-root <dir>` — roles root directory for multi-role repos
|
- `--roles-root <dir>` — roles root directory for multi-role repos
|
||||||
- `--junit-output <file>` — generate JUnit XML report
|
|
||||||
|
|
||||||
Environment variables:
|
Environment variables:
|
||||||
- `GITEA_URL` — base URL of the Gitea instance
|
- `GITEA_URL` — base URL of the Gitea instance
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||||
|
|
||||||
__version__ = "0.12.2"
|
__version__ = "0.14.2"
|
||||||
|
|||||||
@@ -192,6 +192,17 @@ class GiteaClient:
|
|||||||
r = self._request("GET", f"/pulls/{pr_number}")
|
r = self._request("GET", f"/pulls/{pr_number}")
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
def list_prs(self, state: str = "all", **params: Any) -> list[dict[str, Any]]:
|
||||||
|
"""List pull requests, optionally filtered by state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: ``open``, ``closed``, ``all`` (default).
|
||||||
|
**params: Additional query params (e.g. ``q="keyword"`` for title search).
|
||||||
|
"""
|
||||||
|
params.setdefault("state", state)
|
||||||
|
r = self._request("GET", "/pulls", params=params)
|
||||||
|
return r.json()
|
||||||
|
|
||||||
def get_pr_files(self, pr_number: str | int) -> list[dict[str, Any]]:
|
def get_pr_files(self, pr_number: str | int) -> list[dict[str, Any]]:
|
||||||
"""Fetch the list of files changed in a pull request."""
|
"""Fetch the list of files changed in a pull request."""
|
||||||
r = self._request("GET", f"/pulls/{pr_number}/files")
|
r = self._request("GET", f"/pulls/{pr_number}/files")
|
||||||
@@ -345,5 +356,28 @@ class VikunjaClient:
|
|||||||
def post_comment(self, task_id: int, comment: str) -> None:
|
def post_comment(self, task_id: int, comment: str) -> None:
|
||||||
self._request("PUT", f"/tasks/{task_id}/comments", json={"comment": comment})
|
self._request("PUT", f"/tasks/{task_id}/comments", json={"comment": comment})
|
||||||
|
|
||||||
|
def list_comments(self, task_id: int) -> list[dict[str, Any]]:
|
||||||
|
"""List all comments on a task."""
|
||||||
|
r = self._request("GET", f"/tasks/{task_id}/comments")
|
||||||
|
return r.json()
|
||||||
|
|
||||||
def update_task(self, task_id: int, **fields: Any) -> None:
|
def update_task(self, task_id: int, **fields: Any) -> None:
|
||||||
|
"""Update task fields via POST (full replacement semantics).
|
||||||
|
|
||||||
|
Warning: Vikunja's POST /tasks/{id} replaces the entire task body.
|
||||||
|
Unspecified fields are reset to their type defaults. Use
|
||||||
|
``update_task_safe`` to preserve existing fields.
|
||||||
|
"""
|
||||||
self._request("POST", f"/tasks/{task_id}", json=fields)
|
self._request("POST", f"/tasks/{task_id}", json=fields)
|
||||||
|
|
||||||
|
def update_task_safe(self, task_id: int, **fields: Any) -> dict[str, Any]:
|
||||||
|
"""Safely update task fields using read-merge-write pattern.
|
||||||
|
|
||||||
|
Fetches the full task body, merges the provided fields on top,
|
||||||
|
and POSTs the complete body back. This prevents accidental
|
||||||
|
resets of done status, title, etc.
|
||||||
|
"""
|
||||||
|
task = self.get_task(task_id)
|
||||||
|
task.update(fields)
|
||||||
|
r = self._request("POST", f"/tasks/{task_id}", json=task)
|
||||||
|
return r.json()
|
||||||
|
|||||||
@@ -615,11 +615,29 @@ def _write_github_output(key: str, value: str) -> None:
|
|||||||
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps). "
|
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps). "
|
||||||
"Outputs 'user-facing-changed' and '<tag>-changed' for each configured tag.",
|
"Outputs 'user-facing-changed' and '<tag>-changed' for each configured tag.",
|
||||||
)
|
)
|
||||||
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None:
|
@click.option(
|
||||||
|
"--force",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Force user-facing-changed=true regardless of actual changes. "
|
||||||
|
"Used by workflow_dispatch with force-deploy input.",
|
||||||
|
)
|
||||||
|
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool, force: bool) -> None:
|
||||||
"""Classify git changes and output results."""
|
"""Classify git changes and output results."""
|
||||||
classifier = _get_classifier()
|
classifier = _get_classifier()
|
||||||
available_tags = list(classifier.config.tags.keys())
|
available_tags = list(classifier.config.tags.keys())
|
||||||
|
|
||||||
|
# --force can also be activated via FORCE_DEPLOY env var (for workflow_dispatch)
|
||||||
|
if os.environ.get("FORCE_DEPLOY", "").lower() == "true":
|
||||||
|
force = True
|
||||||
|
|
||||||
|
if force and github_output:
|
||||||
|
_write_github_output("user-facing-changed", "true")
|
||||||
|
for tag in available_tags:
|
||||||
|
_write_github_output(f"{tag}-changed", "true")
|
||||||
|
click.echo("Forced user-facing-changed=true via --force flag.")
|
||||||
|
return
|
||||||
|
|
||||||
if base is None:
|
if base is None:
|
||||||
base = get_latest_tag()
|
base = get_latest_tag()
|
||||||
if not base:
|
if not base:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Detect whether the latest git commit is a release commit.
|
"""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
|
This script writes ``is-release=true`` or ``is-release=false`` to
|
||||||
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
|
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
reports failure, the current pytest subprocess is killed and this runner
|
||||||
exits early with code 1.
|
exits early with code 1.
|
||||||
|
|
||||||
JUnit XML is generated via pytest's ``--junitxml`` flag (passed through
|
|
||||||
to the pytest invocation).
|
|
||||||
|
|
||||||
Usage::
|
Usage::
|
||||||
|
|
||||||
python3 -m devx.ci.integration_guard \\
|
python3 -m devx.ci.integration_guard \\
|
||||||
--junit-output junit-results/runner-1.xml \\
|
|
||||||
-- test_file1.py test_file2.py
|
-- test_file1.py test_file2.py
|
||||||
|
|
||||||
# With pytest options
|
# With pytest options
|
||||||
python3 -m devx.ci.integration_guard \\
|
python3 -m devx.ci.integration_guard \\
|
||||||
--junit-output junit-results/runner-1.xml \\
|
|
||||||
-- -x -v --tb=short test_file1.py
|
-- -x -v --tb=short test_file1.py
|
||||||
|
|
||||||
Environment variables:
|
Environment variables:
|
||||||
@@ -51,12 +46,7 @@ POLL_INTERVAL = 10
|
|||||||
|
|
||||||
@click.command(context_settings={"ignore_unknown_options": True})
|
@click.command(context_settings={"ignore_unknown_options": True})
|
||||||
@click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED, required=True)
|
@click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED, required=True)
|
||||||
@click.option(
|
def cli(pytest_args: tuple[str, ...]) -> None:
|
||||||
"--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:
|
|
||||||
"""Run pytest with cross-runner failure detection."""
|
"""Run pytest with cross-runner failure detection."""
|
||||||
gitea_url = os.environ.get("GITEA_URL", "")
|
gitea_url = os.environ.get("GITEA_URL", "")
|
||||||
token = os.environ.get("REPO_TOKEN", "")
|
token = os.environ.get("REPO_TOKEN", "")
|
||||||
@@ -93,8 +83,6 @@ def cli(pytest_args: tuple[str, ...], junit_output: str | None) -> None:
|
|||||||
poller.start()
|
poller.start()
|
||||||
|
|
||||||
cmd = [sys.executable, "-m", "pytest"]
|
cmd = [sys.executable, "-m", "pytest"]
|
||||||
if junit_output:
|
|
||||||
cmd.extend(["--junitxml", junit_output])
|
|
||||||
cmd.extend(pytest_args)
|
cmd.extend(pytest_args)
|
||||||
|
|
||||||
click.echo(f"Running: {' '.join(cmd)}")
|
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()
|
|
||||||
+97
-19
@@ -135,13 +135,21 @@ def publish_to_gitea_registry(registry_url: str, token: str) -> None:
|
|||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise click.ClickException(
|
# Twine writes errors to stdout (not stderr), so check both.
|
||||||
_(
|
combined = f"{result.stdout}\n{result.stderr}".strip()
|
||||||
"Oops! Gitea PyPI registry publish failed:\n{stderr}",
|
# 409 Conflict means the package version is already published —
|
||||||
stderr=result.stderr.strip(),
|
# 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,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
else:
|
||||||
click.echo(_("Published to Gitea PyPI registry."))
|
click.echo(_("Published to Gitea PyPI registry."))
|
||||||
|
|
||||||
|
|
||||||
def _default_gitea_registry_url() -> str:
|
def _default_gitea_registry_url() -> str:
|
||||||
@@ -161,9 +169,37 @@ def _default_gitea_registry_url() -> str:
|
|||||||
return f"{base}/api/packages/{owner}/pypi"
|
return f"{base}/api/packages/{owner}/pypi"
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_tag() -> str | None:
|
||||||
|
"""Get the latest git tag, or None if no tags exist."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run( # nosec
|
||||||
|
["git", "describe", "--tags", "--abbrev=0"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return result.stdout.strip()
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_release_commit(tag: str) -> bool:
|
||||||
|
"""Check if HEAD commit message starts with 'release: <tag>'."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run( # nosec
|
||||||
|
["git", "log", "-1", "--format=%s"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return result.stdout.strip().startswith(f"release: {tag}")
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.argument("tag")
|
@click.argument("tag", required=False)
|
||||||
@click.argument("repo")
|
@click.argument("repo", required=False)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--registry-url",
|
"--registry-url",
|
||||||
default=None,
|
default=None,
|
||||||
@@ -178,7 +214,37 @@ def _default_gitea_registry_url() -> str:
|
|||||||
help="Skip package build and PyPI publish (for non-Python repos that only "
|
help="Skip package build and PyPI publish (for non-Python repos that only "
|
||||||
"need a Gitea release with git-cliff notes).",
|
"need a Gitea release with git-cliff notes).",
|
||||||
)
|
)
|
||||||
def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> None:
|
@click.option(
|
||||||
|
"--from-tag",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Auto-detect latest tag and check if HEAD is a release commit. "
|
||||||
|
"Skips publish if no tag or HEAD is not a release commit for that tag.",
|
||||||
|
)
|
||||||
|
def main(
|
||||||
|
tag: str | None,
|
||||||
|
repo: str | None,
|
||||||
|
registry_url: str | None,
|
||||||
|
skip_build: bool,
|
||||||
|
from_tag: bool,
|
||||||
|
) -> None:
|
||||||
|
if repo is None:
|
||||||
|
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||||
|
if not repo:
|
||||||
|
raise click.ClickException(_("REPO argument is required (or set GITHUB_REPOSITORY env var)."))
|
||||||
|
if from_tag:
|
||||||
|
detected_tag = get_latest_tag()
|
||||||
|
if not detected_tag:
|
||||||
|
click.echo(_("No tag found — skipping publish."))
|
||||||
|
return
|
||||||
|
if not is_release_commit(detected_tag):
|
||||||
|
click.echo(_("HEAD is not a release commit for {tag} — skipping publish.", tag=detected_tag))
|
||||||
|
return
|
||||||
|
tag = detected_tag
|
||||||
|
click.echo(_("Publishing release {tag}...", tag=tag))
|
||||||
|
|
||||||
|
if not tag:
|
||||||
|
raise click.ClickException(_("Tag is required (or use --from-tag)."))
|
||||||
gitea_token = os.environ.get("REPO_TOKEN", "")
|
gitea_token = os.environ.get("REPO_TOKEN", "")
|
||||||
if not gitea_token:
|
if not gitea_token:
|
||||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||||
@@ -194,18 +260,27 @@ def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> Non
|
|||||||
if not skip_build:
|
if not skip_build:
|
||||||
build_package()
|
build_package()
|
||||||
|
|
||||||
if pypi_token:
|
try:
|
||||||
# Standard PyPI flow takes precedence when PYPI_TOKEN is set
|
if pypi_token:
|
||||||
publish_to_pypi(pypi_token)
|
# Standard PyPI flow takes precedence when PYPI_TOKEN is set
|
||||||
elif registry_url:
|
publish_to_pypi(pypi_token)
|
||||||
# Gitea PyPI registry flow
|
elif registry_url:
|
||||||
publish_to_gitea_registry(registry_url, gitea_token)
|
# Gitea PyPI registry flow
|
||||||
else:
|
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(
|
click.echo(
|
||||||
_(
|
_(
|
||||||
"PYPI_TOKEN not set and no registry URL configured — "
|
"PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}",
|
||||||
"skipping PyPI publish. No worries, we'll just create the Gitea release."
|
error=str(e),
|
||||||
)
|
),
|
||||||
|
err=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
click.echo(_("--skip-build: skipping package build and PyPI publish."))
|
click.echo(_("--skip-build: skipping package build and PyPI publish."))
|
||||||
@@ -227,6 +302,9 @@ def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> Non
|
|||||||
try:
|
try:
|
||||||
tea.create_release(repo, tag=tag, title=tag, body=release_body)
|
tea.create_release(repo, tag=tag, title=tag, body=release_body)
|
||||||
except TeaCLIError as e:
|
except TeaCLIError as e:
|
||||||
|
if "already" in str(e).lower() and "release" in str(e).lower():
|
||||||
|
click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag))
|
||||||
|
return
|
||||||
raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None
|
raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None
|
||||||
|
|
||||||
click.echo(
|
click.echo(
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ def commit_release_changes(new_version: str) -> bool:
|
|||||||
if status.returncode == 0:
|
if status.returncode == 0:
|
||||||
click.echo(_("No staged changes — version and changelog already up to date."))
|
click.echo(_("No staged changes — version and changelog already up to date."))
|
||||||
return False
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ task ID format for each project.
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import subprocess # nosec B404
|
import subprocess # nosec B404
|
||||||
|
import sys
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
@@ -23,6 +24,17 @@ from devx.i18n import _
|
|||||||
MASTER_TASK_ID_RE = re.compile(rf"^{TASK_PREFIX}-\d+:")
|
MASTER_TASK_ID_RE = re.compile(rf"^{TASK_PREFIX}-\d+:")
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_commit_msg() -> str:
|
||||||
|
"""Get the latest commit message from git."""
|
||||||
|
result = subprocess.run( # nosec
|
||||||
|
["git", "log", "-1", "--format=%B"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
def first_line(text: str) -> str:
|
def first_line(text: str) -> str:
|
||||||
return text.split("\n")[0]
|
return text.split("\n")[0]
|
||||||
|
|
||||||
@@ -41,11 +53,26 @@ def get_branch() -> str:
|
|||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.argument("commit_msg_file")
|
@click.argument("commit_msg_file", required=False)
|
||||||
@click.option("--branch", default=None, help="Override branch detection (for CI use).")
|
@click.option("--branch", default=None, help="Override branch detection (for CI use).")
|
||||||
def main(commit_msg_file: str, branch: str | None) -> None:
|
@click.option(
|
||||||
with open(commit_msg_file) as f:
|
"--git",
|
||||||
msg = f.read().strip()
|
"from_git",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Read commit message from git log instead of a file.",
|
||||||
|
)
|
||||||
|
def main(commit_msg_file: str | None, branch: str | None, from_git: bool) -> None:
|
||||||
|
if from_git:
|
||||||
|
msg = get_latest_commit_msg()
|
||||||
|
elif commit_msg_file:
|
||||||
|
if commit_msg_file == "-":
|
||||||
|
msg = sys.stdin.read().strip()
|
||||||
|
else:
|
||||||
|
with open(commit_msg_file) as f:
|
||||||
|
msg = f.read().strip()
|
||||||
|
else:
|
||||||
|
raise click.ClickException(_("Provide a commit message file or use --git."))
|
||||||
|
|
||||||
if branch is None:
|
if branch is None:
|
||||||
branch = get_branch()
|
branch = get_branch()
|
||||||
|
|||||||
+1
-8
@@ -158,17 +158,10 @@ def ci_distribute_files(args: tuple[str, ...]) -> None:
|
|||||||
_run_module("devx.ci.distribute_files", list(args))
|
_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")
|
@ci.command("integration-guard")
|
||||||
@click.argument("args", nargs=-1)
|
@click.argument("args", nargs=-1)
|
||||||
def ci_integration_guard(args: tuple[str, ...]) -> None:
|
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))
|
_run_module("devx.ci.integration_guard", list(args))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
reports failure, the current molecule subprocess is killed and this runner
|
||||||
exits early with code 1.
|
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::
|
Usage::
|
||||||
|
|
||||||
# Single-role (grm-style)
|
# Single-role (grm-style)
|
||||||
python3 -m devx.molecule.molecule_ci_guard pair1 pair2 ...
|
python3 -m devx.molecule.molecule_ci_guard pair1 pair2 ...
|
||||||
# Multi-role (infra-style)
|
# Multi-role (infra-style)
|
||||||
python3 -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2 ...
|
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:
|
Environment variables:
|
||||||
GITEA_URL Base URL of the Gitea instance.
|
GITEA_URL Base URL of the Gitea instance.
|
||||||
@@ -43,7 +38,6 @@ import subprocess # nosec B404
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import xml.etree.ElementTree as ET # nosec B405
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import click
|
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"
|
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.command()
|
||||||
@click.argument("pairs", nargs=-1, required=True)
|
@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(
|
@click.option(
|
||||||
"--roles-root",
|
"--roles-root",
|
||||||
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
||||||
default=None,
|
default=None,
|
||||||
help="Root directory for multi-role pairs (e.g. ansible/roles). Required when pairs use 5-part format.",
|
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."""
|
"""Run molecule pairs sequentially, stop if another CI runner fails."""
|
||||||
gitea_url = os.environ.get("GITEA_URL", "")
|
gitea_url = os.environ.get("GITEA_URL", "")
|
||||||
token = os.environ.get("REPO_TOKEN", "")
|
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()
|
poller.start()
|
||||||
|
|
||||||
testcases: list[dict] = []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for pair in pairs:
|
for pair in pairs:
|
||||||
if failed_event.is_set():
|
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)
|
env = build_env_for_pair(pair, base_env)
|
||||||
cwd = resolve_role_dir(role, roles_root, repo_root)
|
cwd = resolve_role_dir(role, roles_root, repo_root)
|
||||||
|
|
||||||
start = time.time()
|
|
||||||
process = subprocess.Popen( # nosec B603
|
process = subprocess.Popen( # nosec B603
|
||||||
cmd,
|
cmd,
|
||||||
cwd=str(cwd),
|
cwd=str(cwd),
|
||||||
@@ -282,18 +235,6 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
|
|||||||
with contextlib.suppress(ProcessLookupError):
|
with contextlib.suppress(ProcessLookupError):
|
||||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||||
process.wait()
|
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)
|
sys.exit(1)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -303,23 +244,9 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
rc = process.returncode
|
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:
|
if rc != 0:
|
||||||
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
|
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)
|
sys.exit(rc)
|
||||||
|
|
||||||
click.echo(_("PASSED: {pair}", pair=pair))
|
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."))
|
click.echo(_("All molecule tests passed."))
|
||||||
if junit_output:
|
|
||||||
write_junit_report(junit_output, testcases, current_index)
|
|
||||||
finally:
|
finally:
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
|
|
||||||
|
|||||||
+64
-16
@@ -599,6 +599,14 @@
|
|||||||
"ru": "Generated {file} with prefix '{prefix}'.",
|
"ru": "Generated {file} with prefix '{prefix}'.",
|
||||||
"zh": "Generated {file} with prefix '{prefix}'."
|
"zh": "Generated {file} with prefix '{prefix}'."
|
||||||
},
|
},
|
||||||
|
"Gitea PyPI registry: {tag} already published — continuing.": {
|
||||||
|
"bg": "Gitea PyPI registry: {tag} вече е публикуван — продължава.",
|
||||||
|
"de": "Gitea PyPI-Registry: {tag} bereits veröffentlicht — wird fortgesetzt.",
|
||||||
|
"en": "Gitea PyPI registry: {tag} already published — continuing.",
|
||||||
|
"pl": "Gitea PyPI registry: {tag} już opublikowano — kontynuacja.",
|
||||||
|
"ru": "Gitea PyPI registry: {tag} уже опубликован — продолжаем.",
|
||||||
|
"zh": "Gitea PyPI registry: {tag} 已发布 — 继续。"
|
||||||
|
},
|
||||||
"Gitea release {tag} already exists — skipping creation.": {
|
"Gitea release {tag} already exists — skipping creation.": {
|
||||||
"bg": "Gitea release {tag} вече съществува — прескачане на създаването.",
|
"bg": "Gitea release {tag} вече съществува — прескачане на създаването.",
|
||||||
"de": "Gitea-Release {tag} existiert bereits — Erstellung übersprungen.",
|
"de": "Gitea-Release {tag} existiert bereits — Erstellung übersprungen.",
|
||||||
@@ -631,6 +639,14 @@
|
|||||||
"ru": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
|
"ru": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
|
||||||
"zh": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping."
|
"zh": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping."
|
||||||
},
|
},
|
||||||
|
"HEAD is not a release commit for {tag} — skipping publish.": {
|
||||||
|
"bg": "HEAD is not a release commit for {tag} — skipping publish.",
|
||||||
|
"de": "HEAD is not a release commit for {tag} — skipping publish.",
|
||||||
|
"en": "HEAD is not a release commit for {tag} — skipping publish.",
|
||||||
|
"pl": "HEAD nie jest commitem wydania dla {tag} — pomijanie publikacji.",
|
||||||
|
"ru": "HEAD is not a release commit for {tag} — skipping publish.",
|
||||||
|
"zh": "HEAD is not a release commit for {tag} — skipping publish."
|
||||||
|
},
|
||||||
"HTTP error: {status} — {message}": {
|
"HTTP error: {status} — {message}": {
|
||||||
"bg": "HTTP грешка: {status} — {message}",
|
"bg": "HTTP грешка: {status} — {message}",
|
||||||
"de": "HTTP-Fehler: {status} — {message}",
|
"de": "HTTP-Fehler: {status} — {message}",
|
||||||
@@ -727,14 +743,6 @@
|
|||||||
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
|
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
|
||||||
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
|
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
|
||||||
},
|
},
|
||||||
"Merged {count} reports: {tests} tests, {failures} failures → {output}": {
|
|
||||||
"bg": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
|
|
||||||
"de": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
|
|
||||||
"en": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
|
|
||||||
"pl": "Scalono {count} raportów: {tests} testów, {failures} niepowodzeń → {output}",
|
|
||||||
"ru": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
|
|
||||||
"zh": "Merged {count} reports: {tests} tests, {failures} failures → {output}"
|
|
||||||
},
|
|
||||||
"Module {mod} has no main() function": {
|
"Module {mod} has no main() function": {
|
||||||
"bg": "Модул {mod} няма функция main()",
|
"bg": "Модул {mod} няма функция main()",
|
||||||
"de": "Modul {mod} hat keine main()-Funktion",
|
"de": "Modul {mod} hat keine main()-Funktion",
|
||||||
@@ -783,14 +791,6 @@
|
|||||||
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
|
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
|
||||||
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
|
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
|
||||||
},
|
},
|
||||||
"No JUnit reports found matching {pattern} — skipping merge.": {
|
|
||||||
"bg": "No JUnit reports found matching {pattern} — skipping merge.",
|
|
||||||
"de": "No JUnit reports found matching {pattern} — skipping merge.",
|
|
||||||
"en": "No JUnit reports found matching {pattern} — skipping merge.",
|
|
||||||
"pl": "Nie znaleziono raportów JUnit pasujących do {pattern} — pomijanie scalania.",
|
|
||||||
"ru": "No JUnit reports found matching {pattern} — skipping merge.",
|
|
||||||
"zh": "No JUnit reports found matching {pattern} — skipping merge."
|
|
||||||
},
|
|
||||||
"No changes between {base} and {head}.": {
|
"No changes between {base} and {head}.": {
|
||||||
"bg": "No changes between {base} and {head}.",
|
"bg": "No changes between {base} and {head}.",
|
||||||
"de": "No changes between {base} and {head}.",
|
"de": "No changes between {base} and {head}.",
|
||||||
@@ -807,6 +807,14 @@
|
|||||||
"ru": "No staged changes — version and changelog already up to date.",
|
"ru": "No staged changes — version and changelog already up to date.",
|
||||||
"zh": "No staged changes — version and changelog already up to date."
|
"zh": "No staged changes — version and changelog already up to date."
|
||||||
},
|
},
|
||||||
|
"No tag found — skipping publish.": {
|
||||||
|
"bg": "No tag found — skipping publish.",
|
||||||
|
"de": "No tag found — skipping publish.",
|
||||||
|
"en": "No tag found — skipping publish.",
|
||||||
|
"pl": "Nie znaleziono tagu — pomijanie publikacji.",
|
||||||
|
"ru": "No tag found — skipping publish.",
|
||||||
|
"zh": "No tag found — skipping publish."
|
||||||
|
},
|
||||||
"No tags found — treating all changes as user-facing.": {
|
"No tags found — treating all changes as user-facing.": {
|
||||||
"bg": "No tags found — treating all changes as user-facing.",
|
"bg": "No tags found — treating all changes as user-facing.",
|
||||||
"de": "No tags found — treating all changes as user-facing.",
|
"de": "No tags found — treating all changes as user-facing.",
|
||||||
@@ -975,6 +983,14 @@
|
|||||||
"ru": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
"ru": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||||
"zh": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit."
|
"zh": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit."
|
||||||
},
|
},
|
||||||
|
"Provide a commit message file or use --git.": {
|
||||||
|
"bg": "Provide a commit message file or use --git.",
|
||||||
|
"de": "Provide a commit message file or use --git.",
|
||||||
|
"en": "Provide a commit message file or use --git.",
|
||||||
|
"pl": "Podaj plik komunikatu commitu lub użyj --git.",
|
||||||
|
"ru": "Provide a commit message file or use --git.",
|
||||||
|
"zh": "Provide a commit message file or use --git."
|
||||||
|
},
|
||||||
"Published to Gitea PyPI registry.": {
|
"Published to Gitea PyPI registry.": {
|
||||||
"bg": "Публикувано в Gitea PyPI registry.",
|
"bg": "Публикувано в Gitea PyPI registry.",
|
||||||
"de": "In der Gitea PyPI-Registry veröffentlicht.",
|
"de": "In der Gitea PyPI-Registry veröffentlicht.",
|
||||||
@@ -991,6 +1007,14 @@
|
|||||||
"ru": "Опубликовано в PyPI.",
|
"ru": "Опубликовано в PyPI.",
|
||||||
"zh": "已发布到 PyPI。"
|
"zh": "已发布到 PyPI。"
|
||||||
},
|
},
|
||||||
|
"Publishing release {tag}...": {
|
||||||
|
"bg": "Publishing release {tag}...",
|
||||||
|
"de": "Publishing release {tag}...",
|
||||||
|
"en": "Publishing release {tag}...",
|
||||||
|
"pl": "Publikowanie wydania {tag}...",
|
||||||
|
"ru": "Publishing release {tag}...",
|
||||||
|
"zh": "Publishing release {tag}..."
|
||||||
|
},
|
||||||
"Pushed release commit to master.": {
|
"Pushed release commit to master.": {
|
||||||
"bg": "Pushed release commit to master.",
|
"bg": "Pushed release commit to master.",
|
||||||
"de": "Pushed release commit to master.",
|
"de": "Pushed release commit to master.",
|
||||||
@@ -999,6 +1023,14 @@
|
|||||||
"ru": "Pushed release commit to master.",
|
"ru": "Pushed release commit to master.",
|
||||||
"zh": "Pushed release commit to master."
|
"zh": "Pushed release commit to master."
|
||||||
},
|
},
|
||||||
|
"PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}": {
|
||||||
|
"bg": "Публикуването в PyPI неуспешно (некритично — продължава към Gitea release):\n{error}",
|
||||||
|
"de": "PyPI-Veröffentlichung fehlgeschlagen (nicht fatal — Gitea-Release wird fortgesetzt):\n{error}",
|
||||||
|
"en": "PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}",
|
||||||
|
"pl": "Publikacja PyPI nie powiodła się (niekrytyczne — kontynuacja Gitea release):\n{error}",
|
||||||
|
"ru": "Публикация в PyPI не удалась (некритично — продолжаем создание Gitea release):\n{error}",
|
||||||
|
"zh": "PyPI 发布失败(非致命 — 继续创建 Gitea release):\n{error}"
|
||||||
|
},
|
||||||
"Release creation failed: {error}": {
|
"Release creation failed: {error}": {
|
||||||
"bg": "Release creation failed: {error}",
|
"bg": "Release creation failed: {error}",
|
||||||
"de": "Release creation failed: {error}",
|
"de": "Release creation failed: {error}",
|
||||||
@@ -1095,6 +1127,22 @@
|
|||||||
"ru": "Tag consistency check failed.",
|
"ru": "Tag consistency check failed.",
|
||||||
"zh": "Tag consistency check failed."
|
"zh": "Tag consistency check failed."
|
||||||
},
|
},
|
||||||
|
"Tag is required (or use --from-tag).": {
|
||||||
|
"bg": "Tag is required (or use --from-tag).",
|
||||||
|
"de": "Tag is required (or use --from-tag).",
|
||||||
|
"en": "Tag is required (or use --from-tag).",
|
||||||
|
"pl": "Tag jest wymagany (lub użyj --from-tag).",
|
||||||
|
"ru": "Tag is required (or use --from-tag).",
|
||||||
|
"zh": "Tag is required (or use --from-tag)."
|
||||||
|
},
|
||||||
|
"REPO argument is required (or set GITHUB_REPOSITORY env var).": {
|
||||||
|
"bg": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||||
|
"de": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||||
|
"en": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||||
|
"pl": "Argument REPO jest wymagany (lub ustaw zmienną GITHUB_REPOSITORY).",
|
||||||
|
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||||
|
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
|
||||||
|
},
|
||||||
"Tag v{version} already existed. Publish workflow should already have been triggered.": {
|
"Tag v{version} already existed. Publish workflow should already have been triggered.": {
|
||||||
"bg": "Tag v{version} already existed. Publish workflow should already have been triggered.",
|
"bg": "Tag v{version} already existed. Publish workflow should already have been triggered.",
|
||||||
"de": "Tag v{version} already existed. Publish workflow should already have been triggered.",
|
"de": "Tag v{version} already existed. Publish workflow should already have been triggered.",
|
||||||
|
|||||||
@@ -278,6 +278,35 @@ class TestGiteaClient:
|
|||||||
timeout=DEFAULT_TIMEOUT,
|
timeout=DEFAULT_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_list_prs(self) -> None:
|
||||||
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
|
client._session.request = MagicMock(
|
||||||
|
return_value=_mock_response([{"number": 1, "title": "feat: add"}, {"number": 2, "title": "fix: bug"}])
|
||||||
|
)
|
||||||
|
|
||||||
|
result = client.list_prs()
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0]["number"] == 1
|
||||||
|
client._session.request.assert_called_once_with(
|
||||||
|
"GET",
|
||||||
|
"https://git.example.com/repos/owner/repo/pulls",
|
||||||
|
params={"state": "all"},
|
||||||
|
timeout=DEFAULT_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_list_prs_with_params(self) -> None:
|
||||||
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
|
client._session.request = MagicMock(return_value=_mock_response([{"number": 3, "title": "docs: update"}]))
|
||||||
|
|
||||||
|
result = client.list_prs(state="closed", q="docs")
|
||||||
|
assert len(result) == 1
|
||||||
|
client._session.request.assert_called_once_with(
|
||||||
|
"GET",
|
||||||
|
"https://git.example.com/repos/owner/repo/pulls",
|
||||||
|
params={"state": "closed", "q": "docs"},
|
||||||
|
timeout=DEFAULT_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
def test_get_pr_reviews(self) -> None:
|
def test_get_pr_reviews(self) -> None:
|
||||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "state": "APPROVED"}]))
|
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "state": "APPROVED"}]))
|
||||||
@@ -566,6 +595,46 @@ class TestVikunjaClient:
|
|||||||
json={"done": True},
|
json={"done": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_list_comments(self) -> None:
|
||||||
|
client = VikunjaClient("https://work.example.com", "tok")
|
||||||
|
client._session.request = MagicMock(
|
||||||
|
return_value=_mock_response([{"id": 1, "comment": "first"}, {"id": 2, "comment": "second"}])
|
||||||
|
)
|
||||||
|
|
||||||
|
result = client.list_comments(42)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0]["comment"] == "first"
|
||||||
|
client._session.request.assert_called_once_with(
|
||||||
|
"GET",
|
||||||
|
"https://work.example.com/tasks/42/comments",
|
||||||
|
timeout=DEFAULT_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_update_task_safe(self) -> None:
|
||||||
|
client = VikunjaClient("https://work.example.com", "tok")
|
||||||
|
client._session.request = MagicMock(
|
||||||
|
side_effect=[
|
||||||
|
_mock_response({"id": 42, "title": "My task", "done": False}),
|
||||||
|
_mock_response({"id": 42, "title": "My task", "done": True}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = client.update_task_safe(42, done=True)
|
||||||
|
assert result["done"] is True
|
||||||
|
assert result["title"] == "My task"
|
||||||
|
assert client._session.request.call_count == 2
|
||||||
|
client._session.request.assert_any_call(
|
||||||
|
"GET",
|
||||||
|
"https://work.example.com/tasks/42",
|
||||||
|
timeout=DEFAULT_TIMEOUT,
|
||||||
|
)
|
||||||
|
client._session.request.assert_any_call(
|
||||||
|
"POST",
|
||||||
|
"https://work.example.com/tasks/42",
|
||||||
|
timeout=DEFAULT_TIMEOUT,
|
||||||
|
json={"id": 42, "title": "My task", "done": True},
|
||||||
|
)
|
||||||
|
|
||||||
@patch("devx.api_clients.time.sleep")
|
@patch("devx.api_clients.time.sleep")
|
||||||
def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None:
|
def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None:
|
||||||
client = VikunjaClient("https://work.example.com", "tok")
|
client = VikunjaClient("https://work.example.com", "tok")
|
||||||
|
|||||||
@@ -742,3 +742,95 @@ class TestGithubOutput:
|
|||||||
assert "user-facing-changed=true" in content
|
assert "user-facing-changed=true" in content
|
||||||
# No tag outputs since no tags are configured
|
# No tag outputs since no tags are configured
|
||||||
assert "ansible-changed" not in content
|
assert "ansible-changed" not in content
|
||||||
|
|
||||||
|
@patch("devx.ci.classify_changes._get_classifier")
|
||||||
|
def test_force_outputs_true(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""--force with --github-output writes user-facing-changed=true and all tags true."""
|
||||||
|
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||||
|
gh_file = tmp_path / "output.txt"
|
||||||
|
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--github-output", "--force"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
content = gh_file.read_text()
|
||||||
|
assert "user-facing-changed=true" in content
|
||||||
|
assert "ansible-changed=true" in content
|
||||||
|
assert "Forced user-facing-changed=true" in result.output
|
||||||
|
|
||||||
|
@patch("devx.ci.classify_changes._get_classifier")
|
||||||
|
def test_force_without_github_output_does_nothing(
|
||||||
|
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""--force without --github-output falls through to normal classification."""
|
||||||
|
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||||
|
monkeypatch.setenv("GITHUB_OUTPUT", str(tmp_path / "output.txt"))
|
||||||
|
with patch.object(classify_changes_mod, "get_latest_tag", return_value="v1.0"):
|
||||||
|
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--force", "--quiet"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.output.strip() == "false"
|
||||||
|
|
||||||
|
@patch("devx.ci.classify_changes._get_classifier")
|
||||||
|
def test_force_no_tags(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""--force with --github-output and no tags writes only user-facing-changed=true."""
|
||||||
|
mock_clf.return_value = ChangeClassifier(
|
||||||
|
ClassifierConfig(
|
||||||
|
infrastructure=[".gitea/**"],
|
||||||
|
tags={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
gh_file = tmp_path / "output.txt"
|
||||||
|
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--github-output", "--force"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
content = gh_file.read_text()
|
||||||
|
assert "user-facing-changed=true" in content
|
||||||
|
assert "ansible-changed" not in content
|
||||||
|
|
||||||
|
@patch("devx.ci.classify_changes._get_classifier")
|
||||||
|
def test_force_deploy_env_var(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""FORCE_DEPLOY=true env var activates force mode without --force flag."""
|
||||||
|
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||||
|
gh_file = tmp_path / "output.txt"
|
||||||
|
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||||
|
monkeypatch.setenv("FORCE_DEPLOY", "true")
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--github-output"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
content = gh_file.read_text()
|
||||||
|
assert "user-facing-changed=true" in content
|
||||||
|
assert "ansible-changed=true" in content
|
||||||
|
|
||||||
|
@patch("devx.ci.classify_changes._get_classifier")
|
||||||
|
def test_force_deploy_env_var_false(
|
||||||
|
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""FORCE_DEPLOY=false does not activate force mode."""
|
||||||
|
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||||
|
gh_file = tmp_path / "output.txt"
|
||||||
|
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||||
|
monkeypatch.setenv("FORCE_DEPLOY", "false")
|
||||||
|
with patch.object(classify_changes_mod, "get_latest_tag", return_value="v1.0"):
|
||||||
|
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--github-output"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
content = gh_file.read_text()
|
||||||
|
assert "user-facing-changed=false" in content
|
||||||
|
|
||||||
|
@patch("devx.ci.classify_changes._get_classifier")
|
||||||
|
def test_force_flag_overrides_env_var(
|
||||||
|
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""--force flag works even when FORCE_DEPLOY=false."""
|
||||||
|
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||||
|
gh_file = tmp_path / "output.txt"
|
||||||
|
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||||
|
monkeypatch.setenv("FORCE_DEPLOY", "false")
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--github-output", "--force"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
content = gh_file.read_text()
|
||||||
|
assert "user-facing-changed=true" in content
|
||||||
|
|||||||
@@ -233,13 +233,6 @@ class TestNewCiCommands:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
mock_run.assert_called_once_with("devx.ci.distribute_files", ["--pattern", "*.py"])
|
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")
|
@patch("devx.cli._run_module")
|
||||||
def test_ci_integration_guard(self, mock_run: MagicMock) -> None:
|
def test_ci_integration_guard(self, mock_run: MagicMock) -> None:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|||||||
@@ -43,26 +43,6 @@ class TestCli:
|
|||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "failed" in result.output
|
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:
|
def test_pytest_args_passed_through(self) -> None:
|
||||||
with (
|
with (
|
||||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
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 os
|
||||||
import subprocess # nosec B404
|
import subprocess # nosec B404
|
||||||
import time
|
import time
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
@@ -22,7 +21,6 @@ from devx.molecule.molecule_ci_guard import (
|
|||||||
parse_pair,
|
parse_pair,
|
||||||
poll_for_other_failures,
|
poll_for_other_failures,
|
||||||
resolve_role_dir,
|
resolve_role_dir,
|
||||||
write_junit_report,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -504,40 +502,6 @@ class TestResolveRoleDir:
|
|||||||
assert result == tmp_path / "ansible" / "roles" / "gitea-runner"
|
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:
|
class TestCliMultiRole:
|
||||||
def test_multi_role_pair_passes(self, tmp_path: Path) -> None:
|
def test_multi_role_pair_passes(self, tmp_path: Path) -> None:
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
@@ -563,133 +527,3 @@ class TestCliMultiRole:
|
|||||||
)
|
)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "All molecule tests passed" in result.output
|
assert "All molecule tests passed" in result.output
|
||||||
|
|
||||||
def test_junit_output_written(self, tmp_path: Path) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
roles_root = tmp_path / "ansible" / "roles"
|
|
||||||
(roles_root / "gitea-runner").mkdir(parents=True)
|
|
||||||
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
|
||||||
patch("time.sleep"),
|
|
||||||
):
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = 0
|
|
||||||
proc.returncode = 0
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
cli,
|
|
||||||
[
|
|
||||||
"--roles-root",
|
|
||||||
str(roles_root),
|
|
||||||
"--junit-output",
|
|
||||||
junit_path,
|
|
||||||
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert Path(junit_path).exists()
|
|
||||||
|
|
||||||
def test_junit_output_on_failure(self, tmp_path: Path) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
roles_root = tmp_path / "ansible" / "roles"
|
|
||||||
(roles_root / "gitea-runner").mkdir(parents=True)
|
|
||||||
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("time.sleep"),
|
|
||||||
):
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = 1
|
|
||||||
proc.returncode = 1
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
cli,
|
|
||||||
[
|
|
||||||
"--roles-root",
|
|
||||||
str(roles_root),
|
|
||||||
"--junit-output",
|
|
||||||
junit_path,
|
|
||||||
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert Path(junit_path).exists()
|
|
||||||
tree = ET.parse(junit_path)
|
|
||||||
assert tree.getroot().get("failures") == "1"
|
|
||||||
|
|
||||||
def test_junit_output_on_cancellation(self, tmp_path: Path) -> None:
|
|
||||||
"""JUnit report is written when a runner is cancelled by another runner's failure."""
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
real_sleep = time.sleep
|
|
||||||
roles_root = tmp_path / "ansible" / "roles"
|
|
||||||
(roles_root / "gitea-runner").mkdir(parents=True)
|
|
||||||
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
|
|
||||||
call_count = [0]
|
|
||||||
|
|
||||||
def get_jobs_side_effect(*args, **kwargs):
|
|
||||||
call_count[0] += 1
|
|
||||||
if call_count[0] < 2:
|
|
||||||
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
|
|
||||||
return [
|
|
||||||
{"name": "molecule-tests (0)", "conclusion": "running"},
|
|
||||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
|
||||||
]
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{
|
|
||||||
"GITEA_URL": "https://gitea.example",
|
|
||||||
"REPO_TOKEN": "token",
|
|
||||||
"RUN_ID": "123",
|
|
||||||
"JOB_NAME": "molecule-tests",
|
|
||||||
"MATRIX_INDEX": "0",
|
|
||||||
"GITEA_REPOSITORY": "oblachno-oss/infra",
|
|
||||||
"PATH": os.environ.get("PATH", ""),
|
|
||||||
},
|
|
||||||
clear=True,
|
|
||||||
),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
|
||||||
patch("os.killpg"),
|
|
||||||
patch("os.getpgid") as mock_getpgid,
|
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
|
||||||
):
|
|
||||||
mock_getpgid.return_value = 123
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = None
|
|
||||||
proc.wait.return_value = 0
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
cli,
|
|
||||||
[
|
|
||||||
"--roles-root",
|
|
||||||
str(roles_root),
|
|
||||||
"--junit-output",
|
|
||||||
junit_path,
|
|
||||||
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert Path(junit_path).exists()
|
|
||||||
tree = ET.parse(junit_path)
|
|
||||||
root = tree.getroot()
|
|
||||||
assert root.get("failures") == "1"
|
|
||||||
# The failure message should mention cancellation
|
|
||||||
failure = root[0][0]
|
|
||||||
assert "Cancelled" in (failure.text or "")
|
|
||||||
|
|||||||
+165
-6
@@ -11,6 +11,8 @@ from devx.ci.publish import (
|
|||||||
_default_gitea_registry_url,
|
_default_gitea_registry_url,
|
||||||
build_package,
|
build_package,
|
||||||
generate_release_notes,
|
generate_release_notes,
|
||||||
|
get_latest_tag,
|
||||||
|
is_release_commit,
|
||||||
main,
|
main,
|
||||||
publish_to_gitea_registry,
|
publish_to_gitea_registry,
|
||||||
publish_to_pypi,
|
publish_to_pypi,
|
||||||
@@ -122,11 +124,18 @@ class TestPublishToGiteaRegistry:
|
|||||||
|
|
||||||
@patch("devx.ci.publish.subprocess.run")
|
@patch("devx.ci.publish.subprocess.run")
|
||||||
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
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:
|
with pytest.raises(click.ClickException) as exc:
|
||||||
publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok")
|
publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok")
|
||||||
assert "Gitea PyPI registry" in str(exc.value)
|
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:
|
class TestDefaultGiteaRegistryUrl:
|
||||||
@patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True)
|
@patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True)
|
||||||
@@ -294,14 +303,21 @@ class TestMain:
|
|||||||
@patch("devx.ci.publish.TeaCLI")
|
@patch("devx.ci.publish.TeaCLI")
|
||||||
@patch("devx.ci.publish.publish_to_pypi")
|
@patch("devx.ci.publish.publish_to_pypi")
|
||||||
@patch("devx.ci.publish.build_package")
|
@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
|
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||||
) -> None:
|
) -> 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")
|
mock_publish.side_effect = click.ClickException("publish failed")
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 0
|
||||||
assert "publish" in result.output
|
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.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.generate_release_notes", return_value="Release notes")
|
||||||
@@ -371,5 +387,148 @@ class TestMain:
|
|||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Gitea release v1.0.0 created" in result.output
|
|
||||||
mock_tea.create_release.assert_called_once()
|
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
|
||||||
|
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||||
|
@patch("devx.ci.publish.TeaCLI")
|
||||||
|
@patch("devx.ci.publish.publish_to_pypi")
|
||||||
|
@patch("devx.ci.publish.build_package")
|
||||||
|
def test_create_release_already_exists_is_idempotent(
|
||||||
|
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""If create_release fails with 'already exists', treat as success."""
|
||||||
|
mock_tea = MagicMock()
|
||||||
|
mock_tea.list_releases.side_effect = TeaCLIError("api error")
|
||||||
|
mock_tea.create_release.side_effect = TeaCLIError("there is already a release for this tag")
|
||||||
|
mock_tea_cls.return_value = mock_tea
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "already exists" in result.output
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
|
||||||
|
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||||
|
@patch("devx.ci.publish.TeaCLI")
|
||||||
|
@patch("devx.ci.publish.publish_to_pypi")
|
||||||
|
@patch("devx.ci.publish.build_package")
|
||||||
|
def test_create_release_other_error_raises(
|
||||||
|
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""If create_release fails with a non-'already exists' error, raise."""
|
||||||
|
mock_tea = MagicMock()
|
||||||
|
mock_tea.list_releases.side_effect = TeaCLIError("api error")
|
||||||
|
mock_tea.create_release.side_effect = TeaCLIError("network error")
|
||||||
|
mock_tea_cls.return_value = mock_tea
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "Release creation failed" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestFromTag:
|
||||||
|
def test_get_latest_tag_success(self) -> None:
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
with patch("devx.ci.publish.subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="v1.2.3\n")
|
||||||
|
result = get_latest_tag()
|
||||||
|
assert result == "v1.2.3"
|
||||||
|
|
||||||
|
def test_get_latest_tag_no_tags(self) -> None:
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
with patch("devx.ci.publish.subprocess.run") as mock_run:
|
||||||
|
mock_run.side_effect = subprocess.CalledProcessError(1, [])
|
||||||
|
result = get_latest_tag()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_is_release_commit_match(self) -> None:
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
with patch("devx.ci.publish.subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=0, stdout="release: v1.2.3 [skip ci]\n"
|
||||||
|
)
|
||||||
|
result = is_release_commit("v1.2.3")
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_is_release_commit_no_match(self) -> None:
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
with patch("devx.ci.publish.subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: add feature\n")
|
||||||
|
result = is_release_commit("v1.2.3")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_is_release_commit_git_error(self) -> None:
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
with patch("devx.ci.publish.subprocess.run") as mock_run:
|
||||||
|
mock_run.side_effect = subprocess.CalledProcessError(1, [])
|
||||||
|
result = is_release_commit("v1.2.3")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
@patch("devx.ci.publish.get_latest_tag", return_value=None)
|
||||||
|
def test_from_tag_no_tag_skips(self, _mock: MagicMock) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "No tag found" in result.output
|
||||||
|
|
||||||
|
@patch("devx.ci.publish.get_latest_tag", return_value=None)
|
||||||
|
def test_from_tag_no_repo_uses_env(self, _mock: MagicMock) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with patch.dict("os.environ", {"GITHUB_REPOSITORY": "owner/repo"}):
|
||||||
|
result = runner.invoke(main, ["--from-tag", "--skip-build"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "No tag found" in result.output
|
||||||
|
|
||||||
|
@patch("devx.ci.publish.get_latest_tag", return_value=None)
|
||||||
|
def test_from_tag_no_repo_no_env_raises(self, _mock: MagicMock) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with patch.dict("os.environ", {}, clear=True):
|
||||||
|
result = runner.invoke(main, ["--from-tag", "--skip-build"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "REPO argument is required" in result.output
|
||||||
|
|
||||||
|
@patch("devx.ci.publish.is_release_commit", return_value=False)
|
||||||
|
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0")
|
||||||
|
def test_from_tag_not_release_commit_skips(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "not a release commit" in result.output
|
||||||
|
|
||||||
|
@patch("devx.ci.publish.is_release_commit", return_value=True)
|
||||||
|
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0")
|
||||||
|
def test_from_tag_publishes(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
|
||||||
|
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
|
||||||
|
with patch("devx.ci.publish.TeaCLI") as mock_tea_cls:
|
||||||
|
mock_tea = MagicMock()
|
||||||
|
mock_tea.list_releases.return_value = []
|
||||||
|
mock_tea_cls.return_value = mock_tea
|
||||||
|
with patch("devx.ci.publish.generate_release_notes", return_value="notes"):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Publishing release v1.0.0" in result.output
|
||||||
|
|
||||||
|
@patch("devx.ci.publish.is_release_commit", return_value=True)
|
||||||
|
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0")
|
||||||
|
def test_from_tag_publishes_no_repo_arg(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
|
||||||
|
with patch.dict("os.environ", {"REPO_TOKEN": "fake", "GITHUB_REPOSITORY": "owner/repo"}):
|
||||||
|
with patch("devx.ci.publish.TeaCLI") as mock_tea_cls:
|
||||||
|
mock_tea = MagicMock()
|
||||||
|
mock_tea.list_releases.return_value = []
|
||||||
|
mock_tea_cls.return_value = mock_tea
|
||||||
|
with patch("devx.ci.publish.generate_release_notes", return_value="notes"):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--from-tag", "--skip-build"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Publishing release v1.0.0" in result.output
|
||||||
|
|
||||||
|
def test_no_tag_no_from_tag_raises(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["", "owner/repo", "--skip-build"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "Tag is required" in result.output
|
||||||
|
|||||||
@@ -766,7 +766,7 @@ class TestCommitReleaseChanges:
|
|||||||
assert result is True
|
assert result is True
|
||||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
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", "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")
|
@patch("devx.ci.release.run_cmd")
|
||||||
def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None:
|
def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
from devx.ci.validate_commit_msg import first_line, get_branch, main
|
from devx.ci.validate_commit_msg import first_line, get_branch, get_latest_commit_msg, main
|
||||||
from devx.config import CONVENTIONAL_RE, TASK_ID_RE
|
from devx.config import CONVENTIONAL_RE, TASK_ID_RE
|
||||||
|
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ class TestMain:
|
|||||||
def test_usage_message_without_args(self) -> None:
|
def test_usage_message_without_args(self) -> None:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, [])
|
result = runner.invoke(main, [])
|
||||||
assert result.exit_code == 2
|
assert result.exit_code != 0
|
||||||
|
|
||||||
def test_branch_override_accepts_master_commit(self) -> None:
|
def test_branch_override_accepts_master_commit(self) -> None:
|
||||||
"""--branch master overrides branch detection (for CI use)."""
|
"""--branch master overrides branch detection (for CI use)."""
|
||||||
@@ -257,3 +257,45 @@ def test_main_module_block() -> None:
|
|||||||
namespace["main"]([msg_path], standalone_mode=False)
|
namespace["main"]([msg_path], standalone_mode=False)
|
||||||
|
|
||||||
os.unlink(msg_path)
|
os.unlink(msg_path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitMode:
|
||||||
|
def test_git_flag_reads_from_git(self, tmp_path) -> None:
|
||||||
|
with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value="feat: add feature"):
|
||||||
|
with patch("devx.ci.validate_commit_msg.get_branch", return_value="feature-branch"):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--git"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_git_flag_master_valid(self) -> None:
|
||||||
|
msg = "DEVX-24: fix: resolve timeout"
|
||||||
|
with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value=msg):
|
||||||
|
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--git", "--branch", "master"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_git_flag_master_invalid(self) -> None:
|
||||||
|
msg = "fix: resolve timeout"
|
||||||
|
with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value=msg):
|
||||||
|
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--git", "--branch", "master"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
|
||||||
|
def test_no_file_no_git_raises(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--branch", "master"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
|
||||||
|
def test_get_latest_commit_msg_success(self) -> None:
|
||||||
|
with patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: test\n\nBody")
|
||||||
|
result = get_latest_commit_msg()
|
||||||
|
assert result == "feat: test\n\nBody"
|
||||||
|
|
||||||
|
def test_stdin_input(self) -> None:
|
||||||
|
with patch("devx.ci.validate_commit_msg.get_branch", return_value="feature-branch"):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, input="feat: add feature\n", args=["-", "--branch", "feature-branch"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user