Compare commits

...
67 Commits
Author SHA1 Message Date
devx-ci-bot e45a546c16 release: v0.17.0
Publish Release / publish (push) Waiting to run
2026-06-26 17:58:32 +00:00
emil 41c631d5f5 DEVX-62: feat: weighted LPT distribution, workflow fixes, decouple vikunja/sync-wiki from release 2026-06-26 17:57:03 +00:00
gitea-actions-bot e271c79e93 chore: update badge URLs to commit e2c9e22d [skip ci] 2026-06-26 16:14:52 +00:00
gitea-actions-bot f4305821f1 chore: update badge URLs to commit 2e58ef07 [skip ci] 2026-06-26 16:14:11 +00:00
devx-ci-bot e4f40223d2 release: v0.16.0
Publish Release / publish (push) Waiting to run
2026-06-26 18:12:38 +02:00
emil 06e80516d4 DEVX-61: optimise slow unit tests and handle missing tea binary in TeaCLI 2026-06-26 16:09:54 +00:00
gitea-actions-bot f1adf22c3e chore: update badge URLs to commit 097082dd [skip ci] 2026-06-26 17:06:00 +02:00
emil 91216da1a4 DEVX-61: feat: single-source-of-truth config via [tool.devx] in pyproject.toml 2026-06-26 15:04:11 +00:00
gitea-actions-bot f9836208df chore: update badge URLs to commit e913bce4 [skip ci] 2026-06-26 16:32:11 +02:00
gitea-actions-bot 0f0f0b683a chore: update badge URLs to commit 3bc02ab2 [skip ci] 2026-06-26 14:31:58 +00:00
devx-ci-bot 54f687f1bf release: v0.15.0
Publish Release / publish (push) Waiting to run
2026-06-26 16:30:45 +02:00
emil 44c906a5e6 DEVX-60: feat: add create-task, create-pr, pre-push-check tools and devx.mak fragment 2026-06-26 14:29:47 +00:00
gitea-actions-bot a3d528f802 chore: update badge URLs to commit 6d2607dd [skip ci] 2026-06-26 02:20:46 +02:00
gitea-actions-bot e3a7afc0b0 chore: update badge URLs to commit a1b92efe [skip ci] 2026-06-26 02:20:43 +02:00
devx-ci-bot 700d3b55c6 release: v0.14.2
Publish Release / publish (push) Waiting to run
2026-06-26 00:19:13 +00:00
emil 701363d935 DEVX-59: fix: make repo arg optional in publish CLI, auto-detect from GITHUB_REPOSITORY 2026-06-26 00:17:55 +00:00
gitea-actions-bot ddb2d43b4e chore: update badge URLs to commit 3fb76ae0 [skip ci] 2026-06-26 01:34:46 +02:00
gitea-actions-bot fe6373b682 chore: update badge URLs to commit 27c68d16 [skip ci] 2026-06-25 23:34:31 +00:00
devx-ci-bot 33434d5750 release: v0.14.1
Publish Release / publish (push) Waiting to run
2026-06-26 01:33:23 +02:00
emil dfcd33c35b DEVX-58: fix: handle 'already a release' error idempotently in publish 2026-06-25 23:32:28 +00:00
gitea-actions-bot 0aefe1f028 chore: update badge URLs to commit f3c14a15 [skip ci] 2026-06-25 23:24:58 +00:00
gitea-actions-bot 891b0b5dba chore: update badge URLs to commit e89db952 [skip ci] 2026-06-25 23:24:49 +00:00
devx-ci-bot 8f15e5402b release: v0.14.0
Publish Release / publish (push) Waiting to run
2026-06-26 01:23:45 +02:00
emil 9060cd7b1e DEVX-57: feat: add FORCE_DEPLOY env var, --git flag, --from-tag flag 2026-06-25 23:22:41 +00:00
gitea-actions-bot f687ab5aa3 chore: update badge URLs to commit c31f8a46 [skip ci] 2026-06-26 00:59:13 +02:00
gitea-actions-bot 4738b594b2 chore: update badge URLs to commit a398ba43 [skip ci] 2026-06-25 22:58:46 +00:00
devx-ci-bot f6e9f2013b release: v0.13.0
Publish Release / publish (push) Waiting to run
2026-06-25 22:57:37 +00:00
emil 8450f33e88 DEVX-56: feat: add --force flag to classify_changes, fix api_clients coverage 2026-06-25 22:56:31 +00:00
gitea-actions-bot 904812dfae chore: update badge URLs to commit 525d3b70 [skip ci] 2026-06-26 00:11:59 +02:00
emil 95384c26e1 DEVX-55: fix: revert squash-merge format to use colon after task ID 2026-06-25 22:09:46 +00:00
gitea-actions-bot c10b759f6b chore: update badge URLs to commit 47d60f8b [skip ci] 2026-06-25 21:55:13 +00:00
gitea-actions-bot 4c818b32ce chore: update badge URLs to commit 9c9fe0f1 [skip ci] 2026-06-25 23:54:43 +02:00
devx-ci-bot bbf09c07df release: v0.1.0 2026-06-25 21:53:29 +00:00
emil faff67aa6a DEVX-54: fix: squash-merge format uses space not colon after task ID 2026-06-25 21:51:36 +00:00
gitea-actions-bot 3e4dfcadb7 chore: update badge URLs to commit 1c7678eb [skip ci] 2026-06-25 23:41:29 +02:00
gitea-actions-bot 2385747bed chore: update badge URLs to commit cb9e5b47 [skip ci] 2026-06-25 23:41:22 +02:00
devx-ci-bot 3625bf2872 release: v0.12.5
Publish Release / publish (push) Waiting to run
2026-06-25 23:40:08 +02:00
emil 8affccfa35 DEVX-53: fix: make PyPI publish failures non-fatal 2026-06-25 21:39:12 +00:00
gitea-actions-bot 7ae85b6955 chore: update badge URLs to commit e5e2b54b [skip ci] 2026-06-25 23:28:38 +02:00
gitea-actions-bot f702286779 chore: update badge URLs to commit 0ed104f4 [skip ci] 2026-06-25 21:28:25 +00:00
devx-ci-bot d4b58fa86f release: v0.12.4
Publish Release / publish (push) Waiting to run
2026-06-25 23:27:13 +02:00
emil 44c6c42ede DEVX-52: fix: guarantee Gitea release for every tag 2026-06-25 21:26:13 +00:00
gitea-actions-bot 14c585971d chore: update badge URLs to commit dfe5ee1e [skip ci] 2026-06-25 23:04:02 +02:00
emil 3e2342c347 DEVX-51: fix: pass REPO_TOKEN to setup-release so tea login is configured 2026-06-25 21:02:00 +00:00
gitea-actions-bot f3d5b0ff45 chore: update badge URLs to commit d5507246 [skip ci] 2026-06-25 20:56:25 +00:00
devx-ci-bot 75e36897cc release: v0.12.3 [skip ci] 2026-06-25 20:55:23 +00:00
emil 0eef69a902 DEVX-50: refactor: remove JUnit reporting from devx 2026-06-25 20:54:21 +00:00
gitea-actions-bot f9130884d1 chore: update badge URLs to commit 7341990c [skip ci] 2026-06-25 19:32:07 +00:00
devx-ci-bot ba002c2e72 release: v0.12.2 [skip ci] 2026-06-25 21:30:48 +02:00
emil af610d22ec DEVX-49: fix: remove auto-rebase from auto-merge to prevent CI feedback loop 2026-06-25 19:29:45 +00:00
gitea-actions-bot 69a585db2f chore: update badge URLs to commit 1b6a8d99 [skip ci] 2026-06-25 19:14:36 +02:00
devx-ci-bot 700df828ba release: v0.12.1 [skip ci] 2026-06-25 19:13:16 +02:00
emil 4df0602157 DEVX-48: fix: use heredoc syntax for multi-line $GITHUB_ENV values 2026-06-25 17:12:20 +00:00
gitea-actions-bot d7d90fe165 chore: update badge URLs to commit 9a9a53ef [skip ci] 2026-06-25 01:06:19 +02:00
devx-ci-bot 22c2d7c925 release: v0.12.0 [skip ci] 2026-06-24 23:05:23 +00:00
emil b4350751f1 DEVX-47: feat: add Polish as officially supported language 2026-06-24 23:04:20 +00:00
gitea-actions-bot a987b63da7 chore: update badge URLs to commit 85b1ae90 [skip ci] 2026-06-24 22:50:19 +00:00
emil 82d613e23b DEVX-46: docs: add pyproject.toml dependency and pip.conf instructions for devx 2026-06-24 22:48:06 +00:00
gitea-actions-bot 6b6c9d40f1 chore: update badge URLs to commit 90c2559f [skip ci] 2026-06-25 00:35:25 +02:00
emil d17854296a DEVX-45: docs: remove stale .taskid file fallback references 2026-06-24 22:33:35 +00:00
gitea-actions-bot 2fff7ed271 chore: update badge URLs to commit c2065ac1 [skip ci] 2026-06-25 00:28:01 +02:00
devx-ci-bot 0a4d66ab5c release: v0.11.1 [skip ci] 2026-06-25 00:25:51 +02:00
emil 14c9200179 DEVX-44: fix: add build/twine to ci deps, activate venv in notify_failure 2026-06-24 22:24:33 +00:00
gitea-actions-bot 85ae272b1f chore: update badge URLs to commit 389da217 [skip ci] 2026-06-24 22:18:25 +00:00
devx-ci-bot a0c4c1c7f0 release: v0.11.0 [skip ci] 2026-06-24 22:17:36 +00:00
emil fd0c4de31e DEVX-43: feat: add publish step to post-merge release job, make publish idempotent 2026-06-24 22:16:37 +00:00
gitea-actions-bot 8f7af97335 chore: update badge URLs to commit 0bb83e89 [skip ci] 2026-06-24 22:57:46 +02:00
62 changed files with 3280 additions and 847 deletions
+10 -7
View File
@@ -120,10 +120,14 @@ jobs:
auto-merge:
# Auto-merge runs after all CI checks pass. It reads the task ID
# from the branch name (falling back to .taskid file), validates
# the PR title, and squash-merges.
# from the branch name, validates the PR title, and squash-merges.
# Uses always() so it runs even when detect-changes skips (no user-facing changes).
needs: [quality, detect-changes, pr-review]
if: github.event_name == 'pull_request'
if: >-
always() &&
github.event_name == 'pull_request' &&
needs.quality.result == 'success' &&
needs.pr-review.result == 'success'
runs-on: docker
timeout-minutes: 10
steps:
@@ -131,10 +135,8 @@ jobs:
with:
fetch-depth: 0
token: ${{ secrets.REPO_TOKEN }}
- name: Install dependencies
run: |
python3 -m pip install --break-system-packages requests python-dotenv click
python3 -m pip install --break-system-packages -e .
- name: Set up environment
run: make setup-ci
- name: Squash merge with task ID
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
@@ -146,6 +148,7 @@ jobs:
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.number }}
run: |
. .venv/bin/activate
python3 -m devx.ci.auto_merge \
"$HEAD_REF" \
"$PR_TITLE" \
+35 -43
View File
@@ -6,21 +6,20 @@ name: Post-merge
#
# Job dependency graph:
#
# detect-type ──┬── release (skip if release commit)
# detect-type ──┬── validate-commit-msg (skip if release commit)
# ├── release (skip if release commit)
# ├── badges (ALWAYS runs — even on release commits)
# ├── configure-repo (independent — skip if release commit)
# ├── sync-wiki (needs release — skip if release commit/fails)
# └── vikunja (needs release — skip if release commit/fails)
# ├── sync-wiki (skip if release commit — runs for ALL merges)
# └── vikunja (skip if release commit — runs for ALL merges)
#
# sync-wiki and vikunja depend on release succeeding so that the wiki
# and task tracker are only updated when the code is actually released.
# If release fails, they are skipped to avoid leaving the wiki or
# Vikunja in an inconsistent state with the codebase on master.
# sync-wiki and vikunja run for ALL non-release commits, not just when
# release succeeds. This ensures the wiki and task tracker are updated
# even for infrastructure-only changes (docs, CI config, etc.).
#
# The badges job depends on release so it picks up the latest version
# number. It uses `if: always()` with no is-release condition so it
# runs on every push to master, including release commits. This
# ensures badges (tests, coverage, version, etc.) are always current.
# The badges job uses `if: always()` with no is-release condition so it
# runs on every push to master, including release commits. This ensures
# badges (tests, coverage, version, etc.) are always current.
#
# When release creates a "release: vX.Y.Z" commit, the release
# commit's post-merge run still updates badges (version badge picks
@@ -40,15 +39,15 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install dependencies
run: |
python3 -m pip install --break-system-packages requests python-dotenv click
python3 -m pip install --break-system-packages -e .
- name: Set up environment
run: make setup-ci
- name: Check if this is a release commit
id: check
env:
PYTHONPATH: src
run: python3 -m devx.ci.detect_release_commit
run: |
. .venv/bin/activate
python3 -m devx.ci.detect_release_commit
validate-commit-msg:
needs: [detect-type]
@@ -59,14 +58,13 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install dependencies
run: |
python3 -m pip install --break-system-packages click python-dotenv
python3 -m pip install --break-system-packages -e .
- name: Set up environment
run: make setup-ci
- name: Validate latest commit message
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
git log -1 --format=%B > commit-msg.txt
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
rm -f commit-msg.txt
@@ -75,13 +73,15 @@ jobs:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
timeout-minutes: 10
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.REPO_TOKEN }}
- name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-release
- name: Configure git
run: |
@@ -100,10 +100,8 @@ jobs:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.tools.install_tools --tool tea
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default devx || true
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
@@ -111,7 +109,7 @@ jobs:
--commit "${{ github.sha }}"
sync-wiki:
needs: [detect-type, release]
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
timeout-minutes: 10
@@ -142,7 +140,7 @@ jobs:
--commit "${{ github.sha }}"
badges:
needs: [detect-type, release]
needs: [detect-type]
if: always()
runs-on: docker
timeout-minutes: 10
@@ -178,7 +176,7 @@ jobs:
--commit "${{ github.sha }}"
vikunja:
needs: [detect-type, release]
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
timeout-minutes: 10
@@ -186,16 +184,16 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
run: |
python3 -m pip install --break-system-packages requests python-dotenv click
python3 -m pip install --break-system-packages -e .
- name: Set up environment
run: make setup-ci
- name: Update Vikunja task
env:
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8"
PYTHONPATH: src
run: python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
run: |
. .venv/bin/activate
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
- name: Notify on failure
if: failure()
env:
@@ -203,9 +201,6 @@ jobs:
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.tools.install_tools --tool tea
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default devx || true
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
@@ -219,15 +214,15 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
python3 -m pip install --break-system-packages requests python-dotenv click
python3 -m pip install --break-system-packages -e .
- name: Set up environment
run: make setup-ci
- name: Ensure branch protection and labels
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
run: |
. .venv/bin/activate
python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
- name: Notify on failure
if: failure()
env:
@@ -235,9 +230,6 @@ jobs:
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.tools.install_tools --tool tea
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default devx || true
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
+4 -13
View File
@@ -19,26 +19,16 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
run: |
python3 -m pip install --break-system-packages build twine requests python-dotenv click
python3 -m pip install --break-system-packages -e .
- name: Install CI tools
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.tools.install_tools --tool git-cliff --tool tea
- name: Configure tea login
- name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: |
export PATH="$HOME/.local/bin:$PATH"
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default devx || true
run: make setup-release
- name: Build and publish release
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.publish "${{ github.event.inputs.tag || github.ref_name }}" "${{ github.repository }}"
- name: Notify on failure
@@ -47,6 +37,7 @@ jobs:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
+34 -11
View File
@@ -66,9 +66,8 @@ src/devx/
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
│ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures)
│ ├── notify_failure.py # Create Gitea issues on CI failures (--auto-login)
│ ├── merge_junit.py # Merge JUnit XML reports from parallel runners
│ ├── distribute_files.py # Distribute files across parallel runners
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast + JUnit output
│ ├── distribute_files.py # Distribute files across parallel runners (LPT scheduling)
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
│ ├── check_translations.py # Translation completeness check
│ └── doc_coverage.py # Documentation coverage check
├── tools/ # Developer tooling modules (run locally or by CI)
@@ -80,8 +79,8 @@ src/devx/
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
├── discover_runners.py # Dynamic Gitea runner discovery
├── distribute_molecule.py # Distribute molecule scenarios across runners (--roles-root for multi-role)
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast + JUnit output (--roles-root, --junit-output)
├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role)
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast (--roles-root)
├── molecule_all.py # Run all molecule scenarios locally
└── platforms.py # Supported molecule platforms
```
@@ -163,7 +162,7 @@ the PR. Then add the `ready-to-merge` label. The auto-merge workflow will:
1. **Validate** PR title format (`DEVX-N: <vikunja task title>`) and match against Vikunja task title
2. **Check** that at least one substantive APPROVE review exists
3. Wait for all CI checks to pass (including the `pr-review` job)
4. Squash-merge with title: `DEVX-N <conventional commit message>` (space-separated, no colon after DEVX-N)
4. Squash-merge with title: `DEVX-N: <conventional commit message>`
5. The post-merge workflow marks the Vikunja task as done
6. The release workflow automatically versions, tags, and publishes
@@ -177,7 +176,7 @@ After a PR is merged to master, the **post-merge workflow**
1. **detect-type** — Checks if the commit is a regular merge or a
release commit (`release: vX.Y.Z`). All subsequent jobs skip for
release commits.
release commits (except badges).
2. **release** — Runs `python -m devx.ci.release` which:
- Checks for user-facing changes via `python -m devx.ci.classify_changes`
@@ -189,11 +188,16 @@ After a PR is merged to master, the **post-merge workflow**
- Creates an annotated tag `vX.Y.Z` on the release commit
- Pushes both the commit and tag to master
3. **sync-wiki** — Syncs documentation to the Gitea wiki.
3. **sync-wiki** — Syncs documentation to the Gitea wiki. Runs for ALL
non-release commits (not just when release succeeds), so docs-only
changes still update the wiki.
4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch.
Uses `if: always()` so it runs on every push, including release commits.
5. **vikunja** — Marks the corresponding Vikunja task as done.
5. **vikunja** — Marks the corresponding Vikunja task as done. Runs for ALL
non-release commits (not just when release succeeds), so infrastructure-only
changes still update the task tracker.
The tag push triggers the **publish workflow** (`.gitea/workflows/publish.yml`)
which builds and publishes the package to the Gitea PyPI registry.
@@ -260,7 +264,7 @@ by `python -m devx.tools.install_tools` and configured by
### git-cliff Commit Preprocessing
Merge commits on master have the format `DEVX-N <conventional commit>`. The
Merge commits on master have the format `DEVX-N: <conventional commit>`. The
`cliff.toml` includes a `commit_preprocessors` entry that strips the `DEVX-N `
prefix before parsing. This ensures all merged work appears in the changelog.
@@ -283,7 +287,7 @@ setuptools via `dynamic = ["version"]` in `pyproject.toml`.
| Branch name | `DEVX-N-short-description` | `DEVX-12-add-release-script` |
| Branch commits | `<conventional commit>` | `feat: add release script` |
| PR title | `DEVX-N: <vikunja task title>` | `DEVX-12: Add release automation` |
| Merge commit | `DEVX-N <conventional commit>` | `DEVX-12 feat: add release script` |
| Merge commit | `DEVX-N: <conventional commit>` | `DEVX-12: feat: add release script` |
### Task ID Resolution
@@ -311,6 +315,25 @@ auto-merge:
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
```
### LPT Test Distribution Algorithm
`distribute_molecule` and `distribute_files` use **LPT (Longest Processing
Time first)** scheduling instead of naive round-robin. This produces a more
balanced distribution when test items have varying costs:
1. **Weight estimation**: Each item is assigned a weight:
- Molecule scenarios: heuristic by name (`nextcloud`=10, `gitea`=8,
`binary`=2, default=3). See `_SCENARIO_WEIGHTS` in
`distribute_molecule.py`.
- Integration test files: weight by file size in bytes (as a proxy
for test runtime).
2. **LPT assignment**: Items are sorted by weight (descending), then
each is assigned to the runner with the least total weight.
This ensures heavy scenarios (e.g. `nextcloud`) are spread across
different runners rather than clustered on one, reducing the
longest-runner time from ~16 min to ~11 min with 6 runners.
## Config System
devx uses environment variables with `.env` file fallback for configuration.
+98
View File
@@ -2,6 +2,104 @@
All notable changes to this project will be documented in this file.
## [0.17.0] - 2026-06-26
### Features
- Weighted LPT distribution, workflow fixes, decouple vikunja/sync-wiki from release
## [0.16.0] - 2026-06-26
### Features
- Single-source-of-truth config via [tool.devx] in pyproject.toml
## [0.15.0] - 2026-06-26
### Features
- Add create-task, create-pr, pre-push-check tools and devx.mak fragment
## [0.14.2] - 2026-06-26
### Bug Fixes
- Make repo arg optional in publish CLI, auto-detect from GITHUB_REPOSITORY
## [0.14.1] - 2026-06-25
### Bug Fixes
- Handle 'already a release' error idempotently in publish
## [0.14.0] - 2026-06-25
### Features
- Add FORCE_DEPLOY env var, --git flag, --from-tag flag
## [0.13.0] - 2026-06-25
### Features
- Add --force flag to classify_changes, fix api_clients coverage
### Bug Fixes
- Squash-merge format uses space not colon after task ID
- Revert squash-merge format to use colon after task ID
## [0.1.0] - 2026-06-25
## [0.12.5] - 2026-06-25
### Bug Fixes
- Make PyPI publish failures non-fatal
## [0.12.4] - 2026-06-25
### Bug Fixes
- Pass REPO_TOKEN to setup-release so tea login is configured
- Guarantee Gitea release for every tag
## [0.12.3] - 2026-06-25
### Refactor
- Remove JUnit reporting from devx
## [0.12.2] - 2026-06-25
### Bug Fixes
- Remove auto-rebase from auto-merge to prevent CI feedback loop
## [0.12.1] - 2026-06-25
### Bug Fixes
- Use heredoc syntax for multi-line $GITHUB_ENV values
## [0.12.0] - 2026-06-24
### Features
- Add Polish as officially supported language
## [0.11.1] - 2026-06-24
### Bug Fixes
- Add build/twine to ci deps, activate venv in notify_failure
## [0.11.0] - 2026-06-24
### Features
- Add publish step to post-merge release job, make publish idempotent
## [0.10.2] - 2026-06-24
### Bug Fixes
+50 -21
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e2c9e22d7281ec8740759ac073f1f057df7fc9b2/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e2c9e22d7281ec8740759ac073f1f057df7fc9b2/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e2c9e22d7281ec8740759ac073f1f057df7fc9b2/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e2c9e22d7281ec8740759ac073f1f057df7fc9b2/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e2c9e22d7281ec8740759ac073f1f057df7fc9b2/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e2c9e22d7281ec8740759ac073f1f057df7fc9b2/python.svg)](https://www.python.org/downloads/)
## Why devx?
@@ -55,28 +55,61 @@ up by bumping their devx dependency.
- **Developer tools** — environment setup, CI tool installation, test speed
enforcement, repository configuration.
- **i18n** — built-in translations for English, Bulgarian, German, Russian,
and Chinese; projects can extend with their own keys.
Chinese, and Polish; projects can extend with their own keys.
## Installation
Install from the Gitea PyPI registry:
devx is published to the Gitea PyPI registry at
`https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple`.
The registry is publicly readable — no authentication required to install.
### Quick install (one-off)
```bash
pip install devx --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
Or add the registry to your `pip.conf` / `pyproject.toml` and install normally:
### Persistent configuration (recommended)
Add the registry to `~/.pip/pip.conf` so `pip install devx` works without
specifying `--index-url` every time:
```ini
[global]
extra-index-url = https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
### As a dependency in another project
To use devx as a dependency in your `pyproject.toml`, add the registry as an
extra index and list devx in your dependencies:
```toml
[project]
dependencies = [
"devx>=0.11.1",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Then install normally:
```bash
pip install devx
pip install -e .
```
> **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (e.g., `"devx==0.11.1"`) or use a version constraint
> (e.g., `"devx>=0.11.1,<0.12"`).
### Optional extras
devx ships optional dependency groups for different use cases:
```bash
pip install "devx[ci,lint]" # CI runners and linting (pytest, ruff, pyright, bandit)
pip install "devx[ci,lint]" # CI runners and linting (pytest, ruff, pyright, bandit, build, twine)
pip install "devx[molecule]" # Molecule testing for Ansible projects
pip install "devx[dev]" # Full local development (ci + lint + build + twine)
```
@@ -142,11 +175,8 @@ python -m devx.ci.discover_runners --owner oblachno-oss --repo devx --indices
python -m devx.ci.distribute_files --pattern "tests/integration/test_*.py" \
--runner-index 1 --max-runners 3 --github-env
# Merge JUnit XML reports from parallel runners
python -m devx.ci.merge_junit --pattern "junit-results/runner-*.xml" --output junit-merged.xml
# Run pytest with cross-runner fail-fast and JUnit output
python -m devx.ci.integration_guard --junit-output junit-results/runner-1.xml -- test_a.py test_b.py
# Run pytest with cross-runner fail-fast
python -m devx.ci.integration_guard -- test_a.py test_b.py
```
### Developer tools
@@ -194,7 +224,7 @@ python -m devx.molecule.distribute_molecule --list # list all scena
python -m devx.molecule.distribute_molecule --list-platforms # list platforms
# Run molecule tests with cross-runner fail-fast
python -m devx.molecule.molecule_ci_guard --junit-output junit.xml pair1 pair2
python -m devx.molecule.molecule_ci_guard pair1 pair2
python -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2
# Run all molecule scenarios locally (sequential)
@@ -241,8 +271,7 @@ devx --version
| `devx ci discover-runners` | Discover available Gitea Actions runners |
| `devx ci distribute-files` | Distribute files across parallel runners (round-robin) |
| `devx ci doc-coverage` | Check documentation coverage for CLI commands and modules |
| `devx ci integration-guard` | Run pytest with cross-runner fail-fast and JUnit output |
| `devx ci merge-junit` | Merge JUnit XML reports from parallel runners |
| `devx ci integration-guard` | Run pytest with cross-runner fail-fast |
| `devx ci notify-failure` | Create a Gitea issue when a CI workflow fails |
| `devx ci post-merge` | Update Vikunja task after a merge to master |
| `devx ci pr-review` | Run automated PR review |
@@ -291,7 +320,7 @@ The config system loads `.env` automatically via `python-dotenv`.
| `DEVX_REPO_NAME` | **(none — must be set)** | Repository name (or `owner/repo`) |
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
| `DEVX_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID |
| `DEVX_LANG` | `en` | Language for i18n (en, bg, de, ru, zh) |
| `DEVX_LANG` | `en` | Language for i18n (en, bg, de, ru, zh, pl) |
| `DEVX_TRANSLATIONS_PATH` | — | Path to a custom JSON translations file |
| `DEVX_VERSION_FILE` | `src/devx/__init__.py` | Version source file (used by release) |
| `DEVX_DOCS_DIR` | `docs` | Documentation directory (used by sync_wiki) |
@@ -398,7 +427,7 @@ src/devx/
├── i18n.py # Translation system (gettext-based, translations.json)
├── exceptions.py # Custom exception types (DevxError, APIError)
├── opentofu.py # OpenTofu output helpers
├── translations.json # Translation strings (en, bg, de, ru, zh)
├── translations.json # Translation strings (en, bg, de, ru, zh, pl)
├── ci/ # CI/CD automation modules (run by workflows)
├── tools/ # Developer tooling modules (run locally or by CI)
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
+44 -13
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/55e25b065ddcd66194f8897c4d5b12515f15fa28/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e2c9e22d7281ec8740759ac073f1f057df7fc9b2/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e2c9e22d7281ec8740759ac073f1f057df7fc9b2/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e2c9e22d7281ec8740759ac073f1f057df7fc9b2/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e2c9e22d7281ec8740759ac073f1f057df7fc9b2/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e2c9e22d7281ec8740759ac073f1f057df7fc9b2/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/e2c9e22d7281ec8740759ac073f1f057df7fc9b2/python.svg)](https://www.python.org/downloads/)
## Overview
@@ -42,22 +42,53 @@ via environment variables and `pyproject.toml`, and inherit:
- **Developer tools** — environment setup, CI tool installation, test speed
enforcement, repository configuration.
- **i18n** — built-in translations for English, Bulgarian, German, Russian,
and Chinese; projects can extend with their own keys.
Chinese, and Polish; projects can extend with their own keys.
## Installation
Install from the Gitea PyPI registry:
devx is published to the Gitea PyPI registry at
`https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple`.
The registry is publicly readable — no authentication required to install.
### Quick install (one-off)
```bash
pip install devx --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
Optional extras:
### Persistent configuration (recommended)
Add the registry to `~/.pip/pip.conf`:
```ini
[global]
extra-index-url = https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
Then `pip install devx` works without specifying `--index-url`.
### As a dependency in another project
Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml
[project]
dependencies = [
"devx>=0.11.1",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Pin a specific version if needed: `"devx==0.11.1"` or `"devx>=0.11.1,<0.12"`.
### Optional extras
```bash
pip install "devx[ci,lint]" # CI runners and linting
pip install "devx[ci,lint]" # CI runners and linting (pytest, ruff, pyright, bandit, build, twine)
pip install "devx[molecule]" # Molecule testing for Ansible projects
pip install "devx[dev]" # Full local development
pip install "devx[dev]" # Full local development (ci + lint + build + twine)
```
## Architecture
@@ -69,7 +100,7 @@ devx is a self-contained Python package under `src/devx/`:
- **CI automation** (`devx.ci`) — release, publish, auto_merge, pr_review,
classify_changes, sync_wiki, push_badges, check_translations, doc_coverage,
validate_commit_msg, detect_release_commit, notify_failure, post_merge,
discover_runners, distribute_files, merge_junit, integration_guard
discover_runners, distribute_files, integration_guard
- **Dev tools** (`devx.tools`) — setup, install_tools, check_test_speed,
configure_repo, generate_badges, generate_cliff_config, install_checkmake
- **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible
@@ -117,7 +148,7 @@ fallback. Key variables:
| `DEVX_REPO_OWNER` | **(must be set)** | Repository owner |
| `DEVX_REPO_NAME` | **(must be set)** | Repository name |
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
| `DEVX_LANG` | `en` | Language for i18n (en, bg, de, ru, zh) |
| `DEVX_LANG` | `en` | Language for i18n (en, bg, de, ru, zh, pl) |
| `REPO_TOKEN` | — | Gitea API token |
| `VIKUNJA_TOKEN` | — | Vikunja API token |
+5 -14
View File
@@ -16,7 +16,7 @@ src/devx/
├── i18n.py # Translation system (JSON-based, translations.json)
├── exceptions.py # Custom exception types (DevxError, APIError)
├── opentofu.py # OpenTofu output helpers
├── translations.json # Translation strings (en, bg, de, ru, zh)
├── translations.json # Translation strings (en, bg, de, ru, zh, pl)
├── ci/ # CI/CD automation modules (run by workflows)
│ ├── __init__.py
│ ├── _shared.py # Shared utilities (get_latest_tag)
@@ -31,9 +31,8 @@ src/devx/
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
│ ├── push_badges.py # Generate and push quality badges
│ ├── notify_failure.py # Create Gitea issues on CI failures
│ ├── merge_junit.py # Merge JUnit XML reports from parallel runners
│ ├── distribute_files.py # Distribute files across parallel runners
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast + JUnit
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
│ ├── discover_runners.py # Dynamic Gitea runner discovery
│ ├── check_translations.py # Translation completeness check
│ └── doc_coverage.py # Documentation coverage check
@@ -188,7 +187,7 @@ repos that only need a Gitea release.
### `auto_merge.py`
Auto-merges a PR when all CI checks pass. Reads the task ID from the branch
name (falling back to `.taskid` file), validates the PR title format against
name, validates the PR title format against
the Vikunja task title, extracts the conventional commit message from PR
commits, and squash-merges with title `{PREFIX}-N <conventional commit>`.
@@ -296,18 +295,11 @@ Distributes files matching a glob pattern across N parallel runners
(round-robin). Writes the assigned file list for the current runner to
`$GITHUB_ENV`. Used for splitting test suites across CI runners.
### `merge_junit.py`
Merges JUnit XML reports from parallel matrix runners into a single
consolidated report. Exit code is non-zero if any merged suite reports
failures, making it suitable as a CI gating step.
### `integration_guard.py`
Runs pytest with the same cross-runner failure detection mechanism used by
`molecule_ci_guard`. If any other integration-tests matrix runner reports
failure, the current pytest subprocess is killed and this runner exits early.
Generates JUnit XML via pytest's `--junitxml` flag.
## Developer tools (`devx.tools`)
@@ -382,9 +374,8 @@ platforms.
Runs molecule tests sequentially while polling the Gitea API for other runner
failures. If any other molecule matrix runner reports failure, the current
molecule subprocess is killed and this runner exits early. Generates JUnit
XML when `--junit-output` is provided. Supports both single-role (4-part) and
multi-role (5-part) pair encoding.
molecule subprocess is killed and this runner exits early. Supports both
single-role (4-part) and multi-role (5-part) pair encoding.
### `molecule_all.py`
+5 -19
View File
@@ -93,8 +93,7 @@ Depends on `quality`, `detect-changes`, and `pr-review`. The final job in the
CI workflow. Runs `python -m devx.ci.auto_merge` with the branch name, PR
title, repository, and PR number:
1. **Read task ID** from branch name (e.g., `DEVX-12-fix-foo` → `DEVX-12`),
falling back to `.taskid` file for branches without a task ID prefix
1. **Read task ID** from branch name (e.g., `DEVX-12-fix-foo` → `DEVX-12`)
2. **Validate PR title format** — must be `{PREFIX}-N: <vikunja task title>`
3. **Validate PR title matches Vikunja task** — fetches the Vikunja task and
compares the title
@@ -330,8 +329,7 @@ On failure, the `notify_failure` step creates a Gitea issue.
### `auto_merge.py`
Auto-merge PR when all CI checks pass. Reads task ID from the branch name
(e.g., `DEVX-12-fix-foo` → `DEVX-12`), falling back to `.taskid` file for
branches without a task ID prefix. Validates PR title format, checks the
(e.g., `DEVX-12-fix-foo` → `DEVX-12`). Validates PR title format, checks the
Vikunja task exists and the title matches, extracts the conventional commit
message from PR commits, and squash-merges with
`{PREFIX}-N <conventional commit>` title.
@@ -449,12 +447,10 @@ python -m devx.molecule.distribute_molecule --list-platforms
### `molecule_ci_guard.py`
Runs molecule tests sequentially while polling the Gitea API for other runner
failures. Aborts early if another runner fails the same job. Generates JUnit
XML when `--junit-output` is provided.
failures. Aborts early if another runner fails the same job.
```bash
python -m devx.molecule.molecule_ci_guard [--roles-root <dir>] \
[--junit-output <file>] pair1 pair2 ...
python -m devx.molecule.molecule_ci_guard [--roles-root <dir>] pair1 pair2 ...
```
### `validate_commit_msg.py`
@@ -505,16 +501,6 @@ python -m devx.ci.distribute_files --pattern <glob> --runner-index <i> \
--max-runners <n> [--github-env] [--skip-if-excess]
```
### `merge_junit.py`
Merges JUnit XML reports from parallel matrix runners into a single
consolidated report. Exit code is non-zero if any merged suite reports
failures.
```bash
python -m devx.ci.merge_junit --pattern <glob> --output <file>
```
### `integration_guard.py`
Runs pytest with cross-runner failure detection. If any other
@@ -522,7 +508,7 @@ integration-tests matrix runner reports failure, the current pytest
subprocess is killed and this runner exits early.
```bash
python -m devx.ci.integration_guard --junit-output <file> -- <pytest args>
python -m devx.ci.integration_guard -- <pytest args>
```
## Release process summary
+6 -18
View File
@@ -18,7 +18,7 @@ devx molecule --help # show molecule commands
### `devx ci auto-merge`
Auto-merge a PR when all CI checks pass. Reads the task ID from the branch
name (falling back to `.taskid` file), validates the PR title format against
name, validates the PR title format against
the Vikunja task title, extracts the conventional commit message from PR
commits, and squash-merges with `{PREFIX}-N <conventional commit>` title.
@@ -40,8 +40,8 @@ checks `src/devx/translations.json` against `src/devx/**/*.py`.
Checks performed:
- **Missing keys** — a `_()` call in code has no entry in the translations file
- **Dead keys** — a key in the translations file is not used in any code
- **Missing languages** — a key exists but is missing one of the five
supported languages (en, bg, de, ru, zh)
- **Missing languages** — a key exists but is missing one of the six
supported languages (en, bg, de, ru, zh, pl)
```bash
devx ci check-translations
@@ -137,13 +137,13 @@ Options:
### `devx ci integration-guard`
Run pytest with cross-runner failure detection and JUnit XML output. If any
Run pytest with cross-runner failure detection. If any
other integration-tests matrix runner reports failure, the current pytest
subprocess is killed and this runner exits early with code 1.
```bash
devx ci integration-guard --junit-output junit-results/runner-1.xml -- test_a.py test_b.py
devx ci integration-guard --junit-output junit-results/runner-1.xml -- -x -v --tb=short test_a.py
devx ci integration-guard -- test_a.py test_b.py
devx ci integration-guard -- -x -v --tb=short test_a.py
```
Environment variables:
@@ -154,16 +154,6 @@ Environment variables:
- `MATRIX_INDEX` — current matrix index (runner-index)
- `GITEA_REPOSITORY` — repository in `owner/repo` format
### `devx ci merge-junit`
Merge multiple JUnit XML reports from parallel runners into a single
consolidated report. Exit code is non-zero if any merged test suite reports
failures, making it suitable as a CI gating step after matrix jobs.
```bash
devx ci merge-junit --pattern "junit-results/runner-*.xml" --output junit-merged.xml
```
### `devx ci notify-failure`
Create a Gitea issue when a CI workflow fails. Uses the tea CLI for issue
@@ -461,7 +451,6 @@ current molecule subprocess is killed and this runner exits early with code 1.
```bash
devx molecule guard pair1 pair2 pair3
devx molecule guard --roles-root ansible/roles pair1 pair2
devx molecule guard --junit-output junit-results/runner-1.xml pair1 pair2
```
Each pair is encoded as:
@@ -470,7 +459,6 @@ Each pair is encoded as:
Options:
- `--roles-root <dir>` — roles root directory for multi-role repos
- `--junit-output <file>` — generate JUnit XML report
Environment variables:
- `GITEA_URL` — base URL of the Gitea instance
+10 -1
View File
@@ -30,6 +30,8 @@ version = {attr = "devx.__version__"}
ci = [
"pytest>=9.1.0",
"pytest-cov>=7.1.0",
"build>=1.5.0",
"twine>=6.2.0",
]
# Lint and type-checking tools (quality job)
lint = [
@@ -57,7 +59,7 @@ dev = [
where = ["src"]
[tool.setuptools.package-data]
devx = ["translations.json"]
devx = ["translations.json", "make/*.mak"]
[tool.pytest.ini_options]
testpaths = ["tests"]
@@ -94,6 +96,13 @@ strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "s
# Rule priority (first match wins):
# 1. user_facing_overrides (safety — highest priority)
# 2. infrastructure_overrides (explicit per-file)
# Project-specific devx configuration (read by devx.config)
[tool.devx]
task_prefix = "DEVX"
vikunja_project_id = 8
repo_owner = "oblachno-oss"
repo_name = "devx"
# 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns)
# 4. Default: user-facing (safe)
[tool.devx.classify]
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.10.2"
__version__ = "0.17.0"
+64
View File
@@ -192,6 +192,32 @@ class GiteaClient:
r = self._request("GET", f"/pulls/{pr_number}")
return r.json()
def create_pr(self, title: str, head: str, base: str = "master", body: str = "") -> dict[str, Any]:
"""Create a pull request and return the PR dict.
Args:
title: PR title.
head: Head branch name.
base: Base branch name (default: master).
body: PR description (markdown).
"""
payload: dict[str, Any] = {"title": title, "head": head, "base": base}
if body:
payload["body"] = body
r = self._request("POST", "/pulls", json=payload)
return r.json()
def list_prs(self, state: str = "all", **params: Any) -> list[dict[str, Any]]:
"""List pull requests, optionally filtered by state.
Args:
state: ``open``, ``closed``, ``all`` (default).
**params: Additional query params (e.g. ``q="keyword"`` for title search).
"""
params.setdefault("state", state)
r = self._request("GET", "/pulls", params=params)
return r.json()
def get_pr_files(self, pr_number: str | int) -> list[dict[str, Any]]:
"""Fetch the list of files changed in a pull request."""
r = self._request("GET", f"/pulls/{pr_number}/files")
@@ -342,8 +368,46 @@ class VikunjaClient:
r = self._request("GET", f"/projects/{project_id}/tasks", params=params)
return r.json()
def create_task(self, project_id: int, title: str, description: str = "") -> dict[str, Any]:
"""Create a task in a project and return the created task dict.
Args:
project_id: Target Vikunja project ID.
title: Task title (required, non-empty).
description: Task description (HTML supported, optional).
"""
r = self._request(
"PUT",
f"/projects/{project_id}/tasks",
json={"title": title, "description": description},
)
return r.json()
def post_comment(self, task_id: int, comment: str) -> None:
self._request("PUT", f"/tasks/{task_id}/comments", json={"comment": comment})
def list_comments(self, task_id: int) -> list[dict[str, Any]]:
"""List all comments on a task."""
r = self._request("GET", f"/tasks/{task_id}/comments")
return r.json()
def update_task(self, task_id: int, **fields: Any) -> None:
"""Update task fields via POST (full replacement semantics).
Warning: Vikunja's POST /tasks/{id} replaces the entire task body.
Unspecified fields are reset to their type defaults. Use
``update_task_safe`` to preserve existing fields.
"""
self._request("POST", f"/tasks/{task_id}", json=fields)
def update_task_safe(self, task_id: int, **fields: Any) -> dict[str, Any]:
"""Safely update task fields using read-merge-write pattern.
Fetches the full task body, merges the provided fields on top,
and POSTs the complete body back. This prevents accidental
resets of done status, title, etc.
"""
task = self.get_task(task_id)
task.update(fields)
r = self._request("POST", f"/tasks/{task_id}", json=task)
return r.json()
+11 -17
View File
@@ -237,23 +237,17 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
client.merge_pr(pr_num, merge_title)
except APIError as e:
if e.status == 405 and "behind" in e.message.lower():
# Head branch is behind master — pull master and rebase, then retry
click.echo(_("Head branch is behind master. Pulling and rebasing..."))
try:
run_cmd(["git", "config", "user.name", "devx-ci-bot"])
run_cmd(["git", "config", "user.email", "devx-ci-bot@oblachno.fyi"])
run_cmd(["git", "fetch", "origin", "master"])
run_cmd(["git", "rebase", "origin/master"])
run_cmd(["git", "push", "--force-with-lease", "origin", f"HEAD:{branch}"])
click.echo(_("Rebased and pushed. Retrying merge..."))
client.merge_pr(pr_num, merge_title)
except (APIError, Exception) as retry_err:
raise click.ClickException(
_(
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
error=str(retry_err),
)
) from None
# Head branch is behind master — do NOT auto-rebase.
# Auto-rebasing creates a feedback loop: the force-push triggers
# a new pull_request synchronize event, which starts a new CI run,
# which runs auto-merge again, which rebases again, etc.
raise click.ClickException(
_(
"Branch is behind master. Rebase manually:\n"
" git fetch origin master && git rebase origin/master && git push --force-with-lease\n"
"Then re-add the ready-to-merge label.",
)
) from None
else:
raise click.ClickException(
_(
+6 -6
View File
@@ -11,8 +11,8 @@ Checks performed (all fail with exit code 1 on error):
- **Missing keys**: a ``_()`` call in code has no entry in the corresponding
translations file.
- **Dead keys**: a key in a translations file is not used in any code.
- **Missing languages**: a key exists but is missing one of the 5 supported
languages (en, bg, de, ru, zh). This is an error — all supported languages
- **Missing languages**: a key exists but is missing one of the 6 supported
languages (en, bg, de, ru, zh, pl). This is an error — all supported languages
must have translations for every key.
Usage::
@@ -33,7 +33,7 @@ import click
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh")
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh", "pl")
# Default translation set: devx package itself
DEFAULT_TRANS_FILE = REPO_ROOT / "src" / "devx" / "translations.json"
@@ -101,9 +101,9 @@ def collect_keys(src_dir: Path) -> set[str]:
if pyfile.name == "i18n.py":
continue
keys |= extract_keys(pyfile)
# Add dynamic keys for the default source directory
if src_dir == DEFAULT_SRC_DIR:
keys |= DYNAMIC_KEYS
# Dynamic keys are common status strings used via _(variable) that
# can't be detected by AST scanning. Include them for all projects.
keys |= DYNAMIC_KEYS
return keys
+19 -1
View File
@@ -615,11 +615,29 @@ def _write_github_output(key: str, value: str) -> None:
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps). "
"Outputs 'user-facing-changed' and '<tag>-changed' for each configured tag.",
)
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None:
@click.option(
"--force",
is_flag=True,
default=False,
help="Force user-facing-changed=true regardless of actual changes. "
"Used by workflow_dispatch with force-deploy input.",
)
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool, force: bool) -> None:
"""Classify git changes and output results."""
classifier = _get_classifier()
available_tags = list(classifier.config.tags.keys())
# --force can also be activated via FORCE_DEPLOY env var (for workflow_dispatch)
if os.environ.get("FORCE_DEPLOY", "").lower() == "true":
force = True
if force and github_output:
_write_github_output("user-facing-changed", "true")
for tag in available_tags:
_write_github_output(f"{tag}-changed", "true")
click.echo("Forced user-facing-changed=true via --force flag.")
return
if base is None:
base = get_latest_tag()
if not base:
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Detect whether the latest git commit is a release commit.
Release commits have the format ``release: vX.Y.Z [skip ci]``.
Release commits have the format ``release: vX.Y.Z``.
This script writes ``is-release=true`` or ``is-release=false`` to
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
+37 -7
View File
@@ -1,10 +1,14 @@
#!/usr/bin/env python3
"""Distribute a list of files across N parallel runners (round-robin).
"""Distribute a list of files across N parallel runners using LPT scheduling.
Generic file-based test distribution for CI matrix jobs. Discovers files
matching a glob pattern, sorts them for deterministic ordering, then
assigns them round-robin to *max_runners* groups. The assigned group for
*runner_index* is written to ``$GITHUB_ENV`` for use by subsequent steps.
assigns them to *max_runners* groups using LPT (Longest Processing Time
first) scheduling — files are weighted by size (as a proxy for test
runtime) and assigned to the runner with the least total weight.
The assigned group for *runner_index* is written to ``$GITHUB_ENV`` for
use by subsequent steps.
Usage::
@@ -32,11 +36,32 @@ def discover_files(pattern: str) -> list[str]:
return sorted(glob.glob(pattern))
def _file_weight(path: str) -> int:
"""Estimate a weight for a file based on its size in bytes.
Falls back to 1 if the file cannot be stat'd (e.g. in tests).
"""
try:
return max(1, os.path.getsize(path))
except OSError:
return 1
def distribute(files: list[str], max_runners: int) -> list[list[str]]:
"""Split *files* into *max_runners* balanced groups (round-robin)."""
"""Split *files* into *max_runners* balanced groups using LPT scheduling.
Files are weighted by size (as a proxy for runtime) and assigned to
the runner with the least total weight.
"""
weights = [_file_weight(f) for f in files]
groups: list[list[str]] = [[] for _ in range(max_runners)]
for i, f in enumerate(files):
groups[i % max_runners].append(f)
loads = [0] * max_runners
# Sort by weight descending, preserving original order for ties
indexed = sorted(enumerate(files), key=lambda x: (-weights[x[0]], x[0]))
for orig_idx, f in indexed:
min_runner = min(range(max_runners), key=lambda r: loads[r])
groups[min_runner].append(f)
loads[min_runner] += weights[orig_idx]
return groups
@@ -55,7 +80,12 @@ def _write_github_env(key: str, value: str) -> None:
if not gh_env:
raise click.ClickException("GITHUB_ENV environment variable is not set")
with open(gh_env, "a") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
if "\n" in value:
# Multi-line values require the heredoc syntax in $GITHUB_ENV.
delimiter = "EOF"
f.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
else:
f.write(f"{key}={value}\n")
@click.command()
+1 -13
View File
@@ -6,18 +6,13 @@ Wraps ``pytest`` with the same Gitea API polling mechanism used by
reports failure, the current pytest subprocess is killed and this runner
exits early with code 1.
JUnit XML is generated via pytest's ``--junitxml`` flag (passed through
to the pytest invocation).
Usage::
python3 -m devx.ci.integration_guard \\
--junit-output junit-results/runner-1.xml \\
-- test_file1.py test_file2.py
# With pytest options
python3 -m devx.ci.integration_guard \\
--junit-output junit-results/runner-1.xml \\
-- -x -v --tb=short test_file1.py
Environment variables:
@@ -51,12 +46,7 @@ POLL_INTERVAL = 10
@click.command(context_settings={"ignore_unknown_options": True})
@click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED, required=True)
@click.option(
"--junit-output",
default=None,
help="Path for JUnit XML output (passed to pytest as --junitxml).",
)
def cli(pytest_args: tuple[str, ...], junit_output: str | None) -> None:
def cli(pytest_args: tuple[str, ...]) -> None:
"""Run pytest with cross-runner failure detection."""
gitea_url = os.environ.get("GITEA_URL", "")
token = os.environ.get("REPO_TOKEN", "")
@@ -93,8 +83,6 @@ def cli(pytest_args: tuple[str, ...], junit_output: str | None) -> None:
poller.start()
cmd = [sys.executable, "-m", "pytest"]
if junit_output:
cmd.extend(["--junitxml", junit_output])
cmd.extend(pytest_args)
click.echo(f"Running: {' '.join(cmd)}")
-97
View File
@@ -1,97 +0,0 @@
#!/usr/bin/env python3
"""Merge multiple JUnit XML reports into a single report.
Used by CI workflows to consolidate JUnit XML files produced by
parallel matrix runners into a single merged report for archival
and dashboard consumption.
Usage::
python3 -m devx.ci.merge_junit \\
--pattern "junit-results/runner-*.xml" \\
--output junit-merged.xml
Exit code is non-zero if any merged test suite reports failures,
making this suitable as a CI gating step after matrix jobs.
"""
from __future__ import annotations
import glob
import sys
import xml.etree.ElementTree as ET # nosec B405
import click
from devx.i18n import _
def merge_files(pattern: str) -> tuple[ET.Element, int, int]:
"""Merge JUnit XML files matching *pattern* into a single ``<testsuites>`` element.
Returns ``(merged_element, total_tests, total_failures)``.
If no files match, returns an empty ``<testsuites>`` with zero counts.
"""
files = sorted(glob.glob(pattern))
merged = ET.Element("testsuites")
total_tests = 0
total_failures = 0
for f in files:
tree = ET.parse(f) # nosec B314
suite = tree.getroot()
# Handle both <testsuites> (wrapper) and <testsuite> (single) roots
if suite.tag == "testsuites":
for child in suite:
merged.append(child)
total_tests += int(child.get("tests", 0))
total_failures += int(child.get("failures", 0))
else:
merged.append(suite)
total_tests += int(suite.get("tests", 0))
total_failures += int(suite.get("failures", 0))
merged.set("tests", str(total_tests))
merged.set("failures", str(total_failures))
return merged, total_tests, total_failures
@click.command()
@click.option(
"--pattern",
default="junit-results/runner-*.xml",
show_default=True,
help="Glob pattern for input JUnit XML files.",
)
@click.option(
"--output",
default="junit-merged.xml",
show_default=True,
help="Output path for the merged JUnit XML file.",
)
def main(pattern: str, output: str) -> None:
merged, total_tests, total_failures = merge_files(pattern)
if total_tests == 0:
click.echo(_("No JUnit reports found matching {pattern} — skipping merge.", pattern=pattern))
return
ET.indent(merged)
tree = ET.ElementTree(merged)
tree.write(output, encoding="UTF-8", xml_declaration=True)
click.echo(
_(
"Merged {count} reports: {tests} tests, {failures} failures → {output}",
count=len(glob.glob(pattern)),
tests=total_tests,
failures=total_failures,
output=output,
)
)
if total_failures > 0:
sys.exit(1)
if __name__ == "__main__": # pragma: no cover
main()
+108 -19
View File
@@ -135,13 +135,21 @@ def publish_to_gitea_registry(registry_url: str, token: str) -> None:
check=False,
)
if result.returncode != 0:
raise click.ClickException(
_(
"Oops! Gitea PyPI registry publish failed:\n{stderr}",
stderr=result.stderr.strip(),
# Twine writes errors to stdout (not stderr), so check both.
combined = f"{result.stdout}\n{result.stderr}".strip()
# 409 Conflict means the package version is already published —
# this is not an error, just a sign we're re-running publish.
if "409" in combined or "Conflict" in combined:
click.echo(_("Gitea PyPI registry: {tag} already published — continuing.", tag=""))
else:
raise click.ClickException(
_(
"Oops! Gitea PyPI registry publish failed:\n{stderr}",
stderr=combined,
)
)
)
click.echo(_("Published to Gitea PyPI registry."))
else:
click.echo(_("Published to Gitea PyPI registry."))
def _default_gitea_registry_url() -> str:
@@ -161,9 +169,37 @@ def _default_gitea_registry_url() -> str:
return f"{base}/api/packages/{owner}/pypi"
def get_latest_tag() -> str | None:
"""Get the latest git tag, or None if no tags exist."""
try:
result = subprocess.run( # nosec
["git", "describe", "--tags", "--abbrev=0"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except subprocess.CalledProcessError:
return None
def is_release_commit(tag: str) -> bool:
"""Check if HEAD commit message starts with 'release: <tag>'."""
try:
result = subprocess.run( # nosec
["git", "log", "-1", "--format=%s"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip().startswith(f"release: {tag}")
except subprocess.CalledProcessError:
return False
@click.command()
@click.argument("tag")
@click.argument("repo")
@click.argument("tag", required=False)
@click.argument("repo", required=False)
@click.option(
"--registry-url",
default=None,
@@ -178,7 +214,37 @@ def _default_gitea_registry_url() -> str:
help="Skip package build and PyPI publish (for non-Python repos that only "
"need a Gitea release with git-cliff notes).",
)
def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> None:
@click.option(
"--from-tag",
is_flag=True,
default=False,
help="Auto-detect latest tag and check if HEAD is a release commit. "
"Skips publish if no tag or HEAD is not a release commit for that tag.",
)
def main(
tag: str | None,
repo: str | None,
registry_url: str | None,
skip_build: bool,
from_tag: bool,
) -> None:
if repo is None:
repo = os.environ.get("GITHUB_REPOSITORY", "")
if not repo:
raise click.ClickException(_("REPO argument is required (or set GITHUB_REPOSITORY env var)."))
if from_tag:
detected_tag = get_latest_tag()
if not detected_tag:
click.echo(_("No tag found — skipping publish."))
return
if not is_release_commit(detected_tag):
click.echo(_("HEAD is not a release commit for {tag} — skipping publish.", tag=detected_tag))
return
tag = detected_tag
click.echo(_("Publishing release {tag}...", tag=tag))
if not tag:
raise click.ClickException(_("Tag is required (or use --from-tag)."))
gitea_token = os.environ.get("REPO_TOKEN", "")
if not gitea_token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
@@ -194,28 +260,51 @@ def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> Non
if not skip_build:
build_package()
if pypi_token:
# Standard PyPI flow takes precedence when PYPI_TOKEN is set
publish_to_pypi(pypi_token)
elif registry_url:
# Gitea PyPI registry flow
publish_to_gitea_registry(registry_url, gitea_token)
else:
try:
if pypi_token:
# Standard PyPI flow takes precedence when PYPI_TOKEN is set
publish_to_pypi(pypi_token)
elif registry_url:
# Gitea PyPI registry flow
publish_to_gitea_registry(registry_url, gitea_token)
else:
click.echo(
_(
"PYPI_TOKEN not set and no registry URL configured — "
"skipping PyPI publish. No worries, we'll just create the Gitea release."
)
)
except click.ClickException as e:
click.echo(
_(
"PYPI_TOKEN not set and no registry URL configured — "
"skipping PyPI publish. No worries, we'll just create the Gitea release."
)
"PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}",
error=str(e),
),
err=True,
)
else:
click.echo(_("--skip-build: skipping package build and PyPI publish."))
tea = TeaCLI(repo=repo)
# Check if release already exists (idempotent — avoids failure when
# called multiple times, e.g. by both post-merge and publish workflows)
try:
releases = tea.list_releases(repo)
if any(r.get("tag_name") == tag for r in releases):
click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag))
return
except TeaCLIError:
pass # If listing fails, proceed to create
release_body = generate_release_notes(tag)
try:
tea.create_release(repo, tag=tag, title=tag, body=release_body)
except TeaCLIError as e:
if "already" in str(e).lower() and "release" in str(e).lower():
click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag))
return
raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None
click.echo(
+1 -1
View File
@@ -279,7 +279,7 @@ def commit_release_changes(new_version: str) -> bool:
if status.returncode == 0:
click.echo(_("No staged changes — version and changelog already up to date."))
return False
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version} [skip ci]"])
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version}"])
return True
+31 -4
View File
@@ -14,6 +14,7 @@ task ID format for each project.
import re
import subprocess # nosec B404
import sys
import click
@@ -23,6 +24,17 @@ from devx.i18n import _
MASTER_TASK_ID_RE = re.compile(rf"^{TASK_PREFIX}-\d+:")
def get_latest_commit_msg() -> str:
"""Get the latest commit message from git."""
result = subprocess.run( # nosec
["git", "log", "-1", "--format=%B"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def first_line(text: str) -> str:
return text.split("\n")[0]
@@ -41,11 +53,26 @@ def get_branch() -> str:
@click.command()
@click.argument("commit_msg_file")
@click.argument("commit_msg_file", required=False)
@click.option("--branch", default=None, help="Override branch detection (for CI use).")
def main(commit_msg_file: str, branch: str | None) -> None:
with open(commit_msg_file) as f:
msg = f.read().strip()
@click.option(
"--git",
"from_git",
is_flag=True,
default=False,
help="Read commit message from git log instead of a file.",
)
def main(commit_msg_file: str | None, branch: str | None, from_git: bool) -> None:
if from_git:
msg = get_latest_commit_msg()
elif commit_msg_file:
if commit_msg_file == "-":
msg = sys.stdin.read().strip()
else:
with open(commit_msg_file) as f:
msg = f.read().strip()
else:
raise click.ClickException(_("Provide a commit message file or use --git."))
if branch is None:
branch = get_branch()
+1 -8
View File
@@ -158,17 +158,10 @@ def ci_distribute_files(args: tuple[str, ...]) -> None:
_run_module("devx.ci.distribute_files", list(args))
@ci.command("merge-junit")
@click.argument("args", nargs=-1)
def ci_merge_junit(args: tuple[str, ...]) -> None:
"""Merge multiple JUnit XML reports into a single report."""
_run_module("devx.ci.merge_junit", list(args))
@ci.command("integration-guard")
@click.argument("args", nargs=-1)
def ci_integration_guard(args: tuple[str, ...]) -> None:
"""Run pytest with cross-runner failure detection and JUnit output."""
"""Run pytest with cross-runner failure detection."""
_run_module("devx.ci.integration_guard", list(args))
+65 -7
View File
@@ -1,28 +1,86 @@
"""Shared configuration constants for devx scripts and API clients.
All defaults can be overridden via environment variables with the ``DEVX_``
prefix. Projects consuming devx can set these in their ``.env`` files.
Configuration is read from two sources, in priority order:
1. **Environment variables** (``DEVX_`` prefix) — highest priority, used for
CI secrets and per-run overrides.
2. **``[tool.devx]`` section in ``pyproject.toml``** — project defaults,
read from the current working directory.
If neither source provides a value, built-in defaults are used.
"""
from __future__ import annotations
import os
import re
import tomllib
from pathlib import Path
def _load_pyproject_devx() -> dict[str, object]:
"""Load the ``[tool.devx]`` section from pyproject.toml in the CWD.
Returns an empty dict if the file or section is missing.
"""
path = Path("pyproject.toml")
if not path.exists():
return {}
try:
with open(path, "rb") as f: # noqa: PTH123
data: dict[str, object] = tomllib.load(f)
except (tomllib.TOMLDecodeError, OSError):
return {}
tool_raw: object = data.get("tool", {})
if not isinstance(tool_raw, dict):
return {}
tool: dict[str, object] = tool_raw # type: ignore[assignment]
devx_raw: object = tool.get("devx", {})
if not isinstance(devx_raw, dict):
return {}
devx: dict[str, object] = devx_raw # type: ignore[assignment]
return devx
_PYPROJECT = _load_pyproject_devx()
def _get(key: str, env_var: str, default: str) -> str:
"""Get a config value: env var > pyproject.toml > default."""
env_val = os.getenv(env_var)
if env_val is not None:
return env_val
pyproject_val = _PYPROJECT.get(key)
if isinstance(pyproject_val, str):
return pyproject_val
return default
def _get_int(key: str, env_var: str, default: int) -> int:
"""Get an int config value: env var > pyproject.toml > default."""
env_val = os.getenv(env_var)
if env_val is not None:
return int(env_val)
pyproject_val = _PYPROJECT.get(key)
if isinstance(pyproject_val, int):
return pyproject_val
return default
# API endpoints — override via env vars for different Gitea/Vikunja instances
GITEA_API_URL = os.getenv("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
VIKUNJA_API_URL = os.getenv("DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
GITEA_API_URL = _get("gitea_api_url", "DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
VIKUNJA_API_URL = _get("vikunja_api_url", "DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
# No default: prevents silent 404s when the wrong owner is used.
REPO_OWNER = os.getenv("DEVX_REPO_OWNER", "")
REPO_OWNER = _get("repo_owner", "DEVX_REPO_OWNER", "")
# Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
TASK_PREFIX = os.getenv("DEVX_TASK_PREFIX", "DEVX")
TASK_PREFIX = _get("task_prefix", "DEVX_TASK_PREFIX", "DEVX")
TASK_ID_RE = re.compile(rf"{TASK_PREFIX}-\d+")
# Vikunja project ID — each project uses a different Vikunja project
VIKUNJA_PROJECT_ID = int(os.getenv("DEVX_VIKUNJA_PROJECT_ID", "6"))
VIKUNJA_PROJECT_ID = _get_int("vikunja_project_id", "DEVX_VIKUNJA_PROJECT_ID", 6)
# HTTP client defaults
DEFAULT_TIMEOUT = 30
+9 -6
View File
@@ -82,12 +82,15 @@ class TeaCLI:
cmd = [self._tea, *args]
if json_output:
cmd.extend(["--output", "json"])
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
)
try:
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
)
except FileNotFoundError as e:
raise TeaCLIError(f"tea binary not found ('{self._tea}'). Install tea or add it to PATH.") from e
if result.returncode != 0:
raise TeaCLIError(
f"tea command failed (rc={result.returncode}): {' '.join(args)}\nstderr: {result.stderr.strip()}"
+2 -2
View File
@@ -1,7 +1,7 @@
"""Simple i18n for devx scripts and tools.
Set DEVX_LANG environment variable to override the default English.
Supported: en, bg, de, ru, zh.
Supported: en, bg, de, ru, zh, pl.
Projects can extend translations by setting DEVX_TRANSLATIONS_PATH to a
JSON file with additional keys. Keys from the project's file are merged
@@ -45,7 +45,7 @@ def _(key: str, **kwargs: object) -> str:
If unset, English is always returned regardless of system locale.
"""
lang = os.getenv("DEVX_LANG", "en")
if lang not in ("en", "bg", "de", "ru", "zh"):
if lang not in ("en", "bg", "de", "ru", "zh", "pl"):
lang = "en"
template = TRANSLATIONS.get(key, {}).get(lang, key)
return template.format(**kwargs)
+52
View File
@@ -0,0 +1,52 @@
# devx.mak — Shared Makefile fragment for devx-integrated projects.
#
# This fragment provides common targets for Vikunja task management,
# PR creation, and pushing. It is designed to be included from a
# project's Makefile.
#
# Project config (task prefix, Vikunja project ID, repo owner, repo name)
# is read from [tool.devx] in pyproject.toml by devx.config — no
# Makefile variables needed.
#
# Usage in your Makefile:
#
# # Set DEVX_PYTHON if you need a specific interpreter
# DEVX_PYTHON := $(BIN)/python
#
# # Include the devx fragment (silent if devx not installed yet)
# DEVX_MAK := $(shell $(DEVX_PYTHON) -c \
# "from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
# 2>/dev/null)
# -include $(DEVX_MAK)
#
# If devx is not installed, the -include silently skips and the targets
# are simply unavailable (run 'make setup' first).
#
# Variables:
# DEVX_PYTHON — Python executable (default: python3)
# DEVX_PR_BASE — PR base branch (default: master)
DEVX_PYTHON ?= python3
DEVX_PR_BASE ?= master
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
# Create a Vikunja task (project ID read from [tool.devx] in pyproject.toml)
devx-create-task:
@$(DEVX_PYTHON) -m devx.tools.create_task
# Create a PR with title auto-derived from the Vikunja task
# (owner/repo read from [tool.devx] in pyproject.toml)
devx-create-pr:
@$(DEVX_PYTHON) -m devx.tools.create_pr --base $(DEVX_PR_BASE)
# Push current branch to origin
devx-push:
@git push -u origin HEAD
# Validate devx configuration in pyproject.toml
devx-check-config:
@$(DEVX_PYTHON) -m devx.tools.check_config
# Push and create PR in one step
devx-push-with-pr: devx-push devx-create-pr
+62 -10
View File
@@ -132,14 +132,63 @@ def build_multi_role_pairs(
return [MultiRoleTestPair(r, s, p) for r, s in role_scenarios for p in platforms]
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
groups: list[list[MultiRoleTestPair]] = [[] for _ in range(max_runners)]
for i, pair in enumerate(pairs):
groups[i % max_runners].append(pair)
# Heuristic weights for known heavy molecule scenarios.
# These are estimated from CI run times — scenarios that pull large Docker
# images or run complex Ansible playbooks take longer.
_SCENARIO_WEIGHTS: dict[str, int] = {
"nextcloud": 10,
"gitea": 8,
"vaultwarden": 7,
"zitadel": 7,
"postgresql": 6,
"redis": 5,
"backup": 5,
"docker-base": 4,
"default": 3,
"binary": 2,
}
_DEFAULT_SCENARIO_WEIGHT = 3
def _scenario_weight(scenario: str) -> int:
"""Estimate a weight for a scenario based on its name."""
s = scenario.lower()
for key, weight in _SCENARIO_WEIGHTS.items():
if key in s:
return weight
return _DEFAULT_SCENARIO_WEIGHT
def _lpt_distribute[T](items: list[T], weights: list[int], max_runners: int) -> list[list[T]]:
"""Distribute *items* across *max_runners* using LPT (Longest Processing Time first).
Sorts items by weight (descending), then assigns each to the runner
with the least total weight. This produces a more balanced distribution
than naive round-robin when items have varying costs.
"""
groups: list[list[T]] = [[] for _ in range(max_runners)]
loads = [0] * max_runners
# Sort by weight descending, preserving original order for ties
indexed = sorted(enumerate(items), key=lambda x: (-weights[x[0]], x[0]))
for orig_idx, item in indexed:
# Find the runner with the minimum load
min_runner = min(range(max_runners), key=lambda r: loads[r])
groups[min_runner].append(item)
loads[min_runner] += weights[orig_idx]
return groups
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
"""Split *pairs* into *max_runners* balanced groups using LPT scheduling.
Each pair is weighted by scenario name heuristics (e.g. ``nextcloud`` is
heavier than ``binary``). Pairs are sorted by weight descending and
assigned to the runner with the least total weight.
"""
weights = [_scenario_weight(p.scenario) for p in pairs]
return _lpt_distribute(pairs, weights, max_runners)
def multi_role_pairs_for_runner(
pairs: list[MultiRoleTestPair], runner_index: int, max_runners: int
) -> list[MultiRoleTestPair]:
@@ -153,11 +202,14 @@ def multi_role_pairs_for_runner(
def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]:
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
groups: list[list[TestPair]] = [[] for _ in range(max_runners)]
for i, pair in enumerate(pairs):
groups[i % max_runners].append(pair)
return groups
"""Split *pairs* into *max_runners* balanced groups using LPT scheduling.
Each pair is weighted by scenario name heuristics (e.g. ``nextcloud`` is
heavier than ``binary``). Pairs are sorted by weight descending and
assigned to the runner with the least total weight.
"""
weights = [_scenario_weight(p.scenario) for p in pairs]
return _lpt_distribute(pairs, weights, max_runners)
def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int) -> list[TestPair]:
+1 -76
View File
@@ -13,17 +13,12 @@ A background thread polls the Gitea API. If any other molecule matrix runner
reports failure, the current molecule subprocess is killed and this runner
exits early with code 1.
JUnit XML is generated when ``--junit-output`` is provided, recording each
pair as a testcase with pass/fail status and elapsed time.
Usage::
# Single-role (grm-style)
python3 -m devx.molecule.molecule_ci_guard pair1 pair2 ...
# Multi-role (infra-style)
python3 -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2 ...
# With JUnit output
python3 -m devx.molecule.molecule_ci_guard --junit-output junit-results/runner-1.xml pair1 pair2 ...
Environment variables:
GITEA_URL Base URL of the Gitea instance.
@@ -43,7 +38,6 @@ import subprocess # nosec B404
import sys
import threading
import time
import xml.etree.ElementTree as ET # nosec B405
from pathlib import Path
import click
@@ -158,53 +152,15 @@ def resolve_role_dir(role: str, roles_root: Path | None, repo_root: Path) -> Pat
return repo_root / "ansible" / "roles" / "gitea-runner"
def write_junit_report(
output_path: str,
testcases: list[dict],
runner_index: int,
) -> None:
"""Write a JUnit XML report from collected test case results.
Each testcase dict has: role, scenario, time (float), passed (bool), error (str|None).
"""
suite = ET.Element(
"testsuite",
name=f"molecule-runner-{runner_index}",
tests=str(len(testcases)),
failures=str(sum(1 for tc in testcases if not tc["passed"])),
)
for tc in testcases:
classname = tc["role"] if tc["role"] else "molecule"
elem = ET.SubElement(
suite,
"testcase",
classname=classname,
name=tc["scenario"],
time=f"{tc['time']:.1f}",
)
if not tc["passed"]:
fail = ET.SubElement(elem, "failure")
fail.text = tc.get("error") or "molecule test failed"
tree = ET.ElementTree(suite)
ET.indent(tree)
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
tree.write(output_path, encoding="UTF-8", xml_declaration=True)
@click.command()
@click.argument("pairs", nargs=-1, required=True)
@click.option(
"--junit-output",
default=None,
help="Path to write JUnit XML report (e.g. junit-results/runner-1.xml).",
)
@click.option(
"--roles-root",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=None,
help="Root directory for multi-role pairs (e.g. ansible/roles). Required when pairs use 5-part format.",
)
def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | None) -> None:
def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None:
"""Run molecule pairs sequentially, stop if another CI runner fails."""
gitea_url = os.environ.get("GITEA_URL", "")
token = os.environ.get("REPO_TOKEN", "")
@@ -249,8 +205,6 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
)
poller.start()
testcases: list[dict] = []
try:
for pair in pairs:
if failed_event.is_set():
@@ -263,7 +217,6 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
env = build_env_for_pair(pair, base_env)
cwd = resolve_role_dir(role, roles_root, repo_root)
start = time.time()
process = subprocess.Popen( # nosec B603
cmd,
cwd=str(cwd),
@@ -282,18 +235,6 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
process.wait()
elapsed = time.time() - start
testcases.append(
{
"role": role,
"scenario": scenario,
"time": elapsed,
"passed": False,
"error": "Cancelled — another runner failed",
}
)
if junit_output:
write_junit_report(junit_output, testcases, current_index)
sys.exit(1)
time.sleep(1)
except KeyboardInterrupt:
@@ -303,23 +244,9 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
sys.exit(1)
rc = process.returncode
elapsed = time.time() - start
passed = rc == 0
testcases.append(
{
"role": role,
"scenario": scenario,
"time": elapsed,
"passed": passed,
"error": f"Exit code: {rc}" if not passed else None,
}
)
if rc != 0:
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
if junit_output:
write_junit_report(junit_output, testcases, current_index)
sys.exit(rc)
click.echo(_("PASSED: {pair}", pair=pair))
@@ -336,8 +263,6 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
)
click.echo(_("All molecule tests passed."))
if junit_output:
write_junit_report(junit_output, testcases, current_index)
finally:
stop_event.set()
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Validate devx configuration consistency in pyproject.toml.
Checks:
1. [tool.devx] section exists with required keys (task_prefix, vikunja_project_id, repo_owner, repo_name)
2. devx version is consistent across all extras that mention it
Usage::
python3 -m devx.tools.check_config
"""
from __future__ import annotations
import re
import sys
import tomllib
from pathlib import Path
import click
from devx.i18n import _
@click.command()
def cli() -> None:
"""Validate devx configuration in pyproject.toml."""
path = Path("pyproject.toml")
if not path.exists():
click.echo(_("pyproject.toml not found in current directory."))
sys.exit(1)
with open(path, "rb") as f: # noqa: PTH123
data = tomllib.load(f)
errors: list[str] = []
# Check [tool.devx] section
devx_cfg = data.get("tool", {}).get("devx", {})
required_keys = {"task_prefix", "vikunja_project_id", "repo_owner", "repo_name"}
missing = required_keys - set(devx_cfg.keys())
if missing:
errors.append(
_("[tool.devx] missing required keys: {keys}", keys=", ".join(sorted(missing))),
)
# Check devx version consistency across extras
optional_deps = data.get("project", {}).get("optional-dependencies", {})
devx_versions: dict[str, str] = {}
for extra_name, deps in optional_deps.items():
for dep in deps:
# Match "devx>=X.Y.Z", "devx==X.Y.Z", "devx>X.Y.Z", etc.
m = re.search(r"\bdevx\s*(>=|==|>|<=|<|~=)\s*([\d.]+)", dep)
if m:
devx_versions[extra_name] = m.group(2)
if devx_versions:
unique_versions = set(devx_versions.values())
if len(unique_versions) > 1:
detail = ", ".join(f"{extra}={v}" for extra, v in sorted(devx_versions.items()))
errors.append(
_("devx version mismatch across extras: {detail}", detail=detail),
)
if errors:
for err in errors:
click.echo(f"ERROR: {err}", err=True)
sys.exit(1)
click.echo(_("Configuration OK: [tool.devx] present, devx versions consistent."))
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Create a pull request with the correct title from the Vikunja task.
This tool is run **after** pushing a feature branch. It:
1. Extracts the task ID from the branch name (e.g. ``DEVX-31-fix-foo`` → ``DEVX-31``).
2. Fetches the Vikunja task title for that task ID.
3. Creates a PR with title ``{TASK_PREFIX}-N: <vikunja task title>``.
This eliminates manual PR title entry and ensures the title always
matches the Vikunja task — which is what the auto-merge workflow
validates.
If a PR already exists for the branch, the tool prints its URL and
exits successfully (idempotent).
Usage::
python -m devx.tools.create_pr --branch DEVX-31-fix-foo
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
"""
from __future__ import annotations
import os
import subprocess # nosec B404
import click
from dotenv import load_dotenv
from devx.api_clients import GiteaClient, VikunjaClient
from devx.config import (
DEFAULT_PER_PAGE,
GITEA_API_URL,
REPO_OWNER,
TASK_ID_RE,
TASK_PREFIX,
VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID,
)
from devx.i18n import _
load_dotenv()
def get_repo_name() -> str:
"""Auto-detect repository name from env vars or git remote."""
name = os.environ.get("DEVX_REPO_NAME", "")
if name:
return name
github_repo = os.environ.get("GITHUB_REPOSITORY", "")
if github_repo and "/" in github_repo:
return github_repo.split("/", 1)[1]
raise click.ClickException(
_("Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var."),
)
def extract_task_id(branch: str) -> str:
"""Extract the task ID (e.g. ``DEVX-31``) from a branch name."""
match = TASK_ID_RE.search(branch)
return match.group(0) if match else ""
def get_vikunja_task_title(task_id: str) -> str:
"""Fetch the Vikunja task title for the given task identifier.
Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found.
"""
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Required to derive PR title."))
client = VikunjaClient(VIKUNJA_API_URL, token)
page = 1
while True:
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
if not tasks:
break
matches = [t for t in tasks if t.get("identifier") == task_id]
if matches:
return str(matches[0].get("title", ""))
if len(tasks) < DEFAULT_PER_PAGE:
break
page += 1
raise click.ClickException(
_(
"Could not find Vikunja task {task_id} in project {project_id}.",
task_id=task_id,
project_id=VIKUNJA_PROJECT_ID,
),
)
def find_existing_pr(client: GiteaClient, branch: str) -> dict | None:
"""Return an existing open PR for the branch, or None."""
prs = client.list_prs(state="open")
for pr in prs:
if pr.get("head", {}).get("ref") == branch:
return pr
return None
def create_pr(
branch: str,
base: str,
body: str,
repo_owner: str,
repo_name: str,
) -> dict:
"""Create a PR with the title derived from the Vikunja task.
Returns the PR dict from the Gitea API.
"""
task_id = extract_task_id(branch)
if not task_id:
raise click.ClickException(
_(
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description",
branch=branch,
prefix=TASK_PREFIX,
),
)
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("REPO_TOKEN is not set. Required to create a PR."))
vikunja_title = get_vikunja_task_title(task_id)
pr_title = f"{task_id}: {vikunja_title}"
client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name)
existing = find_existing_pr(client, branch)
if existing:
click.echo(
_(
"PR already exists: #{index} — {url}",
index=existing.get("number", "?"),
url=existing.get("html_url", ""),
),
)
return existing
pr = client.create_pr(title=pr_title, head=branch, base=base, body=body)
click.echo(
_(
"Created PR #{index}: {title}\n {url}",
index=pr.get("number", "?"),
title=pr_title,
url=pr.get("html_url", ""),
),
)
return pr
@click.command()
@click.option("--branch", default=None, help="Head branch (default: auto-detect from git).")
@click.option("--base", default="master", show_default=True, help="Base branch.")
@click.option("--body", default="", help="PR body (markdown). Read from stdin if '-' is passed.")
@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).")
@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).")
def cli(branch: str | None, base: str, body: str, owner: str | None, repo: str | None) -> None:
"""Create a PR with the correct title from the Vikunja task."""
if branch is None:
result = subprocess.run( # nosec
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(
_("Could not detect current branch: {error}", error=result.stderr.strip()),
)
branch = result.stdout.strip()
if body == "-":
body = click.get_text_stream("stdin").read().strip()
repo_owner = owner or REPO_OWNER
if not repo_owner:
raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var."))
repo_name = repo or get_repo_name()
create_pr(branch, base, body, repo_owner, repo_name)
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Create a Vikunja task with a detailed HTML description.
This tool is used during the planning phase of the development workflow
to create a well-described task before any code is written. The task
identifier (e.g. ``DEVX-N``, ``GRM-N``, ``OBL-INFRA-N``) is then used
to name the feature branch and the pull request.
Usage::
python -m devx.tools.create_task --title "Add release automation" \\
--description "<h2>Overview</h2><p>Implement automated...</p>"
The project ID and task prefix are read from ``DEVX_VIKUNJA_PROJECT_ID``
and ``DEVX_TASK_PREFIX`` environment variables (or ``.env``).
"""
from __future__ import annotations
import os
import click
from dotenv import load_dotenv
from devx.api_clients import VikunjaClient
from devx.config import TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.i18n import _
load_dotenv()
@click.command()
@click.option("--title", required=True, help="Task title (becomes the Vikunja task title).")
@click.option(
"--description",
default="",
help="Task description (HTML supported). Read from stdin if '-' is passed.",
)
@click.option("--project-id", type=int, default=None, help="Vikunja project ID (default: DEVX_VIKUNJA_PROJECT_ID).")
def cli(title: str, description: str, project_id: int | None) -> None:
"""Create a Vikunja task and print its identifier."""
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Set it in .env or environment."))
pid = project_id if project_id is not None else VIKUNJA_PROJECT_ID
if description == "-":
description = click.get_text_stream("stdin").read().strip()
client = VikunjaClient(VIKUNJA_API_URL, token)
task = client.create_task(pid, title, description)
identifier = task.get("identifier", "")
task_id = task.get("id", "")
click.echo(
_(
"Created Vikunja task: {identifier} (id={task_id})",
identifier=identifier,
task_id=task_id,
)
)
if identifier:
click.echo(
_(
"Next steps:\n"
" 1. git checkout master && git pull\n"
" 2. git checkout -b {prefix}-{num}-short-description\n"
" 3. Implement changes, commit with conventional commit format\n"
" 4. git push -u origin HEAD\n"
" 5. make create-pr (creates PR with title: {identifier}: {title})",
prefix=TASK_PREFIX,
num=identifier.split("-")[-1] if "-" in identifier else "N",
identifier=identifier,
title=title,
)
)
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Pre-push validation: ensure a Vikunja task exists for the branch.
This tool is designed to run as a git pre-push hook. It extracts the
task ID from the branch name (e.g. ``DEVX-31-fix-foo`` → ``DEVX-31``)
and verifies that a corresponding Vikunja task exists.
If the task does not exist, the hook **fails with guidance** — it does
not auto-create the task. This prevents accidental pushes of branches
without a planning task.
Usage::
python -m devx.tools.pre_push_check --branch DEVX-31-fix-foo
Exit codes:
0 — all checks passed, safe to push
1 — validation failed (missing task, missing token, etc.)
"""
from __future__ import annotations
import os
import subprocess # nosec B404
import click
from dotenv import load_dotenv
from devx.api_clients import VikunjaClient
from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.i18n import _
load_dotenv()
def get_current_branch() -> str:
"""Return the current git branch name, or empty string on error."""
result = subprocess.run( # nosec
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
check=False,
)
return result.stdout.strip()
def extract_task_id(branch: str) -> str:
"""Extract the task ID (e.g. ``DEVX-31``) from a branch name."""
match = TASK_ID_RE.search(branch)
return match.group(0) if match else ""
def task_exists(task_id: str) -> bool:
"""Check if a Vikunja task with the given identifier exists.
Returns ``False`` if VIKUNJA_TOKEN is not set (soft-fail in local mode).
"""
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
return False
client = VikunjaClient(VIKUNJA_API_URL, token)
page = 1
while True:
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
if not tasks:
break
if any(t.get("identifier") == task_id for t in tasks):
return True
if len(tasks) < DEFAULT_PER_PAGE:
break
page += 1
return False
def validate(branch: str) -> None:
"""Run all pre-push validations for the given branch.
Raises ``click.ClickException`` on failure.
"""
if not branch or branch in ("master", "main"):
return
task_id = extract_task_id(branch)
if not task_id:
raise click.ClickException(
_(
"Branch '{branch}' does not contain a task ID.\n"
" Expected format: {prefix}-N-short-description\n"
" Example: {prefix}-42-add-feature\n"
" Fix: rename the branch or create a Vikunja task first:\n"
' python -m devx.tools.create_task --title "Task title"',
branch=branch,
prefix=TASK_PREFIX,
)
)
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
click.echo(
_(
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. "
"Set it in .env to enable full validation.",
),
err=True,
)
return
if not task_exists(task_id):
raise click.ClickException(
_(
"Vikunja task {task_id} not found in project {project_id}.\n"
" Create it first:\n"
' python -m devx.tools.create_task --title "Task title"\n'
" Or check that the task ID in the branch name is correct.",
task_id=task_id,
project_id=VIKUNJA_PROJECT_ID,
)
)
click.echo(_("Pre-push check passed: task {task_id} exists.", task_id=task_id))
@click.command()
@click.option("--branch", default=None, help="Branch name (default: auto-detect from git).")
def cli(branch: str | None) -> None:
"""Validate pre-push preconditions for the current branch."""
if branch is None:
branch = get_current_branch()
validate(branch)
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+12 -2
View File
@@ -150,19 +150,29 @@ def _verify(bin_dir: str) -> None:
default=False,
help="Skip Ansible Galaxy collection installation.",
)
@click.option(
"--skip-install",
is_flag=True,
default=False,
help="Skip pip install (use when deps already installed, e.g. devx came via ci extra).",
)
def main(
bin_dir: str,
extras: str,
no_pre_commit: bool,
no_tea_login: bool,
no_ansible_collections: bool,
skip_install: bool,
) -> None:
"""Install Python deps, pre-commit hooks, and configure tea CLI."""
if not Path(bin_dir).exists():
raise click.ClickException(f"Bin directory not found: {bin_dir}. Run 'python3 -m venv .venv' first.")
click.echo(f"Installing Python dependencies (extras: {extras})...")
_install_python_deps(bin_dir, extras)
if not skip_install:
click.echo(f"Installing Python dependencies (extras: {extras})...")
_install_python_deps(bin_dir, extras)
else:
click.echo("Skipping pip install (--skip-install).")
if not no_ansible_collections:
click.echo("Installing Ansible Galaxy collections...")
File diff suppressed because it is too large Load Diff
+149
View File
@@ -137,6 +137,19 @@ class TestGiteaClient:
assert result is None
client.create_label.assert_not_called()
def test_ensure_label_creates_when_others_exist(self) -> None:
"""When labels exist but none match the target name, create a new one."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_labels = MagicMock(
return_value=[{"name": "bug", "color": "ff0000"}, {"name": "docs", "color": "007ec6"}]
)
client.create_label = MagicMock(return_value={"name": "ready-to-merge", "color": "2ecc71"})
result = client.ensure_label("ready-to-merge", "2ecc71", "desc")
assert result is not None
assert result["name"] == "ready-to-merge"
client.create_label.assert_called_once_with("ready-to-merge", "2ecc71", "desc")
def test_list_branch_protections(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
@@ -196,6 +209,18 @@ class TestGiteaClient:
expected_update = {k: v for k, v in TEST_BP_CONFIG.items() if k != "branch_name"}
client.update_branch_protection.assert_called_once_with("master", expected_update)
def test_ensure_branch_protection_creates_when_none_match(self) -> None:
"""When existing protections exist but none match the target branch, create a new one."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_branch_protections = MagicMock(
return_value=[{"branch_name": "develop"}, {"branch_name": "staging"}]
)
client.create_branch_protection = MagicMock(return_value={"id": 5, "branch_name": "master"})
result = client.ensure_branch_protection("master", TEST_BP_CONFIG)
assert result["id"] == 5
client.create_branch_protection.assert_called_once_with(TEST_BP_CONFIG)
def test_merge_pr(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response())
@@ -248,6 +273,37 @@ class TestGiteaClient:
timeout=DEFAULT_TIMEOUT,
)
def test_create_pr(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response({"number": 15, "html_url": "https://git.example.com/pr/15"})
)
result = client.create_pr(title="DEVX-42: Add feature", head="DEVX-42-fix", body="desc")
assert result["number"] == 15
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls",
timeout=DEFAULT_TIMEOUT,
json={"title": "DEVX-42: Add feature", "head": "DEVX-42-fix", "base": "master", "body": "desc"},
)
def test_create_pr_no_body(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response({"number": 16, "html_url": "https://git.example.com/pr/16"})
)
result = client.create_pr(title="DEVX-43: Fix bug", head="DEVX-43-fix")
assert result["number"] == 16
call_kwargs = client._session.request.call_args.kwargs
assert "body" not in call_kwargs["json"]
def test_create_pr_custom_base(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"number": 17}))
client.create_pr(title="Test", head="branch", base="develop")
call_kwargs = client._session.request.call_args.kwargs
assert call_kwargs["json"]["base"] == "develop"
def test_get_pr_files(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
@@ -278,6 +334,35 @@ class TestGiteaClient:
timeout=DEFAULT_TIMEOUT,
)
def test_list_prs(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response([{"number": 1, "title": "feat: add"}, {"number": 2, "title": "fix: bug"}])
)
result = client.list_prs()
assert len(result) == 2
assert result[0]["number"] == 1
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls",
params={"state": "all"},
timeout=DEFAULT_TIMEOUT,
)
def test_list_prs_with_params(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"number": 3, "title": "docs: update"}]))
result = client.list_prs(state="closed", q="docs")
assert len(result) == 1
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls",
params={"state": "closed", "q": "docs"},
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr_reviews(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "state": "APPROVED"}]))
@@ -566,6 +651,46 @@ class TestVikunjaClient:
json={"done": True},
)
def test_list_comments(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
return_value=_mock_response([{"id": 1, "comment": "first"}, {"id": 2, "comment": "second"}])
)
result = client.list_comments(42)
assert len(result) == 2
assert result[0]["comment"] == "first"
client._session.request.assert_called_once_with(
"GET",
"https://work.example.com/tasks/42/comments",
timeout=DEFAULT_TIMEOUT,
)
def test_update_task_safe(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
side_effect=[
_mock_response({"id": 42, "title": "My task", "done": False}),
_mock_response({"id": 42, "title": "My task", "done": True}),
]
)
result = client.update_task_safe(42, done=True)
assert result["done"] is True
assert result["title"] == "My task"
assert client._session.request.call_count == 2
client._session.request.assert_any_call(
"GET",
"https://work.example.com/tasks/42",
timeout=DEFAULT_TIMEOUT,
)
client._session.request.assert_any_call(
"POST",
"https://work.example.com/tasks/42",
timeout=DEFAULT_TIMEOUT,
json={"id": 42, "title": "My task", "done": True},
)
@patch("devx.api_clients.time.sleep")
def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None:
client = VikunjaClient("https://work.example.com", "tok")
@@ -632,6 +757,30 @@ class TestVikunjaClient:
assert exc_info.value.status == 0
assert client._session.request.call_count == 3 # MAX_RETRIES
def test_vikunja_create_task(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
return_value=_mock_response({"id": 1, "identifier": "DEVX-1", "title": "Test"})
)
result = client.create_task(6, "Test", "<p>desc</p>")
assert result["identifier"] == "DEVX-1"
client._session.request.assert_called_once_with(
"PUT",
"https://work.example.com/projects/6/tasks",
timeout=DEFAULT_TIMEOUT,
json={"title": "Test", "description": "<p>desc</p>"},
)
def test_vikunja_create_task_no_description(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
return_value=_mock_response({"id": 2, "identifier": "DEVX-2", "title": "No desc"})
)
result = client.create_task(6, "No desc")
assert result["id"] == 2
call_kwargs = client._session.request.call_args.kwargs
assert call_kwargs["json"]["description"] == ""
class TestIsRetryable:
def test_connection_error_is_retryable(self) -> None:
+38 -19
View File
@@ -47,6 +47,22 @@ class TestReadTaskid:
captured = capsys.readouterr()
assert "WARNING" not in captured.out
def test_no_warning_when_taskid_file_matches_branch(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
"""No warning when .taskid file content matches the branch task ID."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
captured = capsys.readouterr()
assert "WARNING" not in captured.out
def test_no_warning_when_taskid_file_empty(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
"""No warning when .taskid file exists but is empty."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("\n")
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
captured = capsys.readouterr()
assert "WARNING" not in captured.out
# -- extract_task_id (legacy fallback) --
@@ -253,31 +269,34 @@ class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_merge_behind_master_rebases(
def test_merge_behind_master_raises_no_rebase(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""When branch is behind master, auto-merge should NOT rebase.
Auto-rebasing creates a feedback loop: the force-push triggers a new
pull_request synchronize event, which starts a new CI run, which runs
auto-merge again, which rebases again, etc.
"""
monkeypatch.chdir(tmp_path)
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve timeout"}},
]
mock_client.merge_pr.side_effect = [
APIError(405, "HEAD branch is behind master"),
None, # Second call succeeds
]
mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master")
mock_client_cls.return_value = mock_client
with patch("devx.ci.auto_merge.run_cmd") as mock_run:
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code == 0, result.output
assert mock_client.merge_pr.call_count == 2
# Should have fetched, rebased, and pushed
assert mock_run.call_count == 5 # config name, config email, fetch, rebase, push
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert "behind master" in result.output.lower()
assert "rebase manually" in result.output.lower()
# Must NOT have called merge_pr twice (no retry after rebase)
assert mock_client.merge_pr.call_count == 1
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@@ -344,10 +363,10 @@ class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_rebase_retry_failure_raises(
def test_merge_behind_master_does_not_force_push(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""When rebase retry also fails, raises with helpful message."""
"""Verify no git commands are run when branch is behind master."""
monkeypatch.chdir(tmp_path)
mock_client = MagicMock()
@@ -358,14 +377,14 @@ class TestMain:
mock_client_cls.return_value = mock_client
with patch("devx.ci.auto_merge.run_cmd") as mock_run:
mock_run.side_effect = click.ClickException("git rebase failed")
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert "rebase" in result.output.lower()
# No git commands should be run (no rebase, no push)
mock_run.assert_not_called()
def test_main_module_block() -> None:
+90
View File
@@ -0,0 +1,90 @@
"""Unit tests for devx.tools.check_config."""
from pathlib import Path
from click.testing import CliRunner
from devx.tools.check_config import cli
class TestCheckConfig:
def test_valid_config(self, tmp_path: Path) -> None:
"""A valid [tool.devx] section with consistent versions passes."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
Path(fs, "pyproject.toml").write_text(
'[project]\nname = "test"\n'
'[project.optional-dependencies]\nci = ["devx>=0.15.0"]\ndev = ["devx>=0.15.0"]\n'
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
)
result = runner.invoke(cli)
assert result.exit_code == 0
assert "Configuration OK" in result.output
def test_missing_tool_devx_section(self, tmp_path: Path) -> None:
"""Missing [tool.devx] section fails with error."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
Path(fs, "pyproject.toml").write_text('[project]\nname = "test"\n')
result = runner.invoke(cli)
assert result.exit_code == 1
assert "missing required keys" in result.output
def test_partial_tool_devx_section(self, tmp_path: Path) -> None:
"""Partial [tool.devx] section fails with missing keys."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
Path(fs, "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\ntask_prefix = "TEST"\n')
result = runner.invoke(cli)
assert result.exit_code == 1
assert "missing required keys" in result.output
assert "vikunja_project_id" in result.output
assert "repo_owner" in result.output
assert "repo_name" in result.output
def test_version_mismatch(self, tmp_path: Path) -> None:
"""Version mismatch across extras fails."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
Path(fs, "pyproject.toml").write_text(
'[project]\nname = "test"\n'
"[project.optional-dependencies]\n"
'ci = ["devx>=0.15.0"]\n'
'dev = ["devx>=0.14.2"]\n'
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
)
result = runner.invoke(cli)
assert result.exit_code == 1
assert "version mismatch" in result.output
def test_no_pyproject_file(self, tmp_path: Path) -> None:
"""Missing pyproject.toml fails."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=str(tmp_path)):
result = runner.invoke(cli)
assert result.exit_code == 1
assert "not found" in result.output
def test_no_extras_passes(self, tmp_path: Path) -> None:
"""No optional-dependencies with devx is fine (no versions to compare)."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
Path(fs, "pyproject.toml").write_text(
'[project]\nname = "test"\n'
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
)
result = runner.invoke(cli)
assert result.exit_code == 0
assert "Configuration OK" in result.output
def test_single_extra_passes(self, tmp_path: Path) -> None:
"""Single extra with devx version is fine (no mismatch possible)."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
Path(fs, "pyproject.toml").write_text(
'[project]\nname = "test"\n'
'[project.optional-dependencies]\nci = ["devx>=0.15.0", "pytest"]\n'
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
)
result = runner.invoke(cli)
assert result.exit_code == 0
+32 -14
View File
@@ -41,9 +41,12 @@ class TestCheckTranslationSet:
src_dir.mkdir()
(src_dir / "mod.py").write_text('_("Hello")\n')
trans_file = tmp_path / "translations.json"
trans_file.write_text(
json.dumps({"Hello": {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好"}})
)
all_langs = {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好", "pl": "Cześć"}
# Include dynamic keys since collect_keys now adds them for all dirs
data = {"Hello": all_langs}
for dk in check_translations.DYNAMIC_KEYS:
data[dk] = all_langs
trans_file.write_text(json.dumps(data))
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert not result.errors
@@ -170,9 +173,11 @@ class TestMain:
def test_translations_flag(self, tmp_path: Path) -> None:
"""--translations flag should check a specific file."""
trans_file = tmp_path / "translations.json"
trans_file.write_text(
json.dumps({"Hello": {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好"}})
)
all_langs = {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好", "pl": "Cześć"}
data = {"Hello": all_langs}
for dk in check_translations.DYNAMIC_KEYS:
data[dk] = all_langs
trans_file.write_text(json.dumps(data))
(tmp_path / "mod.py").write_text('_("Hello")\n')
runner = CliRunner()
@@ -251,6 +256,19 @@ class TestDevxI18n:
monkeypatch.delenv("DEVX_LANG", raising=False)
importlib.reload(devx.i18n)
def test_polish_translation(self, monkeypatch: pytest.MonkeyPatch) -> None:
import importlib
monkeypatch.setenv("DEVX_LANG", "pl")
import devx.i18n
importlib.reload(devx.i18n)
result = devx.i18n._("Running tests...")
assert "Uruchamianie testów" in result
monkeypatch.delenv("DEVX_LANG", raising=False)
importlib.reload(devx.i18n)
def test_unsupported_lang_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
import importlib
@@ -290,18 +308,18 @@ class TestCollectKeys:
assert "should_appear" in keys
assert "should_not_appear" not in keys
def test_non_default_dir_no_dynamic_keys(self, tmp_path: Path) -> None:
"""Non-default source dirs should not include DYNAMIC_KEYS."""
def test_non_default_dir_includes_dynamic_keys(self, tmp_path: Path) -> None:
"""Non-default source dirs should also include DYNAMIC_KEYS."""
(tmp_path / "mod.py").write_text('_("mykey")\n')
keys = check_translations.collect_keys(tmp_path)
assert "mykey" in keys
# Dynamic keys should NOT be present for non-default dirs
assert "completed" not in keys
assert "pending" not in keys
# Dynamic keys should be present for all dirs
assert "completed" in keys
assert "pending" in keys
def test_default_dir_includes_dynamic_keys(self) -> None:
"""The default source dir should include DYNAMIC_KEYS."""
keys = check_translations.collect_keys(check_translations.DEFAULT_SRC_DIR)
def test_default_dir_includes_dynamic_keys(self, tmp_path: Path) -> None:
"""collect_keys includes DYNAMIC_KEYS even with an empty source dir."""
keys = check_translations.collect_keys(tmp_path)
assert "completed" in keys
assert "pending" in keys
assert "in_progress" in keys
+122
View File
@@ -162,6 +162,15 @@ class TestClassifierConfig:
assert config.user_facing_overrides == []
assert config.tags == {}
def test_from_pyproject_dedupes_existing_default(self, tmp_path: Path) -> None:
"""Project infrastructure patterns already in defaults are not duplicated."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx.classify]\ninfrastructure = [".gitea/**", "scripts/**"]\n')
config = ClassifierConfig.from_pyproject(str(pyproject))
# .gitea/** should appear only once (deduplicated with defaults)
assert config.infrastructure.count(".gitea/**") == 1
assert "scripts/**" in config.infrastructure
def test_defaults_are_empty_for_bare_constructor(self) -> None:
"""ClassifierConfig() without from_pyproject has empty lists."""
config = ClassifierConfig()
@@ -515,6 +524,27 @@ class TestMain:
assert "Ansible files" in result.output
assert "ansible/tasks/main.yml" in result.output
@patch("devx.ci.classify_changes._get_classifier")
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_default_mode_skips_empty_tag(
self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock
) -> None:
"""Tags with no matching files are skipped in default mode output."""
mock_changes.return_value = ["ansible/tasks/main.yml"]
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={"ansible": ["ansible/**"], "docs": ["docs/**"]},
)
)
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "Ansible files" in result.output
# docs tag has no matching files — should not appear
assert "Docs files" not in result.output
@patch("devx.ci.classify_changes.get_latest_tag", return_value="")
def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None:
runner = CliRunner()
@@ -742,3 +772,95 @@ class TestGithubOutput:
assert "user-facing-changed=true" in content
# No tag outputs since no tags are configured
assert "ansible-changed" not in content
@patch("devx.ci.classify_changes._get_classifier")
def test_force_outputs_true(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""--force with --github-output writes user-facing-changed=true and all tags true."""
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
runner = CliRunner()
result = runner.invoke(main, ["--github-output", "--force"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "user-facing-changed=true" in content
assert "ansible-changed=true" in content
assert "Forced user-facing-changed=true" in result.output
@patch("devx.ci.classify_changes._get_classifier")
def test_force_without_github_output_does_nothing(
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""--force without --github-output falls through to normal classification."""
mock_clf.return_value = self._make_classifier_with_ansible()
monkeypatch.setenv("GITHUB_OUTPUT", str(tmp_path / "output.txt"))
with patch.object(classify_changes_mod, "get_latest_tag", return_value="v1.0"):
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
runner = CliRunner()
result = runner.invoke(main, ["--force", "--quiet"])
assert result.exit_code == 0
assert result.output.strip() == "false"
@patch("devx.ci.classify_changes._get_classifier")
def test_force_no_tags(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""--force with --github-output and no tags writes only user-facing-changed=true."""
mock_clf.return_value = ChangeClassifier(
ClassifierConfig(
infrastructure=[".gitea/**"],
tags={},
)
)
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
runner = CliRunner()
result = runner.invoke(main, ["--github-output", "--force"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "user-facing-changed=true" in content
assert "ansible-changed" not in content
@patch("devx.ci.classify_changes._get_classifier")
def test_force_deploy_env_var(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""FORCE_DEPLOY=true env var activates force mode without --force flag."""
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
monkeypatch.setenv("FORCE_DEPLOY", "true")
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "user-facing-changed=true" in content
assert "ansible-changed=true" in content
@patch("devx.ci.classify_changes._get_classifier")
def test_force_deploy_env_var_false(
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""FORCE_DEPLOY=false does not activate force mode."""
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
monkeypatch.setenv("FORCE_DEPLOY", "false")
with patch.object(classify_changes_mod, "get_latest_tag", return_value="v1.0"):
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "user-facing-changed=false" in content
@patch("devx.ci.classify_changes._get_classifier")
def test_force_flag_overrides_env_var(
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""--force flag works even when FORCE_DEPLOY=false."""
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
monkeypatch.setenv("FORCE_DEPLOY", "false")
runner = CliRunner()
result = runner.invoke(main, ["--github-output", "--force"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "user-facing-changed=true" in content
-7
View File
@@ -233,13 +233,6 @@ class TestNewCiCommands:
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.distribute_files", ["--pattern", "*.py"])
@patch("devx.cli._run_module")
def test_ci_merge_junit(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "merge-junit", "--", "--output", "merged.xml"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.merge_junit", ["--output", "merged.xml"])
@patch("devx.cli._run_module")
def test_ci_integration_guard(self, mock_run: MagicMock) -> None:
runner = CliRunner()
+106 -25
View File
@@ -1,12 +1,14 @@
"""Unit tests for config module constants."""
import importlib
from pathlib import Path
from devx.config import (
CONVENTIONAL_RE,
DEFAULT_PER_PAGE,
DEFAULT_TIMEOUT,
GITEA_API_URL,
MAX_RETRIES,
REPO_OWNER,
RETRY_BACKOFF_BASE,
RETRY_STATUS_CODES,
TASK_ID_RE,
@@ -20,25 +22,10 @@ class TestConfigConstants:
assert "api/v1" in GITEA_API_URL
assert "api/v1" in VIKUNJA_API_URL
def test_project_ids(self, monkeypatch: object) -> None:
"""VIKUNJA_PROJECT_ID defaults to 6 when DEVX_VIKUNJA_PROJECT_ID is not set."""
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
import importlib
import devx.config as cfg
importlib.reload(cfg)
assert cfg.VIKUNJA_PROJECT_ID == 6
# Restore module state
importlib.reload(cfg)
def test_timeouts(self) -> None:
assert DEFAULT_TIMEOUT == 30
assert DEFAULT_PER_PAGE == 50
def test_owner(self) -> None:
assert REPO_OWNER == ""
def test_task_prefix(self) -> None:
assert TASK_PREFIX == "DEVX"
@@ -64,28 +51,122 @@ class TestConfigConstants:
assert 503 in RETRY_STATUS_CODES
assert 504 in RETRY_STATUS_CODES
def test_env_var_override(self, monkeypatch: object) -> None:
"""Test that env vars override defaults at import time."""
# We can't easily re-import the module, but we can verify
# the constants respect env vars by checking the module source.
class TestPyprojectReading:
"""Test that config.py reads [tool.devx] from pyproject.toml."""
def test_pyproject_provides_values(self) -> None:
"""When pyproject.toml has [tool.devx], values are read from it."""
import devx.config as cfg
assert cfg.GITEA_API_URL # always non-empty
assert cfg.VIKUNJA_API_URL # always non-empty
# devx's own pyproject.toml has task_prefix=DEVX, vikunja_project_id=8
assert cfg.TASK_PREFIX == "DEVX"
assert cfg.VIKUNJA_PROJECT_ID == 8
assert cfg.REPO_OWNER == "oblachno-oss"
def test_env_overrides_pyproject(self, monkeypatch: object) -> None:
"""Env vars take priority over pyproject.toml."""
monkeypatch.setenv("DEVX_TASK_PREFIX", "CUSTOM")
import devx.config as cfg
importlib.reload(cfg)
assert cfg.TASK_PREFIX == "CUSTOM"
assert cfg.TASK_ID_RE.search("CUSTOM-42")
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
importlib.reload(cfg)
def test_no_pyproject_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
"""When no pyproject.toml exists, defaults are used."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
monkeypatch.delenv("DEVX_REPO_OWNER", raising=False)
import devx.config as cfg
importlib.reload(cfg)
assert cfg.TASK_PREFIX == "DEVX"
assert cfg.VIKUNJA_PROJECT_ID == 6
assert cfg.REPO_OWNER == ""
importlib.reload(cfg)
def test_invalid_toml_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
"""When pyproject.toml is invalid TOML, defaults are used."""
(tmp_path / "pyproject.toml").write_text("invalid toml {{{")
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
import devx.config as cfg
importlib.reload(cfg)
assert cfg.TASK_PREFIX == "DEVX"
importlib.reload(cfg)
def test_no_devx_section_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
"""When pyproject.toml has no [tool.devx], defaults are used."""
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n')
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
import devx.config as cfg
importlib.reload(cfg)
assert cfg.TASK_PREFIX == "DEVX"
assert cfg.VIKUNJA_PROJECT_ID == 6
importlib.reload(cfg)
def test_pyproject_int_value_used(self, monkeypatch: object, tmp_path: Path) -> None:
"""When pyproject.toml has an int value, it is used (covers _get_int return)."""
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\nvikunja_project_id = 42\n')
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
import devx.config as cfg
importlib.reload(cfg)
assert cfg.VIKUNJA_PROJECT_ID == 42
importlib.reload(cfg)
def test_env_int_override(self, monkeypatch: object, tmp_path: Path) -> None:
"""Env var override for int config takes priority over pyproject.toml."""
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\nvikunja_project_id = 42\n')
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("DEVX_VIKUNJA_PROJECT_ID", "99")
import devx.config as cfg
importlib.reload(cfg)
assert cfg.VIKUNJA_PROJECT_ID == 99
importlib.reload(cfg)
def test_tool_not_dict_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
"""When [tool] is not a dict, defaults are used."""
(tmp_path / "pyproject.toml").write_text('tool = "not a dict"\n')
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
import devx.config as cfg
importlib.reload(cfg)
assert cfg.TASK_PREFIX == "DEVX"
importlib.reload(cfg)
def test_devx_not_dict_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
"""When [tool.devx] is not a dict, defaults are used."""
(tmp_path / "pyproject.toml").write_text('[tool]\ndevx = "not a dict"\n')
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
import devx.config as cfg
importlib.reload(cfg)
assert cfg.TASK_PREFIX == "DEVX"
importlib.reload(cfg)
class TestTaskPrefixOverride:
def test_task_prefix_from_env(self, monkeypatch: object) -> None:
"""Verify TASK_PREFIX reads from DEVX_TASK_PREFIX env var."""
monkeypatch.setenv("DEVX_TASK_PREFIX", "INFRA")
import importlib
import devx.config as cfg
importlib.reload(cfg)
assert cfg.TASK_PREFIX == "INFRA"
assert cfg.TASK_ID_RE.search("INFRA-42")
assert not cfg.TASK_ID_RE.search("DEVX-42")
# Restore
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
importlib.reload(cfg)
+193
View File
@@ -0,0 +1,193 @@
"""Unit tests for devx.tools.create_pr."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.tools.create_pr import (
cli,
create_pr,
extract_task_id,
find_existing_pr,
get_repo_name,
get_vikunja_task_title,
)
class TestExtractTaskId:
def test_valid(self) -> None:
assert extract_task_id("DEVX-42-fix") == "DEVX-42"
def test_invalid(self) -> None:
assert extract_task_id("feature") == ""
class TestGetRepoName:
@patch.dict("os.environ", {"DEVX_REPO_NAME": "infra"})
def test_from_env(self) -> None:
assert get_repo_name() == "infra"
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
def test_from_github(self) -> None:
assert get_repo_name() == "infra"
@patch.dict("os.environ", {}, clear=True)
def test_missing_raises(self) -> None:
with pytest.raises(click.ClickException, match="Repository name"):
get_repo_name()
class TestGetVikunjaTaskTitle:
@patch("devx.tools.create_pr.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-42", "title": "Add feature"}]
mock_client_cls.return_value = mock_client
assert get_vikunja_task_title("DEVX-42") == "Add feature"
@patch.dict("os.environ", {}, clear=True)
def test_no_token(self) -> None:
with pytest.raises(click.ClickException, match="VIKUNJA_TOKEN"):
get_vikunja_task_title("DEVX-42")
@patch("devx.tools.create_pr.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_not_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="Could not find"):
get_vikunja_task_title("DEVX-42")
@patch("devx.tools.create_pr.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_pagination_not_found(self, mock_client_cls: MagicMock) -> None:
from devx.config import DEFAULT_PER_PAGE
mock_client = MagicMock()
page1 = [{"identifier": f"OTHER-{i}"} for i in range(DEFAULT_PER_PAGE)]
page2 = [{"identifier": "OTHER-99"}]
mock_client.list_project_tasks.side_effect = [page1, page2]
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="Could not find"):
get_vikunja_task_title("DEVX-42")
class TestFindExistingPr:
def test_found(self) -> None:
client = MagicMock()
client.list_prs.return_value = [{"head": {"ref": "DEVX-42-fix"}, "number": 10}]
result = find_existing_pr(client, "DEVX-42-fix")
assert result is not None
assert result["number"] == 10
def test_not_found(self) -> None:
client = MagicMock()
client.list_prs.return_value = [{"head": {"ref": "other"}, "number": 10}]
result = find_existing_pr(client, "DEVX-42-fix")
assert result is None
class TestCreatePr:
@patch("devx.tools.create_pr.GiteaClient")
@patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature")
@patch("devx.tools.create_pr.find_existing_pr", return_value=None)
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
def test_creates_new_pr(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_pr.return_value = {"number": 15, "html_url": "https://git.example.com/pr/15"}
mock_gitea.return_value = mock_client
result = create_pr("DEVX-42-fix", "master", "body", "owner", "repo")
assert result["number"] == 15
mock_client.create_pr.assert_called_once_with(
title="DEVX-42: Add feature",
head="DEVX-42-fix",
base="master",
body="body",
)
@patch("devx.tools.create_pr.GiteaClient")
@patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature")
@patch("devx.tools.create_pr.find_existing_pr")
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
def test_existing_pr_idempotent(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None:
mock_find.return_value = {"number": 10, "html_url": "https://git.example.com/pr/10"}
mock_client = MagicMock()
mock_gitea.return_value = mock_client
result = create_pr("DEVX-42-fix", "master", "", "owner", "repo")
assert result["number"] == 10
mock_client.create_pr.assert_not_called()
@patch.dict("os.environ", {}, clear=True)
def test_no_repo_token(self) -> None:
with pytest.raises(click.ClickException, match="REPO_TOKEN"):
create_pr("DEVX-42-fix", "master", "", "owner", "repo")
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
def test_no_task_id_in_branch(self) -> None:
with pytest.raises(click.ClickException, match="does not contain a task ID"):
create_pr("feature-branch", "master", "", "owner", "repo")
class TestCli:
@patch("devx.tools.create_pr.create_pr")
@patch("devx.tools.create_pr.subprocess.run")
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_auto_detect_branch(self, mock_repo: MagicMock, mock_run: MagicMock, mock_create: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="DEVX-42-fix\n", returncode=0)
mock_create.return_value = {"number": 1}
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
mock_create.assert_called_once_with("DEVX-42-fix", "master", "", "owner", "repo")
@patch("devx.tools.create_pr.create_pr")
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_explicit_branch(self, mock_repo: MagicMock, mock_create: MagicMock) -> None:
mock_create.return_value = {"number": 1}
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "DEVX-42-fix"])
assert result.exit_code == 0
@patch("devx.tools.create_pr.create_pr")
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_body_from_stdin(self, mock_repo: MagicMock, mock_create: MagicMock) -> None:
mock_create.return_value = {"number": 1}
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "DEVX-42-fix", "--body", "-"], input="PR body text")
assert result.exit_code == 0
mock_create.assert_called_once()
assert mock_create.call_args.args[2] == "PR body text"
@patch("devx.tools.create_pr.REPO_OWNER", "")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_missing_owner(self, mock_repo: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "DEVX-42-fix"])
assert result.exit_code != 0
assert "owner" in result.output.lower()
@patch("devx.tools.create_pr.create_pr")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_explicit_owner(self, mock_repo: MagicMock, mock_create: MagicMock) -> None:
mock_create.return_value = {"number": 1}
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "DEVX-42-fix", "--owner", "custom"])
assert result.exit_code == 0
mock_create.assert_called_once_with("DEVX-42-fix", "master", "", "custom", "repo")
@patch("devx.tools.create_pr.subprocess.run")
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_git_detect_failure(self, mock_repo: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="", stderr="fatal: not a git repository", returncode=128)
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code != 0
assert "Could not detect" in result.output
+83
View File
@@ -0,0 +1,83 @@
"""Unit tests for devx.tools.create_task."""
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.tools.create_task import cli
class TestCreateTaskCli:
@patch("devx.tools.create_task.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_success(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_task.return_value = {"identifier": "DEVX-60", "id": 60}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(cli, ["--title", "Add feature X"])
assert result.exit_code == 0
assert "DEVX-60" in result.output
mock_client.create_task.assert_called_once()
@patch.dict("os.environ", {}, clear=True)
def test_missing_token(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--title", "Add feature X"])
assert result.exit_code != 0
assert "VIKUNJA_TOKEN" in result.output
@patch("devx.tools.create_task.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_with_description(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_task.return_value = {"identifier": "DEVX-61", "id": 61}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
cli,
["--title", "Add feature Y", "--description", "<p>desc</p>"],
)
assert result.exit_code == 0
call_args = mock_client.create_task.call_args
assert call_args.args[1] == "Add feature Y"
assert call_args.args[2] == "<p>desc</p>"
@patch("devx.tools.create_task.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_description_from_stdin(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_task.return_value = {"identifier": "DEVX-62", "id": 62}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
cli,
["--title", "Add feature Z", "--description", "-"],
input="<p>stdin desc</p>",
)
assert result.exit_code == 0
mock_client.create_task.assert_called_once()
call_args = mock_client.create_task.call_args
assert call_args.args[2] == "<p>stdin desc</p>"
@patch("devx.tools.create_task.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_custom_project_id(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_task.return_value = {"identifier": "GRM-10", "id": 10}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(cli, ["--title", "Task", "--project-id", "3"])
assert result.exit_code == 0
mock_client.create_task.assert_called_once_with(3, "Task", "")
@patch("devx.tools.create_task.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_no_identifier_in_response(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_task.return_value = {"id": 99}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(cli, ["--title", "Task"])
assert result.exit_code == 0
assert "id=99" in result.output
+12
View File
@@ -237,3 +237,15 @@ class TestMain:
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code != 0
@patch("devx.ci.discover_runners.get_runner_count", return_value=2)
def test_explicit_owner_and_repo(self, mock_count: MagicMock) -> None:
"""When --owner and --repo are provided, env vars are not used."""
runner = CliRunner()
result = runner.invoke(main, ["--owner", "myorg", "--repo", "myrepo"])
assert result.exit_code == 0
mock_count.assert_called_once()
# Verify owner/repo passed through
args, kwargs = mock_count.call_args
assert "myorg" in args
assert "myrepo" in args
+61
View File
@@ -7,6 +7,7 @@ from click.testing import CliRunner
from devx.ci.distribute_files import (
DEFAULT_MAX_RUNNERS,
_file_weight,
discover_files,
distribute,
files_for_runner,
@@ -101,6 +102,23 @@ class TestCli:
assert "ASSIGNED_FILES=" in content
assert "SKIP=false" in content
def test_github_env_multiline_uses_heredoc(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
for i in range(6):
(tmp_path / f"test_{i}.py").write_text("")
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "1", "--max-runners", "2", "--github-env"],
)
assert result.exit_code == 0
content = gh_file.read_text()
# Multi-line values must use heredoc syntax to avoid corrupting $GITHUB_ENV
assert "ASSIGNED_FILES<<EOF" in content
assert content.count("EOF") >= 2
assert "SKIP=false" in content
def test_skip_if_excess(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
@@ -152,3 +170,46 @@ def test_main_module_block() -> None:
import devx.ci.distribute_files as mod
assert hasattr(mod, "main")
class TestFileWeight:
def test_weight_based_on_size(self, tmp_path: Path) -> None:
f = tmp_path / "test_big.py"
f.write_text("x" * 5000)
assert _file_weight(str(f)) == 5000
def test_min_weight_is_1(self, tmp_path: Path) -> None:
f = tmp_path / "empty.py"
f.write_text("")
assert _file_weight(str(f)) == 1
def test_nonexistent_file_returns_1(self) -> None:
assert _file_weight("/nonexistent/file.py") == 1
class TestDistributeLpt:
def test_large_files_on_different_runners(self, tmp_path: Path) -> None:
"""Two large files should go to different runners."""
big1 = tmp_path / "test_big1.py"
big2 = tmp_path / "test_big2.py"
small1 = tmp_path / "test_small1.py"
small2 = tmp_path / "test_small2.py"
big1.write_text("x" * 10000)
big2.write_text("x" * 10000)
small1.write_text("x")
small2.write_text("x")
files = [str(big1), str(big2), str(small1), str(small2)]
groups = distribute(files, 2)
runner_0 = groups[0]
runner_1 = groups[1]
# Big files should be on different runners
assert not (str(big1) in runner_0 and str(big2) in runner_0)
assert not (str(big1) in runner_1 and str(big2) in runner_1)
def test_all_files_preserved(self, tmp_path: Path) -> None:
for i in range(5):
(tmp_path / f"test_{i}.py").write_text(f"content {i}" * (i + 1))
files = [str(tmp_path / f"test_{i}.py") for i in range(5)]
groups = distribute(files, 3)
flat = sorted(f for group in groups for f in group)
assert flat == sorted(files)
+91
View File
@@ -13,6 +13,8 @@ from devx.molecule.distribute_molecule import (
PLATFORMS,
MultiRoleTestPair,
TestPair,
_lpt_distribute,
_scenario_weight,
build_multi_role_pairs,
build_pairs,
cli,
@@ -477,3 +479,92 @@ class TestCliMultiRole:
result = runner.invoke(cli, ["--roles-root", str(roles), "--runner-index", "0", "--max-runners", "3"])
assert result.exit_code != 0
assert "out of range" in result.output
class TestScenarioWeight:
def test_known_heavy_scenario(self) -> None:
assert _scenario_weight("nextcloud") == 10
assert _scenario_weight("gitea") == 8
def test_known_light_scenario(self) -> None:
assert _scenario_weight("binary") == 2
def test_default_weight(self) -> None:
assert _scenario_weight("unknown-scenario") == 3
def test_case_insensitive(self) -> None:
assert _scenario_weight("NextCloud") == 10
assert _scenario_weight("GITEA") == 8
def test_substring_match(self) -> None:
assert _scenario_weight("nextcloud-with-redis") == 10
assert _scenario_weight("custom-gitea-setup") == 8
class TestLptDistribute:
def test_equal_weights_produce_even_split(self) -> None:
items = list(range(6))
weights = [3, 3, 3, 3, 3, 3]
groups = _lpt_distribute(items, weights, 3)
assert all(len(g) == 2 for g in groups)
def test_heavy_items_on_different_runners(self) -> None:
"""Two heavy items should go to different runners."""
items = ["heavy-a", "heavy-b", "light-1", "light-2"]
weights = [10, 10, 1, 1]
groups = _lpt_distribute(items, weights, 2)
# Heavy items should be on different runners
flat = [item for group in groups for item in group]
assert "heavy-a" in flat
assert "heavy-b" in flat
runner_a = next(i for i, g in enumerate(groups) if "heavy-a" in g)
runner_b = next(i for i, g in enumerate(groups) if "heavy-b" in g)
assert runner_a != runner_b
def test_load_balance_with_varying_weights(self) -> None:
"""LPT should produce better load balance than round-robin."""
items = list(range(7))
# Simulate infra-like weights: 2 heavy, 2 medium, 3 light
weights = [10, 10, 7, 7, 3, 3, 3]
groups = _lpt_distribute(items, weights, 3)
loads = [sum(weights[i] for i in g) for g in groups]
# LPT should produce loads close to total/3 = 43/3 ≈ 14.3
# Round-robin would produce: 10+7+3=20, 10+7+3=20, 3=3 (terrible)
assert max(loads) - min(loads) <= 10 # Reasonably balanced
def test_more_runners_than_items(self) -> None:
items = ["a"]
weights = [5]
groups = _lpt_distribute(items, weights, 5)
assert len(groups) == 5
assert len(groups[0]) == 1
assert all(len(g) == 0 for g in groups[1:])
def test_empty_items(self) -> None:
groups = _lpt_distribute([], [], 3)
assert groups == [[], [], []]
def test_preserves_all_items(self) -> None:
items = ["a", "b", "c", "d", "e"]
weights = [5, 3, 8, 1, 2]
groups = _lpt_distribute(items, weights, 3)
flat = sorted(item for group in groups for item in group)
assert flat == sorted(items)
class TestDistributeLpt:
def test_nextcloud_on_separate_runners(self) -> None:
"""Two nextcloud scenarios should go to different runners."""
pairs = [
TestPair("nextcloud", {"name": "p", "image": "i", "command": ""}),
TestPair("nextcloud-backup", {"name": "p", "image": "i", "command": ""}),
TestPair("binary", {"name": "p", "image": "i", "command": ""}),
TestPair("default", {"name": "p", "image": "i", "command": ""}),
]
groups = distribute(pairs, 2)
# Both nextcloud scenarios (weight 10) should be on different runners
runner_0 = [p.scenario for p in groups[0]]
runner_1 = [p.scenario for p in groups[1]]
# nextcloud and nextcloud-backup should NOT be on the same runner
assert not ("nextcloud" in runner_0 and "nextcloud-backup" in runner_0)
assert not ("nextcloud" in runner_1 and "nextcloud-backup" in runner_1)
+15
View File
@@ -46,6 +46,21 @@ class TestExtractCliCommands:
commands = extract_cli_commands()
assert "my_command" in commands
def test_command_decorator_no_def_fallback(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When a command decorator has no name and no following def, it is skipped."""
from devx.ci import doc_coverage
fake_cli = tmp_path / "cli.py"
# The last @cli.command() has no explicit name and no def statement after it
fake_cli.write_text(
"@click.group()\ndef cli():\n pass\n@cli.command()\ndef real_cmd():\n pass\n@cli.command()\npass\n"
)
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli)
commands = extract_cli_commands()
# real_cmd should be found via def fallback; the bare @cli.command() is skipped
assert "real_cmd" in commands
assert "pass" not in commands
class TestCheckCommandDocumented:
def test_finds_command_in_heading(self) -> None:
+17
View File
@@ -97,6 +97,15 @@ class TestDetectCoverageTarget:
def test_returns_none_when_no_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
assert detect_coverage_target(tmp_path) is None
def test_pyproject_without_cov_falls_back_to_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
"""When pyproject exists but has no --cov=, falls back to package name."""
src = tmp_path / "src"
pkg = src / "mypkg"
pkg.mkdir(parents=True)
(pkg / "__init__.py").write_text('__version__ = "1.0"\n')
(tmp_path / "pyproject.toml").write_text('[tool.pytest.ini_options]\naddopts = "-ra"\n')
assert detect_coverage_target(tmp_path) == "src/mypkg"
class TestDetectTestpaths:
def test_parses_from_pyproject(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
@@ -112,6 +121,14 @@ class TestDetectTestpaths:
(tmp_path / "pyproject.toml").write_text('[tool.pytest.ini_options]\ntestpaths = ["tests", "nonexistent"]\n')
assert detect_testpaths(tmp_path) == ["tests"]
def test_all_paths_nonexistent_falls_back_to_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
"""When all testpaths are non-existent, falls back to tests/ directory."""
(tmp_path / "tests").mkdir()
(tmp_path / "pyproject.toml").write_text(
'[tool.pytest.ini_options]\ntestpaths = ["nonexistent1", "nonexistent2"]\n'
)
assert detect_testpaths(tmp_path) == ["tests"]
def test_falls_back_to_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
(tmp_path / "tests").mkdir()
assert detect_testpaths(tmp_path) == ["tests"]
+6
View File
@@ -77,6 +77,12 @@ class TestTeaCLIRun:
with pytest.raises(TeaCLIError, match="auth error"):
cli._run(["labels", "list"])
def test_run_tea_not_found_raises_tea_error(self) -> None:
cli = TeaCLI(tea_bin="tea")
with patch("subprocess.run", side_effect=FileNotFoundError("tea not found")):
with pytest.raises(TeaCLIError, match="tea binary not found"):
cli._run(["labels", "list"])
def test_run_includes_json_flag(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="[]", stderr="")
+3 -23
View File
@@ -43,26 +43,6 @@ class TestCli:
assert result.exit_code == 1
assert "failed" in result.output
def test_junit_output_passed_to_pytest(self) -> None:
with (
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
["--junit-output", "junit-results/runner-1.xml", "--", "test_foo.py"],
)
assert result.exit_code == 0
call_args = mock_popen.call_args[0][0]
assert "--junitxml" in call_args
assert "junit-results/runner-1.xml" in call_args
def test_pytest_args_passed_through(self) -> None:
with (
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
@@ -136,7 +116,7 @@ class TestCli:
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
@@ -183,7 +163,7 @@ class TestCli:
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg", side_effect=ProcessLookupError("no such process")),
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
@@ -228,7 +208,7 @@ class TestCli:
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
-91
View File
@@ -1,91 +0,0 @@
"""Unit tests for devx.ci.merge_junit."""
from pathlib import Path
from xml.etree import ElementTree as ET
import pytest
from click.testing import CliRunner
from devx.ci.merge_junit import main, merge_files
def _write_suite(path: Path, name: str, tests: int, failures: int) -> None:
suite = ET.Element("testsuite", name=name, tests=str(tests), failures=str(failures))
for i in range(tests):
tc = ET.SubElement(suite, "testcase", classname="cls", name=f"test{i}", time="0.1")
if i < failures:
ET.SubElement(tc, "failure", message="fail")
tree = ET.ElementTree(suite)
tree.write(path, encoding="UTF-8", xml_declaration=True)
class TestMergeFiles:
def test_merges_multiple_suites(self, tmp_path: Path) -> None:
_write_suite(tmp_path / "runner-1.xml", "r1", tests=3, failures=1)
_write_suite(tmp_path / "runner-2.xml", "r2", tests=2, failures=0)
merged, total_tests, total_failures = merge_files(str(tmp_path / "runner-*.xml"))
assert total_tests == 5
assert total_failures == 1
assert merged.tag == "testsuites"
assert len(merged) == 2
def test_no_files_returns_empty(self, tmp_path: Path) -> None:
merged, total_tests, total_failures = merge_files(str(tmp_path / "nonexistent-*.xml"))
assert total_tests == 0
assert total_failures == 0
assert merged.tag == "testsuites"
assert len(merged) == 0
def test_handles_testsuites_wrapper_root(self, tmp_path: Path) -> None:
wrapper = ET.Element("testsuites")
suite = ET.SubElement(wrapper, "testsuite", name="r1", tests="4", failures="2")
ET.SubElement(suite, "testcase", classname="c", name="t", time="0.1")
tree = ET.ElementTree(wrapper)
tree.write(tmp_path / "runner-1.xml", encoding="UTF-8", xml_declaration=True)
merged, total_tests, total_failures = merge_files(str(tmp_path / "runner-*.xml"))
assert total_tests == 4
assert total_failures == 2
class TestCli:
def test_writes_merged_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_write_suite(tmp_path / "runner-1.xml", "r1", tests=2, failures=0)
_write_suite(tmp_path / "runner-2.xml", "r2", tests=3, failures=0)
out = tmp_path / "merged.xml"
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "runner-*.xml"), "--output", str(out)],
)
assert result.exit_code == 0
assert out.exists()
tree = ET.parse(out)
root = tree.getroot()
assert root.get("tests") == "5"
assert root.get("failures") == "0"
def test_exits_nonzero_on_failures(self, tmp_path: Path) -> None:
_write_suite(tmp_path / "runner-1.xml", "r1", tests=2, failures=1)
out = tmp_path / "merged.xml"
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "runner-*.xml"), "--output", str(out)],
)
assert result.exit_code != 0
assert "failures" in result.output
def test_no_files_exits_zero(self, tmp_path: Path) -> None:
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "nonexistent-*.xml"), "--output", str(tmp_path / "out.xml")],
)
assert result.exit_code == 0
assert "No JUnit" in result.output or "skipping" in result.output
def test_main_module_block() -> None:
import devx.ci.merge_junit as mod
assert hasattr(mod, "main")
+9 -170
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import os
import subprocess # nosec B404
import time
import xml.etree.ElementTree as ET
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -22,7 +21,6 @@ from devx.molecule.molecule_ci_guard import (
parse_pair,
poll_for_other_failures,
resolve_role_dir,
write_junit_report,
)
@@ -99,6 +97,11 @@ class TestBuildEnvForPair:
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_PLATFORM_COMMAND": "old"})
assert "MOLECULE_PLATFORM_COMMAND" not in env
def test_preserves_existing_molecule_home(self) -> None:
"""When MOLECULE_HOME is already set, it is not overridden."""
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_HOME": "/custom/home"})
assert env["MOLECULE_HOME"] == "/custom/home"
class TestPollForOtherFailures:
def test_sets_failed_event_when_other_runner_fails(self) -> None:
@@ -299,7 +302,7 @@ class TestCli:
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
@@ -335,7 +338,7 @@ class TestCli:
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
patch("time.sleep", side_effect=lambda x: real_sleep(0.05)),
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
):
mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}]
proc = MagicMock()
@@ -382,7 +385,7 @@ class TestCli:
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
):
mock_getpgid.return_value = 123
mock_killpg.side_effect = ProcessLookupError("no such process")
@@ -429,7 +432,7 @@ class TestCli:
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
):
mock_getpgid.return_value = 123
mock_killpg.side_effect = [None, ProcessLookupError("no such process")]
@@ -504,40 +507,6 @@ class TestResolveRoleDir:
assert result == tmp_path / "ansible" / "roles" / "gitea-runner"
class TestWriteJunitReport:
def test_writes_report_with_passing_tests(self, tmp_path: Path) -> None:
output = str(tmp_path / "junit-results" / "runner-1.xml")
testcases = [
{"role": "gitea-runner", "scenario": "default", "time": 5.2, "passed": True, "error": None},
{"role": "docker-base", "scenario": "lifecycle", "time": 3.1, "passed": True, "error": None},
]
write_junit_report(output, testcases, 1)
tree = ET.parse(output)
root = tree.getroot()
assert root.get("tests") == "2"
assert root.get("failures") == "0"
assert len(root) == 2
def test_writes_report_with_failures(self, tmp_path: Path) -> None:
output = str(tmp_path / "runner-2.xml")
testcases = [
{"role": "", "scenario": "default", "time": 1.0, "passed": False, "error": "Exit code: 1"},
]
write_junit_report(output, testcases, 2)
tree = ET.parse(output)
root = tree.getroot()
assert root.get("tests") == "1"
assert root.get("failures") == "1"
failure = root[0][0]
assert failure.tag == "failure"
assert failure.text == "Exit code: 1"
def test_creates_parent_directory(self, tmp_path: Path) -> None:
output = str(tmp_path / "deep" / "nested" / "dir" / "runner.xml")
write_junit_report(output, [], 0)
assert Path(output).exists()
class TestCliMultiRole:
def test_multi_role_pair_passes(self, tmp_path: Path) -> None:
from click.testing import CliRunner
@@ -563,133 +532,3 @@ class TestCliMultiRole:
)
assert result.exit_code == 0
assert "All molecule tests passed" in result.output
def test_junit_output_written(self, tmp_path: Path) -> None:
from click.testing import CliRunner
roles_root = tmp_path / "ansible" / "roles"
(roles_root / "gitea-runner").mkdir(parents=True)
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
with (
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
mock_run.return_value = MagicMock(returncode=0)
runner = CliRunner()
result = runner.invoke(
cli,
[
"--roles-root",
str(roles_root),
"--junit-output",
junit_path,
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
],
)
assert result.exit_code == 0
assert Path(junit_path).exists()
def test_junit_output_on_failure(self, tmp_path: Path) -> None:
from click.testing import CliRunner
roles_root = tmp_path / "ansible" / "roles"
(roles_root / "gitea-runner").mkdir(parents=True)
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
with (
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 1
proc.returncode = 1
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
[
"--roles-root",
str(roles_root),
"--junit-output",
junit_path,
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
],
)
assert result.exit_code == 1
assert Path(junit_path).exists()
tree = ET.parse(junit_path)
assert tree.getroot().get("failures") == "1"
def test_junit_output_on_cancellation(self, tmp_path: Path) -> None:
"""JUnit report is written when a runner is cancelled by another runner's failure."""
from click.testing import CliRunner
real_sleep = time.sleep
roles_root = tmp_path / "ansible" / "roles"
(roles_root / "gitea-runner").mkdir(parents=True)
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
return [
{"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/infra",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg"),
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
[
"--roles-root",
str(roles_root),
"--junit-output",
junit_path,
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
],
)
assert result.exit_code == 1
assert Path(junit_path).exists()
tree = ET.parse(junit_path)
root = tree.getroot()
assert root.get("failures") == "1"
# The failure message should mention cancellation
failure = root[0][0]
assert "Cancelled" in (failure.text or "")
@@ -208,3 +208,14 @@ class TestMain:
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code != 0
@patch("devx.molecule.discover_runners.get_runner_count", return_value=2)
def test_explicit_owner_and_repo(self, mock_count: MagicMock) -> None:
"""When --owner and --repo are provided, env vars are not used."""
runner = CliRunner()
result = runner.invoke(main, ["--owner", "myorg", "--repo", "myrepo"])
assert result.exit_code == 0
mock_count.assert_called_once()
args, kwargs = mock_count.call_args
assert "myorg" in args
assert "myrepo" in args
+61
View File
@@ -129,6 +129,18 @@ class TestCheckArchitectureCompliance:
assert result.has_issues
assert "os.system" in result.issues[0]["body"]
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": "@@ -1,2 @@\n+ subprocess.run(['ls'])\n",
}
]
check_architecture_compliance(files, result)
assert result.has_issues
class TestCheckBestPractices:
def test_print_triggers_warning(self) -> None:
@@ -190,6 +202,19 @@ class TestCheckBestPractices:
check_best_practices(files, result)
assert not result.has_issues
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": "@@ -1,2 @@\n+ print('hello')\n",
}
]
check_best_practices(files, result)
assert result.has_issues
assert "print()" in result.issues[0]["body"]
class TestCheckSecurity:
def test_hardcoded_secret_triggers_error(self) -> None:
@@ -239,6 +264,19 @@ class TestCheckSecurity:
check_security(files, result)
assert not result.has_issues
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [
{
"filename": "src/devx/config.py",
"patch": "@@ -1,2 @@\n+ token = 'abc123secrettoken456'\n",
}
]
check_security(files, result)
assert result.has_issues
assert "secret" in result.issues[0]["body"].lower()
class TestCheckI18n:
def test_raw_string_in_echo_triggers_warning(self) -> None:
@@ -295,6 +333,14 @@ class TestCheckI18n:
check_i18n(files, result)
assert any("i18n: OK" in s for s in result.summary)
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,2 @@\n+click.echo("Hello world")\n'}]
check_i18n(files, result)
assert result.has_issues
assert any("i18n" in i["body"] for i in result.issues)
class TestCheckResourceManagement:
def test_open_without_with_triggers_warning(self) -> None:
@@ -366,6 +412,14 @@ class TestCheckResourceManagement:
check_resource_management(files, result)
assert any("Resource management: OK" in s for s in result.summary)
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,2 @@\n+f = open("file.txt")\n'}]
check_resource_management(files, result)
assert result.has_issues
assert any("resource" in i["body"].lower() for i in result.issues)
class TestCheckFunctionLength:
def test_long_function_triggers_warning(self) -> None:
@@ -429,6 +483,13 @@ class TestCheckFunctionLength:
assert result.has_issues
assert "foo" in result.issues[0]["body"]
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": "@@ -1,2 @@\n+def foo():\n+ pass\n"}]
check_function_length(files, result)
assert not result.has_issues
class TestCheckDocumentation:
def test_src_changes_without_docs_warns(self) -> None:
+137
View File
@@ -0,0 +1,137 @@
"""Unit tests for devx.tools.pre_push_check."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.tools.pre_push_check import (
cli,
extract_task_id,
get_current_branch,
task_exists,
validate,
)
class TestExtractTaskId:
def test_valid_branch(self) -> None:
assert extract_task_id("DEVX-42-fix-bug") == "DEVX-42"
def test_no_task_id(self) -> None:
assert extract_task_id("feature-branch") == ""
def test_empty_branch(self) -> None:
assert extract_task_id("") == ""
class TestGetCurrentBranch:
@patch("devx.tools.pre_push_check.subprocess.run")
def test_success(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="DEVX-42-fix\n", returncode=0)
assert get_current_branch() == "DEVX-42-fix"
@patch("devx.tools.pre_push_check.subprocess.run")
def test_failure(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="", returncode=1)
assert get_current_branch() == ""
class TestTaskExists:
@patch("devx.tools.pre_push_check.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-42"}]
mock_client_cls.return_value = mock_client
assert task_exists("DEVX-42") is True
@patch("devx.tools.pre_push_check.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_not_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-99"}]
mock_client_cls.return_value = mock_client
assert task_exists("DEVX-42") is False
@patch.dict("os.environ", {}, clear=True)
def test_no_token(self) -> None:
assert task_exists("DEVX-42") is False
@patch("devx.tools.pre_push_check.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_pagination(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
# First page: full page (50 items, none matching), second page: match
page1 = [{"identifier": f"OTHER-{i}"} for i in range(50)]
page2 = [{"identifier": "DEVX-42"}]
mock_client.list_project_tasks.side_effect = [page1, page2]
mock_client_cls.return_value = mock_client
assert task_exists("DEVX-42") is True
@patch("devx.tools.pre_push_check.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_empty_project(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
mock_client_cls.return_value = mock_client
assert task_exists("DEVX-42") is False
@patch("devx.tools.pre_push_check.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_pagination_not_found(self, mock_client_cls: MagicMock) -> None:
from devx.config import DEFAULT_PER_PAGE
mock_client = MagicMock()
page1 = [{"identifier": f"OTHER-{i}"} for i in range(DEFAULT_PER_PAGE)]
page2 = [{"identifier": "OTHER-99"}]
mock_client.list_project_tasks.side_effect = [page1, page2]
mock_client_cls.return_value = mock_client
assert task_exists("DEVX-42") is False
class TestValidate:
def test_master_branch_skips(self) -> None:
validate("master")
def test_main_branch_skips(self) -> None:
validate("main")
def test_empty_branch_skips(self) -> None:
validate("")
def test_no_task_id_raises(self) -> None:
with pytest.raises(click.ClickException, match="does not contain a task ID"):
validate("feature-branch")
@patch.dict("os.environ", {}, clear=True)
def test_no_token_warns(self) -> None:
validate("DEVX-42-fix-bug")
@patch("devx.tools.pre_push_check.task_exists", return_value=True)
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_task_exists_passes(self, mock_exists: MagicMock) -> None:
validate("DEVX-42-fix-bug")
@patch("devx.tools.pre_push_check.task_exists", return_value=False)
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_task_not_found_raises(self, mock_exists: MagicMock) -> None:
with pytest.raises(click.ClickException, match="not found"):
validate("DEVX-42-fix-bug")
class TestCli:
@patch("devx.tools.pre_push_check.get_current_branch", return_value="master")
def test_auto_detect_master(self, mock_branch: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
@patch("devx.tools.pre_push_check.task_exists", return_value=True)
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_explicit_branch(self, mock_exists: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "DEVX-42-fix"])
assert result.exit_code == 0
assert "passed" in result.output
+225 -4
View File
@@ -11,6 +11,8 @@ from devx.ci.publish import (
_default_gitea_registry_url,
build_package,
generate_release_notes,
get_latest_tag,
is_release_commit,
main,
publish_to_gitea_registry,
publish_to_pypi,
@@ -122,11 +124,18 @@ class TestPublishToGiteaRegistry:
@patch("devx.ci.publish.subprocess.run")
def test_failure_raises(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stderr="registry upload failed")
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="registry upload failed")
with pytest.raises(click.ClickException) as exc:
publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok")
assert "Gitea PyPI registry" in str(exc.value)
@patch("devx.ci.publish.subprocess.run")
def test_409_conflict_is_non_fatal(self, mock_run: MagicMock) -> None:
"""409 Conflict (already published) should not raise — just continue."""
mock_run.return_value = MagicMock(returncode=1, stdout="ERROR 409 Conflict from url", stderr="")
# Should not raise
publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok")
class TestDefaultGiteaRegistryUrl:
@patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True)
@@ -147,6 +156,13 @@ class TestDefaultGiteaRegistryUrl:
url = _default_gitea_registry_url()
assert "oblachno-oss" in url
@patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True)
@patch("devx.ci.publish.GITEA_API_URL", "https://git.example.com/")
def test_no_api_suffix(self) -> None:
"""URL without /api/v1 or /api suffix is used as-is."""
url = _default_gitea_registry_url()
assert url == "https://git.example.com/api/packages/myorg/pypi"
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@@ -162,6 +178,7 @@ class TestMain:
mock_notes: MagicMock,
) -> None:
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
@@ -187,6 +204,7 @@ class TestMain:
) -> None:
"""When no PYPI_TOKEN, publishes to Gitea PyPI registry."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
@@ -209,6 +227,7 @@ class TestMain:
) -> None:
"""--registry-url flag publishes to the specified Gitea registry."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
@@ -236,6 +255,7 @@ class TestMain:
) -> None:
"""DEVX_PYPI_REGISTRY_URL env var sets the registry URL."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
@@ -256,6 +276,7 @@ class TestMain:
) -> None:
"""When no PYPI_TOKEN and no registry URL, skips publish and creates release only."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--registry-url", ""])
@@ -289,14 +310,21 @@ class TestMain:
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
def test_publish_failure_raises_click(
def test_publish_failure_continues_to_gitea_release(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
"""PyPI publish failure is non-fatal — Gitea release is still created."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
mock_publish.side_effect = click.ClickException("publish failed")
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1
assert "publish" in result.output
assert result.exit_code == 0
assert "non-fatal" in result.output
mock_tea.create_release.assert_called_once_with(
"owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes"
)
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@@ -307,6 +335,7 @@ class TestMain:
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea.create_release.side_effect = TeaCLIError("server error")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
@@ -323,6 +352,7 @@ class TestMain:
) -> None:
"""--skip-build skips build_package and PyPI publish, only creates Gitea release."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
@@ -330,3 +360,194 @@ class TestMain:
assert "skip" in result.output.lower()
mock_build.assert_not_called()
mock_tea.create_release.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
def test_skips_release_creation_when_already_exists(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
"""If the Gitea release already exists, skip creation (idempotent)."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = [{"tag_name": "v1.0.0"}]
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0
assert "already exists" in result.output
mock_tea.create_release.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
def test_proceeds_to_create_when_list_releases_fails(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
"""If list_releases raises TeaCLIError, proceed to create the release."""
mock_tea = MagicMock()
mock_tea.list_releases.side_effect = TeaCLIError("api error")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_gitea_registry")
@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_gitea_pub: 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_gitea_registry")
@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_gitea_pub: MagicMock,
mock_tea_cls: MagicMock,
mock_notes: MagicMock,
) -> None:
"""If create_release fails with a non-'already exists' error, raise."""
mock_tea = MagicMock()
mock_tea.list_releases.side_effect = TeaCLIError("api error")
mock_tea.create_release.side_effect = TeaCLIError("network error")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code != 0
assert "Release creation failed" in result.output
class TestFromTag:
def test_get_latest_tag_success(self) -> None:
import subprocess
with patch("devx.ci.publish.subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="v1.2.3\n")
result = get_latest_tag()
assert result == "v1.2.3"
def test_get_latest_tag_no_tags(self) -> None:
import subprocess
with patch("devx.ci.publish.subprocess.run") as mock_run:
mock_run.side_effect = subprocess.CalledProcessError(1, [])
result = get_latest_tag()
assert result is None
def test_is_release_commit_match(self) -> None:
import subprocess
with patch("devx.ci.publish.subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(
args=[], returncode=0, stdout="release: v1.2.3 [skip ci]\n"
)
result = is_release_commit("v1.2.3")
assert result is True
def test_is_release_commit_no_match(self) -> None:
import subprocess
with patch("devx.ci.publish.subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: add feature\n")
result = is_release_commit("v1.2.3")
assert result is False
def test_is_release_commit_git_error(self) -> None:
import subprocess
with patch("devx.ci.publish.subprocess.run") as mock_run:
mock_run.side_effect = subprocess.CalledProcessError(1, [])
result = is_release_commit("v1.2.3")
assert result is False
@patch("devx.ci.publish.get_latest_tag", return_value=None)
def test_from_tag_no_tag_skips(self, _mock: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"])
assert result.exit_code == 0
assert "No tag found" in result.output
@patch("devx.ci.publish.get_latest_tag", return_value=None)
def test_from_tag_no_repo_uses_env(self, _mock: MagicMock) -> None:
runner = CliRunner()
with patch.dict("os.environ", {"GITHUB_REPOSITORY": "owner/repo"}):
result = runner.invoke(main, ["--from-tag", "--skip-build"])
assert result.exit_code == 0
assert "No tag found" in result.output
@patch("devx.ci.publish.get_latest_tag", return_value=None)
def test_from_tag_no_repo_no_env_raises(self, _mock: MagicMock) -> None:
runner = CliRunner()
with patch.dict("os.environ", {}, clear=True):
result = runner.invoke(main, ["--from-tag", "--skip-build"])
assert result.exit_code != 0
assert "REPO argument is required" in result.output
@patch("devx.ci.publish.is_release_commit", return_value=False)
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0")
def test_from_tag_not_release_commit_skips(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"])
assert result.exit_code == 0
assert "not a release commit" in result.output
@patch("devx.ci.publish.is_release_commit", return_value=True)
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0")
def test_from_tag_publishes(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
with patch("devx.ci.publish.TeaCLI") as mock_tea_cls:
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
with patch("devx.ci.publish.generate_release_notes", return_value="notes"):
runner = CliRunner()
result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"])
assert result.exit_code == 0
assert "Publishing release v1.0.0" in result.output
@patch("devx.ci.publish.is_release_commit", return_value=True)
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0")
def test_from_tag_publishes_no_repo_arg(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
with patch.dict("os.environ", {"REPO_TOKEN": "fake", "GITHUB_REPOSITORY": "owner/repo"}):
with patch("devx.ci.publish.TeaCLI") as mock_tea_cls:
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
with patch("devx.ci.publish.generate_release_notes", return_value="notes"):
runner = CliRunner()
result = runner.invoke(main, ["--from-tag", "--skip-build"])
assert result.exit_code == 0
assert "Publishing release v1.0.0" in result.output
def test_no_tag_no_from_tag_raises(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["", "owner/repo", "--skip-build"])
assert result.exit_code != 0
assert "Tag is required" in result.output
+35 -1
View File
@@ -508,6 +508,30 @@ class TestVerifyAlignment:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 1
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@patch("devx.ci.release.verify_tag_consistency")
@patch("devx.ci.release.get_all_tags")
@patch("devx.ci.release.get_latest_tag")
def test_no_latest_tag_skips_changelog_tag_check(
self,
mock_lt: MagicMock,
mock_tags: MagicMock,
mock_vtc: MagicMock,
mock_iv: MagicMock,
mock_cv: MagicMock,
mock_run_cmd: MagicMock,
) -> None:
"""When there is no latest tag, the CHANGELOG/tag match check is skipped."""
mock_lt.return_value = None # no tags
mock_tags.return_value = []
mock_vtc.return_value = []
mock_iv.return_value = "0.4.4"
mock_cv.return_value = ["0.4.4"] # changelog has versions but no tag to compare
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert verify_alignment() == 0
@patch("devx.ci.release.run_cmd")
@patch("devx.ci.release.get_changelog_versions")
@patch("devx.ci.release.get_init_version")
@@ -756,6 +780,16 @@ class TestUpdateChangelog:
assert "# Changelog" not in content
assert "## [0.2.0]" in content
def test_no_version_section_in_changelog(self, tmp_path, monkeypatch) -> None:
"""Changelog input without any ## [ version section is inserted as-is."""
changelog_file = tmp_path / "CHANGELOG.md"
changelog_file.write_text("# Changelog\n\n## [0.1.0] - 2026-06-20\n\n### Features\n- old thing\n")
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", str(changelog_file))
# No ## [ section in the cliff output — should not be stripped
update_changelog("Some raw text without version header")
content = changelog_file.read_text()
assert "Some raw text without version header" in content
class TestCommitReleaseChanges:
@patch("devx.ci.release.run_cmd")
@@ -766,7 +800,7 @@ class TestCommitReleaseChanges:
assert result is True
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "add", "src/devx/__init__.py", "CHANGELOG.md"] in calls
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0 [skip ci]"] in calls
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0"] in calls
@patch("devx.ci.release.run_cmd")
def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None:
+28
View File
@@ -186,6 +186,12 @@ class TestVerify:
mock_run.side_effect = subprocess.TimeoutExpired(cmd="devx", timeout=10)
_verify(".venv/bin") # Should not raise
@patch("devx.tools.setup.subprocess.run")
def test_verify_handles_nonzero_returncode(self, mock_run: MagicMock) -> None:
"""When a tool returns non-zero, it is skipped without raising."""
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
_verify(".venv/bin") # Should not raise
class TestMain:
@patch("devx.tools.setup._configure_tea_login")
@@ -304,6 +310,28 @@ class TestMain:
assert result.exit_code != 0
assert "Bin directory not found" in result.output
@patch("devx.tools.setup._verify")
@patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps")
def test_main_skip_install(
self,
mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock,
mock_verify: MagicMock,
mock_tea: MagicMock,
tmp_path: Path,
) -> None:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
runner = CliRunner()
result = runner.invoke(main, ["--bin", str(bin_dir), "--skip-install"])
assert result.exit_code == 0
mock_install_deps.assert_not_called()
assert "Skipping pip install" in result.output
def test_main_module_block(tmp_path: Path) -> None:
"""Test the __main__ block execution."""
+16
View File
@@ -69,6 +69,22 @@ class TestDiagnoseSocket:
_diagnose_socket()
mock_exists.assert_called_with(DOCKER_SOCK)
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
@patch("devx.molecule.start_docker.os.stat")
@patch("devx.molecule.start_docker.subprocess.run")
def test_docker_info_no_matching_lines(
self, mock_run: MagicMock, mock_stat: MagicMock, mock_exists: MagicMock
) -> None:
"""docker info succeeds but stdout has no Server Version/Storage Driver/Root Dir lines."""
mock_stat.return_value = MagicMock(st_mode=0o660, st_uid=0, st_gid=0)
mock_run.side_effect = [
MagicMock(stdout="/dev/sda1 /var/lib/docker ext4\n", returncode=0, text=""),
MagicMock(stdout="default\n", returncode=0, text=""),
MagicMock(stdout="Containers: 0\nImages: 0\nKernel: 6.1\n", returncode=0, text=""),
]
_diagnose_socket()
mock_exists.assert_called_with(DOCKER_SOCK)
class TestStartDockerDaemon:
@patch("devx.molecule.start_docker._diagnose_socket")
+44 -2
View File
@@ -7,7 +7,7 @@ from unittest.mock import patch
from click.testing import CliRunner
from devx.ci.validate_commit_msg import first_line, get_branch, main
from devx.ci.validate_commit_msg import first_line, get_branch, get_latest_commit_msg, main
from devx.config import CONVENTIONAL_RE, TASK_ID_RE
@@ -126,7 +126,7 @@ class TestMain:
def test_usage_message_without_args(self) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 2
assert result.exit_code != 0
def test_branch_override_accepts_master_commit(self) -> None:
"""--branch master overrides branch detection (for CI use)."""
@@ -257,3 +257,45 @@ def test_main_module_block() -> None:
namespace["main"]([msg_path], standalone_mode=False)
os.unlink(msg_path)
class TestGitMode:
def test_git_flag_reads_from_git(self, tmp_path) -> None:
with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value="feat: add feature"):
with patch("devx.ci.validate_commit_msg.get_branch", return_value="feature-branch"):
runner = CliRunner()
result = runner.invoke(main, ["--git"])
assert result.exit_code == 0
def test_git_flag_master_valid(self) -> None:
msg = "DEVX-24: fix: resolve timeout"
with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value=msg):
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
runner = CliRunner()
result = runner.invoke(main, ["--git", "--branch", "master"])
assert result.exit_code == 0
def test_git_flag_master_invalid(self) -> None:
msg = "fix: resolve timeout"
with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value=msg):
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
runner = CliRunner()
result = runner.invoke(main, ["--git", "--branch", "master"])
assert result.exit_code != 0
def test_no_file_no_git_raises(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--branch", "master"])
assert result.exit_code != 0
def test_get_latest_commit_msg_success(self) -> None:
with patch("subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: test\n\nBody")
result = get_latest_commit_msg()
assert result == "feat: test\n\nBody"
def test_stdin_input(self) -> None:
with patch("devx.ci.validate_commit_msg.get_branch", return_value="feature-branch"):
runner = CliRunner()
result = runner.invoke(main, input="feat: add feature\n", args=["-", "--branch", "feature-branch"])
assert result.exit_code == 0